diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index bbd1d2b41..765705989 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -12,7 +12,7 @@ concurrency: cancel-in-progress: true env: - COMPILER_IMAGE: stashapp/compiler:6 + COMPILER_IMAGE: stashapp/compiler:7 jobs: build: @@ -27,7 +27,7 @@ jobs: run: docker pull $COMPILER_IMAGE - name: Cache node modules - uses: actions/cache@v2 + uses: actions/cache@v3 env: cache-name: cache-node_modules with: @@ -35,7 +35,7 @@ jobs: key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('ui/v2.5/yarn.lock') }} - name: Cache UI build - uses: actions/cache@v2 + uses: actions/cache@v3 id: cache-ui env: cache-name: cache-ui @@ -44,7 +44,7 @@ jobs: key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('ui/v2.5/yarn.lock', 'ui/v2.5/public/**', 'ui/v2.5/src/**', 'graphql/**/*.graphql') }} - name: Cache go build - uses: actions/cache@v2 + uses: actions/cache@v3 env: # increment the number suffix to bump the cache cache-name: cache-go-cache-1 diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml index cc07455e2..3d15f0c48 100644 --- a/.github/workflows/golangci-lint.yml +++ b/.github/workflows/golangci-lint.yml @@ -9,7 +9,7 @@ on: pull_request: env: - COMPILER_IMAGE: stashapp/compiler:6 + COMPILER_IMAGE: stashapp/compiler:7 jobs: golangci: diff --git a/.gitignore b/.gitignore index 34494cd8d..d3a1c21f0 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,12 @@ ui/v2.5/src/core/generated-*.tsx # Jetbrains #### + +#### +# Visual Studio +#### +/.vs + # User-specific stuff .idea/**/workspace.xml .idea/**/tasks.xml @@ -57,4 +63,4 @@ node_modules /stash dist -.DS_Store +.DS_Store \ No newline at end of file diff --git a/Makefile b/Makefile index 8c6d613eb..fe7628a56 100644 --- a/Makefile +++ b/Makefile @@ -14,11 +14,6 @@ else SET := export endif -IS_WIN_OS = -ifeq ($(OS),Windows_NT) - IS_WIN_OS = true -endif - # set LDFLAGS environment variable to any extra ldflags required # set OUTPUT to generate a specific binary name @@ -29,9 +24,14 @@ endif export CGO_ENABLED = 1 +# including netgo causes name resolution to go through the Go resolver +# and isn't necessary for static builds on Windows +GO_BUILD_TAGS_WINDOWS := sqlite_omit_load_extension sqlite_stat4 osusergo +GO_BUILD_TAGS_DEFAULT = $(GO_BUILD_TAGS_WINDOWS) netgo + .PHONY: release pre-build -release: generate ui build-release +release: pre-ui generate ui build-release pre-build: ifndef BUILD_DATE @@ -47,14 +47,21 @@ ifndef STASH_VERSION endif ifndef OFFICIAL_BUILD - $(eval OFFICIAL_BUILD := false) + $(eval OFFICIAL_BUILD := false) endif +ifndef GO_BUILD_TAGS + $(eval GO_BUILD_TAGS := $(GO_BUILD_TAGS_DEFAULT)) +endif + + +# NOTE: the build target still includes netgo because we cannot detect +# Windows easily from the Makefile. build: pre-build build: $(eval LDFLAGS := $(LDFLAGS) -X 'github.com/stashapp/stash/internal/api.version=$(STASH_VERSION)' -X 'github.com/stashapp/stash/internal/api.buildstamp=$(BUILD_DATE)' -X 'github.com/stashapp/stash/internal/api.githash=$(GITHASH)') $(eval LDFLAGS := $(LDFLAGS) -X 'github.com/stashapp/stash/internal/manager/config.officialBuild=$(OFFICIAL_BUILD)') - go build $(OUTPUT) -mod=vendor -v -tags "sqlite_omit_load_extension sqlite_stat4 osusergo netgo" $(GO_BUILD_FLAGS) -ldflags "$(LDFLAGS) $(EXTRA_LDFLAGS) $(PLATFORM_SPECIFIC_LDFLAGS)" ./cmd/stash + go build $(OUTPUT) -mod=vendor -v -tags "$(GO_BUILD_TAGS)" $(GO_BUILD_FLAGS) -ldflags "$(LDFLAGS) $(EXTRA_LDFLAGS) $(PLATFORM_SPECIFIC_LDFLAGS)" ./cmd/stash # strips debug symbols from the release build build-release: EXTRA_LDFLAGS := -s -w @@ -71,6 +78,7 @@ cross-compile-windows: export GOARCH := amd64 cross-compile-windows: export CC := x86_64-w64-mingw32-gcc cross-compile-windows: export CXX := x86_64-w64-mingw32-g++ cross-compile-windows: OUTPUT := -o dist/stash-win.exe +cross-compile-windows: GO_BUILD_TAGS := $(GO_BUILD_TAGS_WINDOWS) cross-compile-windows: build-release-static cross-compile-macos-intel: export GOOS := darwin @@ -78,6 +86,7 @@ cross-compile-macos-intel: export GOARCH := amd64 cross-compile-macos-intel: export CC := o64-clang cross-compile-macos-intel: export CXX := o64-clang++ cross-compile-macos-intel: OUTPUT := -o dist/stash-macos-intel +cross-compile-macos-intel: GO_BUILD_TAGS := $(GO_BUILD_TAGS_DEFAULT) # can't use static build for OSX cross-compile-macos-intel: build-release @@ -86,6 +95,7 @@ cross-compile-macos-applesilicon: export GOARCH := arm64 cross-compile-macos-applesilicon: export CC := oa64e-clang cross-compile-macos-applesilicon: export CXX := oa64e-clang++ cross-compile-macos-applesilicon: OUTPUT := -o dist/stash-macos-applesilicon +cross-compile-macos-applesilicon: GO_BUILD_TAGS := $(GO_BUILD_TAGS_DEFAULT) # can't use static build for OSX cross-compile-macos-applesilicon: build-release @@ -106,17 +116,20 @@ cross-compile-macos: cross-compile-freebsd: export GOOS := freebsd cross-compile-freebsd: export GOARCH := amd64 cross-compile-freebsd: OUTPUT := -o dist/stash-freebsd +cross-compile-freebsd: GO_BUILD_TAGS += netgo cross-compile-freebsd: build-release-static cross-compile-linux: export GOOS := linux cross-compile-linux: export GOARCH := amd64 cross-compile-linux: OUTPUT := -o dist/stash-linux +cross-compile-linux: GO_BUILD_TAGS := $(GO_BUILD_TAGS_DEFAULT) cross-compile-linux: build-release-static cross-compile-linux-arm64v8: export GOOS := linux cross-compile-linux-arm64v8: export GOARCH := arm64 cross-compile-linux-arm64v8: export CC := aarch64-linux-gnu-gcc cross-compile-linux-arm64v8: OUTPUT := -o dist/stash-linux-arm64v8 +cross-compile-linux-arm64v8: GO_BUILD_TAGS := $(GO_BUILD_TAGS_DEFAULT) cross-compile-linux-arm64v8: build-release-static cross-compile-linux-arm32v7: export GOOS := linux @@ -124,6 +137,7 @@ cross-compile-linux-arm32v7: export GOARCH := arm cross-compile-linux-arm32v7: export GOARM := 7 cross-compile-linux-arm32v7: export CC := arm-linux-gnueabihf-gcc cross-compile-linux-arm32v7: OUTPUT := -o dist/stash-linux-arm32v7 +cross-compile-linux-arm32v7: GO_BUILD_TAGS := $(GO_BUILD_TAGS_DEFAULT) cross-compile-linux-arm32v7: build-release-static cross-compile-linux-arm32v6: export GOOS := linux @@ -131,11 +145,13 @@ cross-compile-linux-arm32v6: export GOARCH := arm cross-compile-linux-arm32v6: export GOARM := 6 cross-compile-linux-arm32v6: export CC := arm-linux-gnueabi-gcc cross-compile-linux-arm32v6: OUTPUT := -o dist/stash-linux-arm32v6 +cross-compile-linux-arm32v6: GO_BUILD_TAGS := $(GO_BUILD_TAGS_DEFAULT) cross-compile-linux-arm32v6: build-release-static cross-compile-all: make cross-compile-windows - make cross-compile-macos + make cross-compile-macos-intel + make cross-compile-macos-applesilicon make cross-compile-linux make cross-compile-linux-arm64v8 make cross-compile-linux-arm32v7 diff --git a/README.md b/README.md index ddfb8159a..0fe3139d7 100644 --- a/README.md +++ b/README.md @@ -45,9 +45,9 @@ Many community-maintained scrapers are available for download at the [Community # Translation [![Translate](https://translate.stashapp.cc/widgets/stash/-/stash-desktop-client/svg-badge.svg)](https://translate.stashapp.cc/engage/stash/) -🇧🇷 🇨🇳 🇩🇰 🇳🇱 🇬🇧 🇫🇮 🇫🇷 🇩🇪 🇮🇹 🇯🇵 🇰🇷 🇵🇱 🇪🇸 🇸🇪 🇹🇼 🇹🇷 +🇧🇷 🇨🇳 🇩🇰 🇳🇱 🇬🇧 🇪🇪 🇫🇮 🇫🇷 🇩🇪 🇮🇹 🇯🇵 🇰🇷 🇵🇱 🇷🇺 🇪🇸 🇸🇪 🇹🇼 🇹🇷 -Stash is available in 16 languages (so far!) and it could be in your language too. If you want to help us translate Stash into your language, you can make an account at [translate.stashapp.cc](https://translate.stashapp.cc/projects/stash/stash-desktop-client/) to get started contributing new languages or improving existing ones. Thanks! +Stash is available in 18 languages (so far!) and it could be in your language too. If you want to help us translate Stash into your language, you can make an account at [translate.stashapp.cc](https://translate.stashapp.cc/projects/stash/stash-desktop-client/) to get started contributing new languages or improving existing ones. Thanks! # Support (FAQ) diff --git a/docker/build/x86_64/Dockerfile b/docker/build/x86_64/Dockerfile index 0749ad7c6..5133eba36 100644 --- a/docker/build/x86_64/Dockerfile +++ b/docker/build/x86_64/Dockerfile @@ -16,7 +16,7 @@ ARG STASH_VERSION RUN BUILD_DATE=$(date +"%Y-%m-%d %H:%M:%S") make ui # Build Backend -FROM golang:1.17-alpine as backend +FROM golang:1.19-alpine as backend RUN apk add --no-cache make alpine-sdk WORKDIR /stash COPY ./go* ./*.go Makefile gqlgen.yml .gqlgenc.yml /stash/ diff --git a/docker/ci/x86_64/Dockerfile b/docker/ci/x86_64/Dockerfile index 05fd5cecf..a23763e65 100644 --- a/docker/ci/x86_64/Dockerfile +++ b/docker/ci/x86_64/Dockerfile @@ -12,7 +12,6 @@ RUN if [ "$TARGETPLATFORM" = "linux/arm/v6" ]; then BIN=stash-linux-arm32v6; \ FROM --platform=$TARGETPLATFORM alpine:latest AS app COPY --from=binary /stash /usr/bin/ RUN apk add --no-cache ca-certificates python3 py3-requests py3-requests-toolbelt py3-lxml py3-pip ffmpeg vips-tools ruby && pip install --no-cache-dir mechanicalsoup cloudscraper && gem install faraday -RUN ln -s /usr/bin/python3 /usr/bin/python ENV STASH_CONFIG_FILE=/root/.stash/config.yml EXPOSE 9999 diff --git a/docker/compiler/Dockerfile b/docker/compiler/Dockerfile index d6d54f0cc..be015831d 100644 --- a/docker/compiler/Dockerfile +++ b/docker/compiler/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.17 +FROM golang:1.19 LABEL maintainer="https://discord.gg/2TsNFKt" diff --git a/docker/compiler/Makefile b/docker/compiler/Makefile index 978059b94..e4117a968 100644 --- a/docker/compiler/Makefile +++ b/docker/compiler/Makefile @@ -1,6 +1,6 @@ user=stashapp repo=compiler -version=6 +version=7 latest: docker build -t ${user}/${repo}:latest . diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 2160e4b1c..4df0729c0 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -43,9 +43,10 @@ NOTE: The `make` command in Windows will be `mingw32-make` with MingW. For examp ## Building a release -1. Run `make generate` to create generated files -2. Run `make ui` to compile the frontend -3. Run `make build` to build the executable for your current platform +1. Run `make pre-ui` to install UI dependencies +2. Run `make generate` to create generated files +3. Run `make ui` to compile the frontend +4. Run `make build` to build the executable for your current platform ## Cross compiling diff --git a/go.mod b/go.mod index 4a8f12197..facca58ce 100644 --- a/go.mod +++ b/go.mod @@ -108,4 +108,4 @@ require ( replace git.apache.org/thrift.git => github.com/apache/thrift v0.0.0-20180902110319-2566ecd5d999 -go 1.17 +go 1.19 diff --git a/go.sum b/go.sum index 06230c953..def03da0a 100644 --- a/go.sum +++ b/go.sum @@ -700,7 +700,6 @@ github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6Mwd github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE= github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= -github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog= github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c/go.mod h1:XDJAKZRPZ1CvBcN2aX5YOUTYGHki24fSF0Iv48Ibg0s= github.com/snowflakedb/gosnowflake v1.4.3/go.mod h1:1kyg2XEduwti88V11PKRHImhXLK5WpGiayY6lFNYb98= @@ -773,7 +772,6 @@ github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9dec github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.4.0/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= -github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q= gitlab.com/nyarla/go-crypt v0.0.0-20160106005555-d9a5dc2b789b/go.mod h1:T3BPAOm2cqquPa0MKWeNkmOM5RQsRhkrwMWonFMN7fE= go.etcd.io/etcd/api/v3 v3.5.1/go.mod h1:cbVKeC6lCfl7j/8jBhAK6aIYO9XOjdptoxU/nLQcPvs= @@ -958,7 +956,6 @@ golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20180224232135-f6cff0780e54/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -1049,11 +1046,8 @@ golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20211124211545-fe61309f8881/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211205182925-97ca703d548d/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211210111614-af8b64212486/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220319134239-a9b59b0215f8/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.0.0-20220808155132-1c4a2a72c664 h1:v1W7bwXHsnLLloWYTVEdvGvA7BHMeBYsPcF0GLDxIRs= golang.org/x/sys v0.0.0-20220808155132-1c4a2a72c664/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= diff --git a/graphql/documents/data/config.graphql b/graphql/documents/data/config.graphql index 6241a283c..57f08eb41 100644 --- a/graphql/documents/data/config.graphql +++ b/graphql/documents/data/config.graphql @@ -63,6 +63,8 @@ fragment ConfigInterfaceData on ConfigInterfaceResult { showStudioAsText css cssEnabled + javascript + javascriptEnabled customLocales customLocalesEnabled language diff --git a/graphql/documents/data/file.graphql b/graphql/documents/data/file.graphql index da02f00d6..7acb95feb 100644 --- a/graphql/documents/data/file.graphql +++ b/graphql/documents/data/file.graphql @@ -7,6 +7,7 @@ fragment VideoFileData on VideoFile { id path size + mod_time duration video_codec audio_codec @@ -24,6 +25,7 @@ fragment ImageFileData on ImageFile { id path size + mod_time width height fingerprints { @@ -36,6 +38,7 @@ fragment GalleryFileData on GalleryFile { id path size + mod_time fingerprints { type value diff --git a/graphql/documents/data/gallery-slim.graphql b/graphql/documents/data/gallery-slim.graphql index ea98d30f0..c49ef2c11 100644 --- a/graphql/documents/data/gallery-slim.graphql +++ b/graphql/documents/data/gallery-slim.graphql @@ -4,7 +4,7 @@ fragment SlimGalleryData on Gallery { date url details - rating + rating100 organized files { ...GalleryFileData diff --git a/graphql/documents/data/gallery.graphql b/graphql/documents/data/gallery.graphql index 9d43244e9..bb804047e 100644 --- a/graphql/documents/data/gallery.graphql +++ b/graphql/documents/data/gallery.graphql @@ -6,7 +6,7 @@ fragment GalleryData on Gallery { date url details - rating + rating100 organized files { @@ -16,9 +16,6 @@ fragment GalleryData on Gallery { ...FolderData } - images { - ...SlimImageData - } cover { ...SlimImageData } diff --git a/graphql/documents/data/image-slim.graphql b/graphql/documents/data/image-slim.graphql index 37b0bc86f..b9f891fa8 100644 --- a/graphql/documents/data/image-slim.graphql +++ b/graphql/documents/data/image-slim.graphql @@ -1,7 +1,7 @@ fragment SlimImageData on Image { id title - rating + rating100 organized o_counter diff --git a/graphql/documents/data/image.graphql b/graphql/documents/data/image.graphql index 4fe1f0d0e..8142d9d49 100644 --- a/graphql/documents/data/image.graphql +++ b/graphql/documents/data/image.graphql @@ -1,7 +1,7 @@ fragment ImageData on Image { id title - rating + rating100 organized o_counter created_at diff --git a/graphql/documents/data/movie-slim.graphql b/graphql/documents/data/movie-slim.graphql index 8150986a8..28986b232 100644 --- a/graphql/documents/data/movie-slim.graphql +++ b/graphql/documents/data/movie-slim.graphql @@ -2,4 +2,5 @@ fragment SlimMovieData on Movie { id name front_image_path + rating100 } diff --git a/graphql/documents/data/movie.graphql b/graphql/documents/data/movie.graphql index f566e535d..1605e039e 100644 --- a/graphql/documents/data/movie.graphql +++ b/graphql/documents/data/movie.graphql @@ -5,7 +5,7 @@ fragment MovieData on Movie { aliases duration date - rating + rating100 director studio { diff --git a/graphql/documents/data/performer-slim.graphql b/graphql/documents/data/performer-slim.graphql index 1dd692a6e..6479717f2 100644 --- a/graphql/documents/data/performer-slim.graphql +++ b/graphql/documents/data/performer-slim.graphql @@ -13,7 +13,7 @@ fragment SlimPerformerData on Performer { ethnicity hair_color eye_color - height + height_cm fake_tits career_length tattoos @@ -26,7 +26,7 @@ fragment SlimPerformerData on Performer { endpoint stash_id } - rating + rating100 death_date weight } diff --git a/graphql/documents/data/performer.graphql b/graphql/documents/data/performer.graphql index 4030d6697..ecdb9eacc 100644 --- a/graphql/documents/data/performer.graphql +++ b/graphql/documents/data/performer.graphql @@ -10,7 +10,7 @@ fragment PerformerData on Performer { ethnicity country eye_color - height + height_cm measurements fake_tits career_length @@ -33,7 +33,7 @@ fragment PerformerData on Performer { stash_id endpoint } - rating + rating100 details death_date hair_color diff --git a/graphql/documents/data/scene-slim.graphql b/graphql/documents/data/scene-slim.graphql index 362ac4092..3e0749dd8 100644 --- a/graphql/documents/data/scene-slim.graphql +++ b/graphql/documents/data/scene-slim.graphql @@ -1,14 +1,19 @@ fragment SlimSceneData on Scene { id title + code details + director url date - rating + rating100 o_counter organized interactive interactive_speed + resume_time + play_duration + play_count files { ...VideoFileData @@ -20,7 +25,6 @@ fragment SlimSceneData on Scene { stream webp vtt - chapters_vtt sprite funscript interactive_heatmap diff --git a/graphql/documents/data/scene.graphql b/graphql/documents/data/scene.graphql index 13a672900..8b0a664d5 100644 --- a/graphql/documents/data/scene.graphql +++ b/graphql/documents/data/scene.graphql @@ -1,10 +1,12 @@ fragment SceneData on Scene { id title + code details + director url date - rating + rating100 o_counter organized interactive @@ -15,6 +17,10 @@ fragment SceneData on Scene { } created_at updated_at + resume_time + last_played_at + play_duration + play_count files { ...VideoFileData @@ -26,7 +32,6 @@ fragment SceneData on Scene { stream webp vtt - chapters_vtt sprite funscript interactive_heatmap diff --git a/graphql/documents/data/scrapers.graphql b/graphql/documents/data/scrapers.graphql index 7c4632b95..802220293 100644 --- a/graphql/documents/data/scrapers.graphql +++ b/graphql/documents/data/scrapers.graphql @@ -105,7 +105,9 @@ fragment ScrapedSceneTagData on ScrapedTag { fragment ScrapedSceneData on ScrapedScene { title + code details + director url date image @@ -166,7 +168,9 @@ fragment ScrapedGalleryData on ScrapedGallery { fragment ScrapedStashBoxSceneData on ScrapedScene { title + code details + director url date image diff --git a/graphql/documents/data/studio-slim.graphql b/graphql/documents/data/studio-slim.graphql index 36b0fd287..c37513194 100644 --- a/graphql/documents/data/studio-slim.graphql +++ b/graphql/documents/data/studio-slim.graphql @@ -10,6 +10,6 @@ fragment SlimStudioData on Studio { id } details - rating + rating100 aliases } diff --git a/graphql/documents/data/studio.graphql b/graphql/documents/data/studio.graphql index 94e118b6b..52fd8b418 100644 --- a/graphql/documents/data/studio.graphql +++ b/graphql/documents/data/studio.graphql @@ -25,6 +25,6 @@ fragment StudioData on Studio { endpoint } details - rating + rating100 aliases } diff --git a/graphql/documents/mutations/scene.graphql b/graphql/documents/mutations/scene.graphql index c9763e84a..8da4b3bd9 100644 --- a/graphql/documents/mutations/scene.graphql +++ b/graphql/documents/mutations/scene.graphql @@ -1,3 +1,11 @@ +mutation SceneCreate( + $input: SceneCreateInput!) { + + sceneCreate(input: $input) { + ...SceneData + } +} + mutation SceneUpdate( $input: SceneUpdateInput!) { @@ -20,6 +28,14 @@ mutation ScenesUpdate($input : [SceneUpdateInput!]!) { } } +mutation SceneSaveActivity($id: ID!, $resume_time: Float, $playDuration: Float) { + sceneSaveActivity(id: $id, resume_time: $resume_time, playDuration: $playDuration) +} + +mutation SceneIncrementPlayCount($id: ID!) { + sceneIncrementPlayCount(id: $id) +} + mutation SceneIncrementO($id: ID!) { sceneIncrementO(id: $id) } @@ -43,3 +59,13 @@ mutation ScenesDestroy($ids: [ID!]!, $delete_file: Boolean, $delete_generated : mutation SceneGenerateScreenshot($id: ID!, $at: Float) { sceneGenerateScreenshot(id: $id, at: $at) } + +mutation SceneAssignFile($input: AssignSceneFileInput!) { + sceneAssignFile(input: $input) +} + +mutation SceneMerge($input: SceneMergeInput!) { + sceneMerge(input: $input) { + id + } +} \ No newline at end of file diff --git a/graphql/documents/queries/scene.graphql b/graphql/documents/queries/scene.graphql index c34222b66..1f762855a 100644 --- a/graphql/documents/queries/scene.graphql +++ b/graphql/documents/queries/scene.graphql @@ -52,7 +52,9 @@ query ParseSceneFilenames($filter: FindFilterType!, $config: SceneParserInput!) ...SlimSceneData } title + code details + director url date rating diff --git a/graphql/schema/schema.graphql b/graphql/schema/schema.graphql index 15dd669e6..959e52b99 100644 --- a/graphql/schema/schema.graphql +++ b/graphql/schema/schema.graphql @@ -162,7 +162,9 @@ type Mutation { setup(input: SetupInput!): Boolean! migrate(input: MigrateInput!): Boolean! + sceneCreate(input: SceneCreateInput!): Scene sceneUpdate(input: SceneUpdateInput!): Scene + sceneMerge(input: SceneMergeInput!): Scene bulkSceneUpdate(input: BulkSceneUpdateInput!): [Scene!] sceneDestroy(input: SceneDestroyInput!): Boolean! scenesDestroy(input: ScenesDestroyInput!): Boolean! @@ -175,6 +177,12 @@ type Mutation { """Resets the o-counter for a scene to 0. Returns the new value""" sceneResetO(id: ID!): Int! + """Sets the resume time point (if provided) and adds the provided duration to the scene's play duration""" + sceneSaveActivity(id: ID!, resume_time: Float, playDuration: Float): Boolean! + + """Increments the play count for the scene. Returns the new play count value.""" + sceneIncrementPlayCount(id: ID!): Int! + """Generates screenshot at specified time in seconds. Leave empty to generate default screenshot""" sceneGenerateScreenshot(id: ID!, at: Float): String! @@ -182,6 +190,8 @@ type Mutation { sceneMarkerUpdate(input: SceneMarkerUpdateInput!): SceneMarker sceneMarkerDestroy(id: ID!): Boolean! + sceneAssignFile(input: AssignSceneFileInput!): Boolean! + imageUpdate(input: ImageUpdateInput!): Image bulkImageUpdate(input: BulkImageUpdateInput!): [Image!] imageDestroy(input: ImageDestroyInput!): Boolean! diff --git a/graphql/schema/types/config.graphql b/graphql/schema/types/config.graphql index 48f2de3dc..7cd1fea5f 100644 --- a/graphql/schema/types/config.graphql +++ b/graphql/schema/types/config.graphql @@ -264,6 +264,10 @@ input ConfigInterfaceInput { css: String cssEnabled: Boolean + """Custom Javascript""" + javascript: String + javascriptEnabled: Boolean + """Custom Locales""" customLocales: String customLocalesEnabled: Boolean @@ -330,6 +334,10 @@ type ConfigInterfaceResult { css: String cssEnabled: Boolean + """Custom Javascript""" + javascript: String + javascriptEnabled: Boolean + """Custom Locales""" customLocales: String customLocalesEnabled: Boolean diff --git a/graphql/schema/types/filters.graphql b/graphql/schema/types/filters.graphql index 850d46ad9..b391ef085 100644 --- a/graphql/schema/types/filters.graphql +++ b/graphql/schema/types/filters.graphql @@ -39,6 +39,14 @@ input PHashDuplicationCriterionInput { distance: Int } +input StashIDCriterionInput { + """If present, this value is treated as a predicate. + That is, it will filter based on stash_ids with the matching endpoint""" + endpoint: String + stash_id: String + modifier: CriterionModifier! +} + input PerformerFilterType { AND: PerformerFilterType OR: PerformerFilterType @@ -60,7 +68,9 @@ input PerformerFilterType { """Filter by eye color""" eye_color: StringCriterionInput """Filter by height""" - height: StringCriterionInput + height: StringCriterionInput @deprecated(reason: "Use height_cm instead") + """Filter by height in cm""" + height_cm: IntCriterionInput """Filter by measurements""" measurements: StringCriterionInput """Filter by fake tits value""" @@ -88,9 +98,13 @@ input PerformerFilterType { """Filter by gallery count""" gallery_count: IntCriterionInput """Filter by StashID""" - stash_id: StringCriterionInput + stash_id: StringCriterionInput @deprecated(reason: "Use stash_id_endpoint instead") + """Filter by StashID""" + stash_id_endpoint: StashIDCriterionInput """Filter by rating""" - rating: IntCriterionInput + rating: IntCriterionInput @deprecated(reason: "Use 1-100 range with rating100") + # rating expressed as 1-100 + rating100: IntCriterionInput """Filter by url""" url: StringCriterionInput """Filter by hair color""" @@ -103,6 +117,14 @@ input PerformerFilterType { studios: HierarchicalMultiCriterionInput """Filter by autotag ignore value""" ignore_auto_tag: Boolean + """Filter by birthdate""" + birthdate: DateCriterionInput + """Filter by death date""" + death_date: DateCriterionInput + """Filter by creation time""" + created_at: TimestampCriterionInput + """Filter by last update time""" + updated_at: TimestampCriterionInput } input SceneMarkerFilterType { @@ -114,6 +136,16 @@ input SceneMarkerFilterType { scene_tags: HierarchicalMultiCriterionInput """Filter to only include scene markers with these performers""" performers: MultiCriterionInput + """Filter by creation time""" + created_at: TimestampCriterionInput + """Filter by last update time""" + updated_at: TimestampCriterionInput + """Filter by scene date""" + scene_date: DateCriterionInput + """Filter by cscene reation time""" + scene_created_at: TimestampCriterionInput + """Filter by lscene ast update time""" + scene_updated_at: TimestampCriterionInput } input SceneFilterType { @@ -121,8 +153,11 @@ input SceneFilterType { OR: SceneFilterType NOT: SceneFilterType + id: IntCriterionInput title: StringCriterionInput + code: StringCriterionInput details: StringCriterionInput + director: StringCriterionInput """Filter by file oshash""" oshash: StringCriterionInput @@ -135,7 +170,9 @@ input SceneFilterType { """Filter by file count""" file_count: IntCriterionInput """Filter by rating""" - rating: IntCriterionInput + rating: IntCriterionInput @deprecated(reason: "Use 1-100 range with rating100") + # rating expressed as 1-100 + rating100: IntCriterionInput """Filter by organized""" organized: Boolean """Filter by o-counter""" @@ -169,7 +206,9 @@ input SceneFilterType { """Filter by performer count""" performer_count: IntCriterionInput """Filter by StashID""" - stash_id: StringCriterionInput + stash_id: StringCriterionInput @deprecated(reason: "Use stash_id_endpoint instead") + """Filter by StashID""" + stash_id_endpoint: StashIDCriterionInput """Filter by url""" url: StringCriterionInput """Filter by interactive""" @@ -178,6 +217,18 @@ input SceneFilterType { interactive_speed: IntCriterionInput """Filter by captions""" captions: StringCriterionInput + """Filter by resume time""" + resume_time: IntCriterionInput + """Filter by play count""" + play_count: IntCriterionInput + """Filter by play duration (in seconds)""" + play_duration: IntCriterionInput + """Filter by date""" + date: DateCriterionInput + """Filter by creation time""" + created_at: TimestampCriterionInput + """Filter by last update time""" + updated_at: TimestampCriterionInput } input MovieFilterType { @@ -189,7 +240,9 @@ input MovieFilterType { """Filter by duration (in seconds)""" duration: IntCriterionInput """Filter by rating""" - rating: IntCriterionInput + rating: IntCriterionInput @deprecated(reason: "Use 1-100 range with rating100") + # rating expressed as 1-100 + rating100: IntCriterionInput """Filter to only include movies with this studio""" studios: HierarchicalMultiCriterionInput """Filter to only include movies missing this property""" @@ -198,6 +251,12 @@ input MovieFilterType { url: StringCriterionInput """Filter to only include movies where performer appears in a scene""" performers: MultiCriterionInput + """Filter by date""" + date: DateCriterionInput + """Filter by creation time""" + created_at: TimestampCriterionInput + """Filter by last update time""" + updated_at: TimestampCriterionInput } input StudioFilterType { @@ -210,11 +269,15 @@ input StudioFilterType { """Filter to only include studios with this parent studio""" parents: MultiCriterionInput """Filter by StashID""" - stash_id: StringCriterionInput + stash_id: StringCriterionInput @deprecated(reason: "Use stash_id_endpoint instead") + """Filter by StashID""" + stash_id_endpoint: StashIDCriterionInput """Filter to only include studios missing this property""" is_missing: String """Filter by rating""" - rating: IntCriterionInput + rating: IntCriterionInput @deprecated(reason: "Use 1-100 range with rating100") + # rating expressed as 1-100 + rating100: IntCriterionInput """Filter by scene count""" scene_count: IntCriterionInput """Filter by image count""" @@ -227,6 +290,10 @@ input StudioFilterType { aliases: StringCriterionInput """Filter by autotag ignore value""" ignore_auto_tag: Boolean + """Filter by creation time""" + created_at: TimestampCriterionInput + """Filter by last update time""" + updated_at: TimestampCriterionInput } input GalleryFilterType { @@ -234,6 +301,7 @@ input GalleryFilterType { OR: GalleryFilterType NOT: GalleryFilterType + id: IntCriterionInput title: StringCriterionInput details: StringCriterionInput @@ -248,7 +316,9 @@ input GalleryFilterType { """Filter to include/exclude galleries that were created from zip""" is_zip: Boolean """Filter by rating""" - rating: IntCriterionInput + rating: IntCriterionInput @deprecated(reason: "Use 1-100 range with rating100") + # rating expressed as 1-100 + rating100: IntCriterionInput """Filter by organized""" organized: Boolean """Filter by average image resolution""" @@ -273,6 +343,12 @@ input GalleryFilterType { image_count: IntCriterionInput """Filter by url""" url: StringCriterionInput + """Filter by date""" + date: DateCriterionInput + """Filter by creation time""" + created_at: TimestampCriterionInput + """Filter by last update time""" + updated_at: TimestampCriterionInput } input TagFilterType { @@ -286,6 +362,9 @@ input TagFilterType { """Filter by tag aliases""" aliases: StringCriterionInput + """Filter by tag description""" + description: StringCriterionInput + """Filter to only include tags missing this property""" is_missing: String @@ -318,6 +397,12 @@ input TagFilterType { """Filter by autotag ignore value""" ignore_auto_tag: Boolean + + """Filter by creation time""" + created_at: TimestampCriterionInput + + """Filter by last update time""" + updated_at: TimestampCriterionInput } input ImageFilterType { @@ -327,6 +412,8 @@ input ImageFilterType { title: StringCriterionInput + """ Filter by image id""" + id: IntCriterionInput """Filter by file checksum""" checksum: StringCriterionInput """Filter by path""" @@ -334,7 +421,9 @@ input ImageFilterType { """Filter by file count""" file_count: IntCriterionInput """Filter by rating""" - rating: IntCriterionInput + rating: IntCriterionInput @deprecated(reason: "Use 1-100 range with rating100") + # rating expressed as 1-100 + rating100: IntCriterionInput """Filter by organized""" organized: Boolean """Filter by o-counter""" @@ -359,6 +448,10 @@ input ImageFilterType { performer_favorite: Boolean """Filter to only include images with these galleries""" galleries: MultiCriterionInput + """Filter by creation time""" + created_at: TimestampCriterionInput + """Filter by last update time""" + updated_at: TimestampCriterionInput } enum CriterionModifier { @@ -415,6 +508,18 @@ input HierarchicalMultiCriterionInput { depth: Int } +input DateCriterionInput { + value: String! + value2: String + modifier: CriterionModifier! +} + +input TimestampCriterionInput { + value: String! + value2: String + modifier: CriterionModifier! +} + enum FilterMode { SCENES, PERFORMERS, diff --git a/graphql/schema/types/gallery.graphql b/graphql/schema/types/gallery.graphql index 993f5e010..3716b9478 100644 --- a/graphql/schema/types/gallery.graphql +++ b/graphql/schema/types/gallery.graphql @@ -7,7 +7,10 @@ type Gallery { url: String date: String details: String - rating: Int + # rating expressed as 1-5 + rating: Int @deprecated(reason: "Use 1-100 range with rating100") + # rating expressed as 1-100 + rating100: Int organized: Boolean! created_at: Time! updated_at: Time! @@ -23,7 +26,7 @@ type Gallery { performers: [Performer!]! """The images in the gallery""" - images: [Image!]! # Resolver + images: [Image!]! @deprecated(reason: "Use findImages") cover: Image } @@ -32,7 +35,10 @@ input GalleryCreateInput { url: String date: String details: String - rating: Int + # rating expressed as 1-5 + rating: Int @deprecated(reason: "Use 1-100 range with rating100") + # rating expressed as 1-100 + rating100: Int organized: Boolean scene_ids: [ID!] studio_id: ID @@ -47,7 +53,10 @@ input GalleryUpdateInput { url: String date: String details: String - rating: Int + # rating expressed as 1-5 + rating: Int @deprecated(reason: "Use 1-100 range with rating100") + # rating expressed as 1-100 + rating100: Int organized: Boolean scene_ids: [ID!] studio_id: ID @@ -63,7 +72,10 @@ input BulkGalleryUpdateInput { url: String date: String details: String - rating: Int + # rating expressed as 1-5 + rating: Int @deprecated(reason: "Use 1-100 range with rating100") + # rating expressed as 1-100 + rating100: Int organized: Boolean scene_ids: BulkUpdateIds studio_id: ID diff --git a/graphql/schema/types/image.graphql b/graphql/schema/types/image.graphql index 82aa1e443..3eed1ee85 100644 --- a/graphql/schema/types/image.graphql +++ b/graphql/schema/types/image.graphql @@ -2,7 +2,10 @@ type Image { id: ID! checksum: String @deprecated(reason: "Use files.fingerprints") title: String - rating: Int + # rating expressed as 1-5 + rating: Int @deprecated(reason: "Use 1-100 range with rating100") + # rating expressed as 1-100 + rating100: Int o_counter: Int organized: Boolean! path: String! @deprecated(reason: "Use files.path") @@ -37,7 +40,10 @@ input ImageUpdateInput { clientMutationId: String id: ID! title: String - rating: Int + # rating expressed as 1-5 + rating: Int @deprecated(reason: "Use 1-100 range with rating100") + # rating expressed as 1-100 + rating100: Int organized: Boolean studio_id: ID @@ -52,7 +58,10 @@ input BulkImageUpdateInput { clientMutationId: String ids: [ID!] title: String - rating: Int + # rating expressed as 1-5 + rating: Int @deprecated(reason: "Use 1-100 range with rating100") + # rating expressed as 1-100 + rating100: Int organized: Boolean studio_id: ID diff --git a/graphql/schema/types/movie.graphql b/graphql/schema/types/movie.graphql index 3d100e141..14910c003 100644 --- a/graphql/schema/types/movie.graphql +++ b/graphql/schema/types/movie.graphql @@ -6,7 +6,10 @@ type Movie { """Duration in seconds""" duration: Int date: String - rating: Int + # rating expressed as 1-5 + rating: Int @deprecated(reason: "Use 1-100 range with rating100") + # rating expressed as 1-100 + rating100: Int studio: Studio director: String synopsis: String @@ -26,7 +29,10 @@ input MovieCreateInput { """Duration in seconds""" duration: Int date: String - rating: Int + # rating expressed as 1-5 + rating: Int @deprecated(reason: "Use 1-100 range with rating100") + # rating expressed as 1-100 + rating100: Int studio_id: ID director: String synopsis: String @@ -43,7 +49,10 @@ input MovieUpdateInput { aliases: String duration: Int date: String - rating: Int + # rating expressed as 1-5 + rating: Int @deprecated(reason: "Use 1-100 range with rating100") + # rating expressed as 1-100 + rating100: Int studio_id: ID director: String synopsis: String @@ -57,7 +66,10 @@ input MovieUpdateInput { input BulkMovieUpdateInput { clientMutationId: String ids: [ID!] - rating: Int + # rating expressed as 1-5 + rating: Int @deprecated(reason: "Use 1-100 range with rating100") + # rating expressed as 1-100 + rating100: Int studio_id: ID director: String } diff --git a/graphql/schema/types/performer.graphql b/graphql/schema/types/performer.graphql index e69d52e47..651341fc2 100644 --- a/graphql/schema/types/performer.graphql +++ b/graphql/schema/types/performer.graphql @@ -19,7 +19,8 @@ type Performer { ethnicity: String country: String eye_color: String - height: String + height: String @deprecated(reason: "Use height_cm") + height_cm: Int measurements: String fake_tits: String career_length: String @@ -36,7 +37,10 @@ type Performer { gallery_count: Int # Resolver scenes: [Scene!]! stash_ids: [StashID!]! - rating: Int + # rating expressed as 1-5 + rating: Int @deprecated(reason: "Use 1-100 range with rating100") + # rating expressed as 1-100 + rating100: Int details: String death_date: String hair_color: String @@ -55,7 +59,9 @@ input PerformerCreateInput { ethnicity: String country: String eye_color: String - height: String + # height must be parsable into an integer + height: String @deprecated(reason: "Use height_cm") + height_cm: Int measurements: String fake_tits: String career_length: String @@ -69,7 +75,10 @@ input PerformerCreateInput { """This should be a URL or a base64 encoded data URL""" image: String stash_ids: [StashIDInput!] - rating: Int + # rating expressed as 1-5 + rating: Int @deprecated(reason: "Use 1-100 range with rating100") + # rating expressed as 1-100 + rating100: Int details: String death_date: String hair_color: String @@ -86,7 +95,9 @@ input PerformerUpdateInput { ethnicity: String country: String eye_color: String - height: String + # height must be parsable into an integer + height: String @deprecated(reason: "Use height_cm") + height_cm: Int measurements: String fake_tits: String career_length: String @@ -100,7 +111,10 @@ input PerformerUpdateInput { """This should be a URL or a base64 encoded data URL""" image: String stash_ids: [StashIDInput!] - rating: Int + # rating expressed as 1-5 + rating: Int @deprecated(reason: "Use 1-100 range with rating100") + # rating expressed as 1-100 + rating100: Int details: String death_date: String hair_color: String @@ -117,7 +131,9 @@ input BulkPerformerUpdateInput { ethnicity: String country: String eye_color: String - height: String + # height must be parsable into an integer + height: String @deprecated(reason: "Use height_cm") + height_cm: Int measurements: String fake_tits: String career_length: String @@ -128,7 +144,10 @@ input BulkPerformerUpdateInput { instagram: String favorite: Boolean tag_ids: BulkUpdateIds - rating: Int + # rating expressed as 1-5 + rating: Int @deprecated(reason: "Use 1-100 range with rating100") + # rating expressed as 1-100 + rating100: Int details: String death_date: String hair_color: String diff --git a/graphql/schema/types/scene.graphql b/graphql/schema/types/scene.graphql index 13c22cdc5..7ec2134c9 100644 --- a/graphql/schema/types/scene.graphql +++ b/graphql/schema/types/scene.graphql @@ -15,7 +15,7 @@ type ScenePathsType { stream: String # Resolver webp: String # Resolver vtt: String # Resolver - chapters_vtt: String # Resolver + chapters_vtt: String @deprecated sprite: String # Resolver funscript: String # Resolver interactive_heatmap: String # Resolver @@ -37,10 +37,15 @@ type Scene { checksum: String @deprecated(reason: "Use files.fingerprints") oshash: String @deprecated(reason: "Use files.fingerprints") title: String + code: String details: String + director: String url: String date: String - rating: Int + # rating expressed as 1-5 + rating: Int @deprecated(reason: "Use 1-100 range with rating100") + # rating expressed as 1-100 + rating100: Int organized: Boolean! o_counter: Int path: String! @deprecated(reason: "Use files.path") @@ -51,6 +56,14 @@ type Scene { created_at: Time! updated_at: Time! file_mod_time: Time + """The last time play count was updated""" + last_played_at: Time + """The time index a scene was left at""" + resume_time: Float + """The total time a scene has spent playing""" + play_duration: Float + """The number ot times a scene has been played""" + play_count: Int file: SceneFileType! @deprecated(reason: "Use files") files: [VideoFile!]! @@ -73,14 +86,17 @@ input SceneMovieInput { scene_index: Int } -input SceneUpdateInput { - clientMutationId: String - id: ID! +input SceneCreateInput { title: String + code: String details: String + director: String url: String date: String - rating: Int + # rating expressed as 1-5 + rating: Int @deprecated(reason: "Use 1-100 range with rating100") + # rating expressed as 1-100 + rating100: Int organized: Boolean studio_id: ID gallery_ids: [ID!] @@ -91,6 +107,42 @@ input SceneUpdateInput { cover_image: String stash_ids: [StashIDInput!] + """The first id will be assigned as primary. Files will be reassigned from + existing scenes if applicable. Files must not already be primary for another scene""" + file_ids: [ID!] +} + +input SceneUpdateInput { + clientMutationId: String + id: ID! + title: String + code: String + details: String + director: String + url: String + date: String + # rating expressed as 1-5 + rating: Int @deprecated(reason: "Use 1-100 range with rating100") + # rating expressed as 1-100 + rating100: Int + o_counter: Int + organized: Boolean + studio_id: ID + gallery_ids: [ID!] + performer_ids: [ID!] + movies: [SceneMovieInput!] + tag_ids: [ID!] + """This should be a URL or a base64 encoded data URL""" + cover_image: String + stash_ids: [StashIDInput!] + + """The time index a scene was left at""" + resume_time: Float + """The total time a scene has spent playing""" + play_duration: Float + """The number ot times a scene has been played""" + play_count: Int + primary_file_id: ID } @@ -109,10 +161,15 @@ input BulkSceneUpdateInput { clientMutationId: String ids: [ID!] title: String + code: String details: String + director: String url: String date: String - rating: Int + # rating expressed as 1-5 + rating: Int @deprecated(reason: "Use 1-100 range with rating100") + # rating expressed as 1-100 + rating100: Int organized: Boolean studio_id: ID gallery_ids: BulkUpdateIds @@ -157,10 +214,15 @@ type SceneMovieID { type SceneParserResult { scene: Scene! title: String + code: String details: String + director: String url: String date: String - rating: Int + # rating expressed as 1-5 + rating: Int @deprecated(reason: "Use 1-100 range with rating100") + # rating expressed as 1-100 + rating100: Int studio_id: ID gallery_ids: [ID!] performer_ids: [ID!] @@ -183,3 +245,17 @@ type SceneStreamEndpoint { mime_type: String label: String } + +input AssignSceneFileInput { + scene_id: ID! + file_id: ID! +} + +input SceneMergeInput { + """If destination scene has no files, then the primary file of the + first source scene will be assigned as primary""" + source: [ID!]! + destination: ID! + # values defined here will override values in the destination + values: SceneUpdateInput +} diff --git a/graphql/schema/types/scraper.graphql b/graphql/schema/types/scraper.graphql index fb0f9ce89..1230fde32 100644 --- a/graphql/schema/types/scraper.graphql +++ b/graphql/schema/types/scraper.graphql @@ -61,7 +61,9 @@ type ScrapedTag { type ScrapedScene { title: String + code: String details: String + director: String url: String date: String @@ -82,7 +84,9 @@ type ScrapedScene { input ScrapedSceneInput { title: String + code: String details: String + director: String url: String date: String diff --git a/graphql/schema/types/studio.graphql b/graphql/schema/types/studio.graphql index 7bf4bb355..097ea8340 100644 --- a/graphql/schema/types/studio.graphql +++ b/graphql/schema/types/studio.graphql @@ -13,7 +13,10 @@ type Studio { image_count: Int # Resolver gallery_count: Int # Resolver stash_ids: [StashID!]! - rating: Int + # rating expressed as 1-5 + rating: Int @deprecated(reason: "Use 1-100 range with rating100") + # rating expressed as 1-100 + rating100: Int details: String created_at: Time! updated_at: Time! @@ -28,7 +31,10 @@ input StudioCreateInput { """This should be a URL or a base64 encoded data URL""" image: String stash_ids: [StashIDInput!] - rating: Int + # rating expressed as 1-5 + rating: Int @deprecated(reason: "Use 1-100 range with rating100") + # rating expressed as 1-100 + rating100: Int details: String aliases: [String!] ignore_auto_tag: Boolean @@ -42,7 +48,10 @@ input StudioUpdateInput { """This should be a URL or a base64 encoded data URL""" image: String stash_ids: [StashIDInput!] - rating: Int + # rating expressed as 1-5 + rating: Int @deprecated(reason: "Use 1-100 range with rating100") + # rating expressed as 1-100 + rating100: Int details: String aliases: [String!] ignore_auto_tag: Boolean diff --git a/graphql/stash-box/query.graphql b/graphql/stash-box/query.graphql index 12f12d2a5..cc0017809 100644 --- a/graphql/stash-box/query.graphql +++ b/graphql/stash-box/query.graphql @@ -94,7 +94,9 @@ fragment FingerprintFragment on Fingerprint { fragment SceneFragment on Scene { id title + code details + director duration date urls { diff --git a/internal/api/changeset_translator.go b/internal/api/changeset_translator.go index 3dfb4a6a1..3e768a136 100644 --- a/internal/api/changeset_translator.go +++ b/internal/api/changeset_translator.go @@ -5,6 +5,7 @@ import ( "database/sql" "fmt" "strconv" + "strings" "github.com/99designs/gqlgen/graphql" "github.com/stashapp/stash/pkg/models" @@ -19,19 +20,35 @@ func getArgumentMap(ctx context.Context) map[string]interface{} { } func getUpdateInputMap(ctx context.Context) map[string]interface{} { + return getNamedUpdateInputMap(ctx, updateInputField) +} + +func getNamedUpdateInputMap(ctx context.Context, field string) map[string]interface{} { args := getArgumentMap(ctx) - input := args[updateInputField] - var ret map[string]interface{} - if input != nil { - ret, _ = input.(map[string]interface{}) + // field can be qualified + fields := strings.Split(field, ".") + + currArgs := args + + for _, f := range fields { + v, found := currArgs[f] + if !found { + currArgs = nil + break + } + + currArgs, _ = v.(map[string]interface{}) + if currArgs == nil { + break + } } - if ret == nil { - ret = make(map[string]interface{}) + if currArgs != nil { + return currArgs } - return ret + return make(map[string]interface{}) } func getUpdateInputMaps(ctx context.Context) []map[string]interface{} { @@ -90,6 +107,14 @@ func (t changesetTranslator) nullString(value *string, field string) *sql.NullSt return ret } +func (t changesetTranslator) string(value *string, field string) string { + if value == nil { + return "" + } + + return *value +} + func (t changesetTranslator) optionalString(value *string, field string) models.OptionalString { if !t.hasField(field) { return models.OptionalString{} @@ -128,6 +153,27 @@ func (t changesetTranslator) optionalDate(value *string, field string) models.Op return models.NewOptionalDate(models.NewDate(*value)) } +func (t changesetTranslator) datePtr(value *string, field string) *models.Date { + if value == nil { + return nil + } + + d := models.NewDate(*value) + return &d +} + +func (t changesetTranslator) intPtrFromString(value *string, field string) (*int, error) { + if value == nil || *value == "" { + return nil, nil + } + + vv, err := strconv.Atoi(*value) + if err != nil { + return nil, fmt.Errorf("converting %v to int: %w", *value, err) + } + return &vv, nil +} + func (t changesetTranslator) nullInt64(value *int, field string) *sql.NullInt64 { if !t.hasField(field) { return nil @@ -143,6 +189,56 @@ func (t changesetTranslator) nullInt64(value *int, field string) *sql.NullInt64 return ret } +func (t changesetTranslator) ratingConversion(legacyValue *int, rating100Value *int) *sql.NullInt64 { + const ( + legacyField = "rating" + rating100Field = "rating100" + ) + + legacyRating := t.nullInt64(legacyValue, legacyField) + if legacyRating != nil { + if legacyRating.Valid { + legacyRating.Int64 = int64(models.Rating5To100(int(legacyRating.Int64))) + } + return legacyRating + } + return t.nullInt64(rating100Value, rating100Field) +} + +func (t changesetTranslator) ratingConversionInt(legacyValue *int, rating100Value *int) *int { + const ( + legacyField = "rating" + rating100Field = "rating100" + ) + + legacyRating := t.optionalInt(legacyValue, legacyField) + if legacyRating.Set && !(legacyRating.Null) { + ret := int(models.Rating5To100(int(legacyRating.Value))) + return &ret + } + + o := t.optionalInt(rating100Value, rating100Field) + if o.Set && !(o.Null) { + return &o.Value + } + + return nil +} + +func (t changesetTranslator) ratingConversionOptional(legacyValue *int, rating100Value *int) models.OptionalInt { + const ( + legacyField = "rating" + rating100Field = "rating100" + ) + + legacyRating := t.optionalInt(legacyValue, legacyField) + if legacyRating.Set && !(legacyRating.Null) { + legacyRating.Value = int(models.Rating5To100(int(legacyRating.Value))) + return legacyRating + } + return t.optionalInt(rating100Value, rating100Field) +} + func (t changesetTranslator) optionalInt(value *int, field string) models.OptionalInt { if !t.hasField(field) { return models.OptionalInt{} @@ -185,19 +281,12 @@ func (t changesetTranslator) optionalIntFromString(value *string, field string) return models.NewOptionalInt(vv), nil } -func (t changesetTranslator) nullBool(value *bool, field string) *sql.NullBool { - if !t.hasField(field) { - return nil +func (t changesetTranslator) bool(value *bool, field string) bool { + if value == nil { + return false } - ret := &sql.NullBool{} - - if value != nil { - ret.Bool = *value - ret.Valid = true - } - - return ret + return *value } func (t changesetTranslator) optionalBool(value *bool, field string) models.OptionalBool { @@ -207,3 +296,11 @@ func (t changesetTranslator) optionalBool(value *bool, field string) models.Opti return models.NewOptionalBoolPtr(value) } + +func (t changesetTranslator) optionalFloat64(value *float64, field string) models.OptionalFloat64 { + if !t.hasField(field) { + return models.OptionalFloat64{} + } + + return models.NewOptionalFloat64Ptr(value) +} diff --git a/internal/api/images.go b/internal/api/images.go index 19156436f..ddcaee629 100644 --- a/internal/api/images.go +++ b/internal/api/images.go @@ -10,6 +10,7 @@ import ( "github.com/stashapp/stash/internal/static" "github.com/stashapp/stash/pkg/hash" "github.com/stashapp/stash/pkg/logger" + "github.com/stashapp/stash/pkg/models" ) type imageBox struct { @@ -86,7 +87,7 @@ func initialiseCustomImages() { } } -func getRandomPerformerImageUsingName(name, gender, customPath string) ([]byte, error) { +func getRandomPerformerImageUsingName(name string, gender models.GenderEnum, customPath string) ([]byte, error) { var box *imageBox // If we have a custom path, we should return a new box in the given path. @@ -95,10 +96,10 @@ func getRandomPerformerImageUsingName(name, gender, customPath string) ([]byte, } if box == nil { - switch strings.ToUpper(gender) { - case "FEMALE": + switch gender { + case models.GenderEnumFemale: box = performerBox - case "MALE": + case models.GenderEnumMale: box = performerBoxMale default: box = performerBox diff --git a/internal/api/locale.go b/internal/api/locale.go index 265d51f5e..a13aeca2f 100644 --- a/internal/api/locale.go +++ b/internal/api/locale.go @@ -26,6 +26,14 @@ var matcher = language.NewMatcher([]language.Tag{ language.MustParse("da-DK"), language.MustParse("pl-PL"), language.MustParse("ko-KR"), + language.MustParse("cs-CZ"), + language.MustParse("bn-BD"), + language.MustParse("et-EE"), + language.MustParse("fa-IR"), + language.MustParse("hu-HU"), + language.MustParse("ro-RO"), + language.MustParse("th-TH"), + language.MustParse("uk-UA"), }) // newCollator parses a locale into a collator diff --git a/internal/api/resolver.go b/internal/api/resolver.go index 5db47c3b9..bfe96939f 100644 --- a/internal/api/resolver.go +++ b/internal/api/resolver.go @@ -95,8 +95,12 @@ func (r *Resolver) withTxn(ctx context.Context, fn func(ctx context.Context) err return txn.WithTxn(ctx, r.txnManager, fn) } +func (r *Resolver) withReadTxn(ctx context.Context, fn func(ctx context.Context) error) error { + return txn.WithReadTxn(ctx, r.txnManager, fn) +} + func (r *queryResolver) MarkerWall(ctx context.Context, q *string) (ret []*models.SceneMarker, err error) { - if err := r.withTxn(ctx, func(ctx context.Context) error { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { ret, err = r.repository.SceneMarker.Wall(ctx, q) return err }); err != nil { @@ -106,7 +110,7 @@ func (r *queryResolver) MarkerWall(ctx context.Context, q *string) (ret []*model } func (r *queryResolver) SceneWall(ctx context.Context, q *string) (ret []*models.Scene, err error) { - if err := r.withTxn(ctx, func(ctx context.Context) error { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { ret, err = r.repository.Scene.Wall(ctx, q) return err }); err != nil { @@ -117,7 +121,7 @@ func (r *queryResolver) SceneWall(ctx context.Context, q *string) (ret []*models } func (r *queryResolver) MarkerStrings(ctx context.Context, q *string, sort *string) (ret []*models.MarkerStringsResultType, err error) { - if err := r.withTxn(ctx, func(ctx context.Context) error { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { ret, err = r.repository.SceneMarker.GetMarkerStrings(ctx, q, sort) return err }); err != nil { @@ -129,7 +133,7 @@ func (r *queryResolver) MarkerStrings(ctx context.Context, q *string, sort *stri func (r *queryResolver) Stats(ctx context.Context) (*StatsResultType, error) { var ret StatsResultType - if err := r.withTxn(ctx, func(ctx context.Context) error { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { repo := r.repository scenesQB := repo.Scene imageQB := repo.Image @@ -205,7 +209,7 @@ func (r *queryResolver) SceneMarkerTags(ctx context.Context, scene_id string) ([ var keys []int tags := make(map[int]*SceneMarkerTag) - if err := r.withTxn(ctx, func(ctx context.Context) error { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { sceneMarkers, err := r.repository.SceneMarker.FindBySceneID(ctx, sceneID) if err != nil { return err diff --git a/internal/api/resolver_model_gallery.go b/internal/api/resolver_model_gallery.go index 8a75b8a28..43c5b2221 100644 --- a/internal/api/resolver_model_gallery.go +++ b/internal/api/resolver_model_gallery.go @@ -73,7 +73,7 @@ func (r *galleryResolver) Folder(ctx context.Context, obj *models.Gallery) (*Fol var ret *file.Folder - if err := r.withTxn(ctx, func(ctx context.Context) error { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { var err error ret, err = r.repository.Folder.Find(ctx, *obj.FolderID) @@ -123,8 +123,9 @@ func (r *galleryResolver) FileModTime(ctx context.Context, obj *models.Gallery) return nil, nil } +// Images is deprecated, slow and shouldn't be used func (r *galleryResolver) Images(ctx context.Context, obj *models.Gallery) (ret []*models.Image, err error) { - if err := r.withTxn(ctx, func(ctx context.Context) error { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { var err error // #2376 - sort images by path @@ -143,25 +144,10 @@ func (r *galleryResolver) Images(ctx context.Context, obj *models.Gallery) (ret } func (r *galleryResolver) Cover(ctx context.Context, obj *models.Gallery) (ret *models.Image, err error) { - if err := r.withTxn(ctx, func(ctx context.Context) error { - // doing this via Query is really slow, so stick with FindByGalleryID - imgs, err := r.repository.Image.FindByGalleryID(ctx, obj.ID) - if err != nil { - return err - } - - if len(imgs) > 0 { - ret = imgs[0] - } - - for _, img := range imgs { - if image.IsCover(img) { - ret = img - break - } - } - - return nil + if err := r.withReadTxn(ctx, func(ctx context.Context) error { + // find cover.jpg first + ret, err = image.FindGalleryCover(ctx, r.repository.Image, obj.ID) + return err }); err != nil { return nil, err } @@ -179,7 +165,7 @@ func (r *galleryResolver) Date(ctx context.Context, obj *models.Gallery) (*strin func (r *galleryResolver) Checksum(ctx context.Context, obj *models.Gallery) (string, error) { if !obj.Files.PrimaryLoaded() { - if err := r.withTxn(ctx, func(ctx context.Context) error { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { return obj.LoadPrimaryFile(ctx, r.repository.File) }); err != nil { return "", err @@ -189,9 +175,21 @@ func (r *galleryResolver) Checksum(ctx context.Context, obj *models.Gallery) (st return obj.PrimaryChecksum(), nil } +func (r *galleryResolver) Rating(ctx context.Context, obj *models.Gallery) (*int, error) { + if obj.Rating != nil { + rating := models.Rating100To5(*obj.Rating) + return &rating, nil + } + return nil, nil +} + +func (r *galleryResolver) Rating100(ctx context.Context, obj *models.Gallery) (*int, error) { + return obj.Rating, nil +} + func (r *galleryResolver) Scenes(ctx context.Context, obj *models.Gallery) (ret []*models.Scene, err error) { if !obj.SceneIDs.Loaded() { - if err := r.withTxn(ctx, func(ctx context.Context) error { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { return obj.LoadSceneIDs(ctx, r.repository.Gallery) }); err != nil { return nil, err @@ -213,7 +211,7 @@ func (r *galleryResolver) Studio(ctx context.Context, obj *models.Gallery) (ret func (r *galleryResolver) Tags(ctx context.Context, obj *models.Gallery) (ret []*models.Tag, err error) { if !obj.TagIDs.Loaded() { - if err := r.withTxn(ctx, func(ctx context.Context) error { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { return obj.LoadTagIDs(ctx, r.repository.Gallery) }); err != nil { return nil, err @@ -227,7 +225,7 @@ func (r *galleryResolver) Tags(ctx context.Context, obj *models.Gallery) (ret [] func (r *galleryResolver) Performers(ctx context.Context, obj *models.Gallery) (ret []*models.Performer, err error) { if !obj.PerformerIDs.Loaded() { - if err := r.withTxn(ctx, func(ctx context.Context) error { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { return obj.LoadPerformerIDs(ctx, r.repository.Gallery) }); err != nil { return nil, err @@ -240,7 +238,7 @@ func (r *galleryResolver) Performers(ctx context.Context, obj *models.Gallery) ( } func (r *galleryResolver) ImageCount(ctx context.Context, obj *models.Gallery) (ret int, err error) { - if err := r.withTxn(ctx, func(ctx context.Context) error { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { var err error ret, err = r.repository.Image.CountByGalleryID(ctx, obj.ID) return err diff --git a/internal/api/resolver_model_image.go b/internal/api/resolver_model_image.go index 136a46622..c7fdb8c5f 100644 --- a/internal/api/resolver_model_image.go +++ b/internal/api/resolver_model_image.go @@ -132,7 +132,7 @@ func (r *imageResolver) Paths(ctx context.Context, obj *models.Image) (*ImagePat func (r *imageResolver) Galleries(ctx context.Context, obj *models.Image) (ret []*models.Gallery, err error) { if !obj.GalleryIDs.Loaded() { - if err := r.withTxn(ctx, func(ctx context.Context) error { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { return obj.LoadGalleryIDs(ctx, r.repository.Image) }); err != nil { return nil, err @@ -144,6 +144,18 @@ func (r *imageResolver) Galleries(ctx context.Context, obj *models.Image) (ret [ return ret, firstError(errs) } +func (r *imageResolver) Rating(ctx context.Context, obj *models.Image) (*int, error) { + if obj.Rating != nil { + rating := models.Rating100To5(*obj.Rating) + return &rating, nil + } + return nil, nil +} + +func (r *imageResolver) Rating100(ctx context.Context, obj *models.Image) (*int, error) { + return obj.Rating, nil +} + func (r *imageResolver) Studio(ctx context.Context, obj *models.Image) (ret *models.Studio, err error) { if obj.StudioID == nil { return nil, nil @@ -154,7 +166,7 @@ func (r *imageResolver) Studio(ctx context.Context, obj *models.Image) (ret *mod func (r *imageResolver) Tags(ctx context.Context, obj *models.Image) (ret []*models.Tag, err error) { if !obj.TagIDs.Loaded() { - if err := r.withTxn(ctx, func(ctx context.Context) error { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { return obj.LoadTagIDs(ctx, r.repository.Image) }); err != nil { return nil, err @@ -168,7 +180,7 @@ func (r *imageResolver) Tags(ctx context.Context, obj *models.Image) (ret []*mod func (r *imageResolver) Performers(ctx context.Context, obj *models.Image) (ret []*models.Performer, err error) { if !obj.PerformerIDs.Loaded() { - if err := r.withTxn(ctx, func(ctx context.Context) error { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { return obj.LoadPerformerIDs(ctx, r.repository.Image) }); err != nil { return nil, err diff --git a/internal/api/resolver_model_movie.go b/internal/api/resolver_model_movie.go index e587e3ba5..fbde8a80a 100644 --- a/internal/api/resolver_model_movie.go +++ b/internal/api/resolver_model_movie.go @@ -48,6 +48,14 @@ func (r *movieResolver) Date(ctx context.Context, obj *models.Movie) (*string, e } func (r *movieResolver) Rating(ctx context.Context, obj *models.Movie) (*int, error) { + if obj.Rating.Valid { + rating := models.Rating100To5(int(obj.Rating.Int64)) + return &rating, nil + } + return nil, nil +} + +func (r *movieResolver) Rating100(ctx context.Context, obj *models.Movie) (*int, error) { if obj.Rating.Valid { rating := int(obj.Rating.Int64) return &rating, nil @@ -86,7 +94,7 @@ func (r *movieResolver) FrontImagePath(ctx context.Context, obj *models.Movie) ( func (r *movieResolver) BackImagePath(ctx context.Context, obj *models.Movie) (*string, error) { // don't return any thing if there is no back image var img []byte - if err := r.withTxn(ctx, func(ctx context.Context) error { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { var err error img, err = r.repository.Movie.GetBackImage(ctx, obj.ID) if err != nil { @@ -109,7 +117,7 @@ func (r *movieResolver) BackImagePath(ctx context.Context, obj *models.Movie) (* func (r *movieResolver) SceneCount(ctx context.Context, obj *models.Movie) (ret *int, err error) { var res int - if err := r.withTxn(ctx, func(ctx context.Context) error { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { res, err = r.repository.Scene.CountByMovieID(ctx, obj.ID) return err }); err != nil { @@ -120,7 +128,7 @@ func (r *movieResolver) SceneCount(ctx context.Context, obj *models.Movie) (ret } func (r *movieResolver) Scenes(ctx context.Context, obj *models.Movie) (ret []*models.Scene, err error) { - if err := r.withTxn(ctx, func(ctx context.Context) error { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { var err error ret, err = r.repository.Scene.FindByMovieID(ctx, obj.ID) return err diff --git a/internal/api/resolver_model_performer.go b/internal/api/resolver_model_performer.go index 9e66fb38d..414b894a4 100644 --- a/internal/api/resolver_model_performer.go +++ b/internal/api/resolver_model_performer.go @@ -2,7 +2,7 @@ package api import ( "context" - "time" + "strconv" "github.com/stashapp/stash/internal/api/urlbuilders" "github.com/stashapp/stash/pkg/gallery" @@ -10,131 +10,26 @@ import ( "github.com/stashapp/stash/pkg/models" ) -func (r *performerResolver) Name(ctx context.Context, obj *models.Performer) (*string, error) { - if obj.Name.Valid { - return &obj.Name.String, nil +func (r *performerResolver) Height(ctx context.Context, obj *models.Performer) (*string, error) { + if obj.Height != nil { + ret := strconv.Itoa(*obj.Height) + return &ret, nil } return nil, nil } -func (r *performerResolver) URL(ctx context.Context, obj *models.Performer) (*string, error) { - if obj.URL.Valid { - return &obj.URL.String, nil - } - return nil, nil -} - -func (r *performerResolver) Gender(ctx context.Context, obj *models.Performer) (*models.GenderEnum, error) { - var ret models.GenderEnum - - if obj.Gender.Valid { - ret = models.GenderEnum(obj.Gender.String) - if ret.IsValid() { - return &ret, nil - } - } - - return nil, nil -} - -func (r *performerResolver) Twitter(ctx context.Context, obj *models.Performer) (*string, error) { - if obj.Twitter.Valid { - return &obj.Twitter.String, nil - } - return nil, nil -} - -func (r *performerResolver) Instagram(ctx context.Context, obj *models.Performer) (*string, error) { - if obj.Instagram.Valid { - return &obj.Instagram.String, nil - } - return nil, nil +func (r *performerResolver) HeightCm(ctx context.Context, obj *models.Performer) (*int, error) { + return obj.Height, nil } func (r *performerResolver) Birthdate(ctx context.Context, obj *models.Performer) (*string, error) { - if obj.Birthdate.Valid { - return &obj.Birthdate.String, nil + if obj.Birthdate != nil { + ret := obj.Birthdate.String() + return &ret, nil } return nil, nil } -func (r *performerResolver) Ethnicity(ctx context.Context, obj *models.Performer) (*string, error) { - if obj.Ethnicity.Valid { - return &obj.Ethnicity.String, nil - } - return nil, nil -} - -func (r *performerResolver) Country(ctx context.Context, obj *models.Performer) (*string, error) { - if obj.Country.Valid { - return &obj.Country.String, nil - } - return nil, nil -} - -func (r *performerResolver) EyeColor(ctx context.Context, obj *models.Performer) (*string, error) { - if obj.EyeColor.Valid { - return &obj.EyeColor.String, nil - } - return nil, nil -} - -func (r *performerResolver) Height(ctx context.Context, obj *models.Performer) (*string, error) { - if obj.Height.Valid { - return &obj.Height.String, nil - } - return nil, nil -} - -func (r *performerResolver) Measurements(ctx context.Context, obj *models.Performer) (*string, error) { - if obj.Measurements.Valid { - return &obj.Measurements.String, nil - } - return nil, nil -} - -func (r *performerResolver) FakeTits(ctx context.Context, obj *models.Performer) (*string, error) { - if obj.FakeTits.Valid { - return &obj.FakeTits.String, nil - } - return nil, nil -} - -func (r *performerResolver) CareerLength(ctx context.Context, obj *models.Performer) (*string, error) { - if obj.CareerLength.Valid { - return &obj.CareerLength.String, nil - } - return nil, nil -} - -func (r *performerResolver) Tattoos(ctx context.Context, obj *models.Performer) (*string, error) { - if obj.Tattoos.Valid { - return &obj.Tattoos.String, nil - } - return nil, nil -} - -func (r *performerResolver) Piercings(ctx context.Context, obj *models.Performer) (*string, error) { - if obj.Piercings.Valid { - return &obj.Piercings.String, nil - } - return nil, nil -} - -func (r *performerResolver) Aliases(ctx context.Context, obj *models.Performer) (*string, error) { - if obj.Aliases.Valid { - return &obj.Aliases.String, nil - } - return nil, nil -} - -func (r *performerResolver) Favorite(ctx context.Context, obj *models.Performer) (bool, error) { - if obj.Favorite.Valid { - return obj.Favorite.Bool, nil - } - return false, nil -} - func (r *performerResolver) ImagePath(ctx context.Context, obj *models.Performer) (*string, error) { baseURL, _ := ctx.Value(BaseURLCtxKey).(string) imagePath := urlbuilders.NewPerformerURLBuilder(baseURL, obj).GetPerformerImageURL() @@ -142,7 +37,7 @@ func (r *performerResolver) ImagePath(ctx context.Context, obj *models.Performer } func (r *performerResolver) Tags(ctx context.Context, obj *models.Performer) (ret []*models.Tag, err error) { - if err := r.withTxn(ctx, func(ctx context.Context) error { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { ret, err = r.repository.Tag.FindByPerformerID(ctx, obj.ID) return err }); err != nil { @@ -154,7 +49,7 @@ func (r *performerResolver) Tags(ctx context.Context, obj *models.Performer) (re func (r *performerResolver) SceneCount(ctx context.Context, obj *models.Performer) (ret *int, err error) { var res int - if err := r.withTxn(ctx, func(ctx context.Context) error { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { res, err = r.repository.Scene.CountByPerformerID(ctx, obj.ID) return err }); err != nil { @@ -166,7 +61,7 @@ func (r *performerResolver) SceneCount(ctx context.Context, obj *models.Performe func (r *performerResolver) ImageCount(ctx context.Context, obj *models.Performer) (ret *int, err error) { var res int - if err := r.withTxn(ctx, func(ctx context.Context) error { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { res, err = image.CountByPerformerID(ctx, r.repository.Image, obj.ID) return err }); err != nil { @@ -178,7 +73,7 @@ func (r *performerResolver) ImageCount(ctx context.Context, obj *models.Performe func (r *performerResolver) GalleryCount(ctx context.Context, obj *models.Performer) (ret *int, err error) { var res int - if err := r.withTxn(ctx, func(ctx context.Context) error { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { res, err = gallery.CountByPerformerID(ctx, r.repository.Gallery, obj.ID) return err }); err != nil { @@ -189,7 +84,7 @@ func (r *performerResolver) GalleryCount(ctx context.Context, obj *models.Perfor } func (r *performerResolver) Scenes(ctx context.Context, obj *models.Performer) (ret []*models.Scene, err error) { - if err := r.withTxn(ctx, func(ctx context.Context) error { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { ret, err = r.repository.Scene.FindByPerformerID(ctx, obj.ID) return err }); err != nil { @@ -201,7 +96,7 @@ func (r *performerResolver) Scenes(ctx context.Context, obj *models.Performer) ( func (r *performerResolver) StashIds(ctx context.Context, obj *models.Performer) ([]*models.StashID, error) { var ret []models.StashID - if err := r.withTxn(ctx, func(ctx context.Context) error { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { var err error ret, err = r.repository.Performer.GetStashIDs(ctx, obj.ID) return err @@ -213,52 +108,27 @@ func (r *performerResolver) StashIds(ctx context.Context, obj *models.Performer) } func (r *performerResolver) Rating(ctx context.Context, obj *models.Performer) (*int, error) { - if obj.Rating.Valid { - rating := int(obj.Rating.Int64) + if obj.Rating != nil { + rating := models.Rating100To5(*obj.Rating) return &rating, nil } return nil, nil } -func (r *performerResolver) Details(ctx context.Context, obj *models.Performer) (*string, error) { - if obj.Details.Valid { - return &obj.Details.String, nil - } - return nil, nil +func (r *performerResolver) Rating100(ctx context.Context, obj *models.Performer) (*int, error) { + return obj.Rating, nil } func (r *performerResolver) DeathDate(ctx context.Context, obj *models.Performer) (*string, error) { - if obj.DeathDate.Valid { - return &obj.DeathDate.String, nil + if obj.DeathDate != nil { + ret := obj.DeathDate.String() + return &ret, nil } return nil, nil } -func (r *performerResolver) HairColor(ctx context.Context, obj *models.Performer) (*string, error) { - if obj.HairColor.Valid { - return &obj.HairColor.String, nil - } - return nil, nil -} - -func (r *performerResolver) Weight(ctx context.Context, obj *models.Performer) (*int, error) { - if obj.Weight.Valid { - weight := int(obj.Weight.Int64) - return &weight, nil - } - return nil, nil -} - -func (r *performerResolver) CreatedAt(ctx context.Context, obj *models.Performer) (*time.Time, error) { - return &obj.CreatedAt.Timestamp, nil -} - -func (r *performerResolver) UpdatedAt(ctx context.Context, obj *models.Performer) (*time.Time, error) { - return &obj.UpdatedAt.Timestamp, nil -} - func (r *performerResolver) Movies(ctx context.Context, obj *models.Performer) (ret []*models.Movie, err error) { - if err := r.withTxn(ctx, func(ctx context.Context) error { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { ret, err = r.repository.Movie.FindByPerformerID(ctx, obj.ID) return err }); err != nil { @@ -270,7 +140,7 @@ func (r *performerResolver) Movies(ctx context.Context, obj *models.Performer) ( func (r *performerResolver) MovieCount(ctx context.Context, obj *models.Performer) (ret *int, err error) { var res int - if err := r.withTxn(ctx, func(ctx context.Context) error { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { res, err = r.repository.Movie.CountByPerformerID(ctx, obj.ID) return err }); err != nil { diff --git a/internal/api/resolver_model_scene.go b/internal/api/resolver_model_scene.go index 624b08b50..47a4d0382 100644 --- a/internal/api/resolver_model_scene.go +++ b/internal/api/resolver_model_scene.go @@ -29,6 +29,8 @@ func (r *sceneResolver) getPrimaryFile(ctx context.Context, obj *models.Scene) ( obj.Files.SetPrimary(ret) return ret, nil + } else { + _ = obj.LoadPrimaryFile(ctx, r.repository.File) } return nil, nil @@ -139,6 +141,18 @@ func (r *sceneResolver) Files(ctx context.Context, obj *models.Scene) ([]*VideoF return ret, nil } +func (r *sceneResolver) Rating(ctx context.Context, obj *models.Scene) (*int, error) { + if obj.Rating != nil { + rating := models.Rating100To5(*obj.Rating) + return &rating, nil + } + return nil, nil +} + +func (r *sceneResolver) Rating100(ctx context.Context, obj *models.Scene) (*int, error) { + return obj.Rating, nil +} + func resolveFingerprints(f *file.BaseFile) []*Fingerprint { ret := make([]*Fingerprint, len(f.Fingerprints)) @@ -192,7 +206,7 @@ func (r *sceneResolver) Paths(ctx context.Context, obj *models.Scene) (*ScenePat } func (r *sceneResolver) SceneMarkers(ctx context.Context, obj *models.Scene) (ret []*models.SceneMarker, err error) { - if err := r.withTxn(ctx, func(ctx context.Context) error { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { ret, err = r.repository.SceneMarker.FindBySceneID(ctx, obj.ID) return err }); err != nil { @@ -211,7 +225,7 @@ func (r *sceneResolver) Captions(ctx context.Context, obj *models.Scene) (ret [] return nil, nil } - if err := r.withTxn(ctx, func(ctx context.Context) error { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { ret, err = r.repository.File.GetCaptions(ctx, primaryFile.Base().ID) return err }); err != nil { @@ -223,7 +237,7 @@ func (r *sceneResolver) Captions(ctx context.Context, obj *models.Scene) (ret [] func (r *sceneResolver) Galleries(ctx context.Context, obj *models.Scene) (ret []*models.Gallery, err error) { if !obj.GalleryIDs.Loaded() { - if err := r.withTxn(ctx, func(ctx context.Context) error { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { return obj.LoadGalleryIDs(ctx, r.repository.Scene) }); err != nil { return nil, err @@ -245,7 +259,7 @@ func (r *sceneResolver) Studio(ctx context.Context, obj *models.Scene) (ret *mod func (r *sceneResolver) Movies(ctx context.Context, obj *models.Scene) (ret []*SceneMovie, err error) { if !obj.Movies.Loaded() { - if err := r.withTxn(ctx, func(ctx context.Context) error { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { qb := r.repository.Scene return obj.LoadMovies(ctx, qb) @@ -276,7 +290,7 @@ func (r *sceneResolver) Movies(ctx context.Context, obj *models.Scene) (ret []*S func (r *sceneResolver) Tags(ctx context.Context, obj *models.Scene) (ret []*models.Tag, err error) { if !obj.TagIDs.Loaded() { - if err := r.withTxn(ctx, func(ctx context.Context) error { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { return obj.LoadTagIDs(ctx, r.repository.Scene) }); err != nil { return nil, err @@ -290,7 +304,7 @@ func (r *sceneResolver) Tags(ctx context.Context, obj *models.Scene) (ret []*mod func (r *sceneResolver) Performers(ctx context.Context, obj *models.Scene) (ret []*models.Performer, err error) { if !obj.PerformerIDs.Loaded() { - if err := r.withTxn(ctx, func(ctx context.Context) error { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { return obj.LoadPerformerIDs(ctx, r.repository.Scene) }); err != nil { return nil, err @@ -313,7 +327,7 @@ func stashIDsSliceToPtrSlice(v []models.StashID) []*models.StashID { } func (r *sceneResolver) StashIds(ctx context.Context, obj *models.Scene) (ret []*models.StashID, err error) { - if err := r.withTxn(ctx, func(ctx context.Context) error { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { return obj.LoadStashIDs(ctx, r.repository.Scene) }); err != nil { return nil, err diff --git a/internal/api/resolver_model_scene_marker.go b/internal/api/resolver_model_scene_marker.go index 7a4d01be1..0057db4e8 100644 --- a/internal/api/resolver_model_scene_marker.go +++ b/internal/api/resolver_model_scene_marker.go @@ -13,7 +13,7 @@ func (r *sceneMarkerResolver) Scene(ctx context.Context, obj *models.SceneMarker panic("Invalid scene id") } - if err := r.withTxn(ctx, func(ctx context.Context) error { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { sceneID := int(obj.SceneID.Int64) ret, err = r.repository.Scene.Find(ctx, sceneID) return err @@ -25,7 +25,7 @@ func (r *sceneMarkerResolver) Scene(ctx context.Context, obj *models.SceneMarker } func (r *sceneMarkerResolver) PrimaryTag(ctx context.Context, obj *models.SceneMarker) (ret *models.Tag, err error) { - if err := r.withTxn(ctx, func(ctx context.Context) error { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { ret, err = r.repository.Tag.Find(ctx, obj.PrimaryTagID) return err }); err != nil { @@ -36,7 +36,7 @@ func (r *sceneMarkerResolver) PrimaryTag(ctx context.Context, obj *models.SceneM } func (r *sceneMarkerResolver) Tags(ctx context.Context, obj *models.SceneMarker) (ret []*models.Tag, err error) { - if err := r.withTxn(ctx, func(ctx context.Context) error { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { ret, err = r.repository.Tag.FindBySceneMarkerID(ctx, obj.ID) return err }); err != nil { diff --git a/internal/api/resolver_model_studio.go b/internal/api/resolver_model_studio.go index 79ef8259e..282e5a46e 100644 --- a/internal/api/resolver_model_studio.go +++ b/internal/api/resolver_model_studio.go @@ -30,7 +30,7 @@ func (r *studioResolver) ImagePath(ctx context.Context, obj *models.Studio) (*st imagePath := urlbuilders.NewStudioURLBuilder(baseURL, obj).GetStudioImageURL() var hasImage bool - if err := r.withTxn(ctx, func(ctx context.Context) error { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { var err error hasImage, err = r.repository.Studio.HasImage(ctx, obj.ID) return err @@ -47,7 +47,7 @@ func (r *studioResolver) ImagePath(ctx context.Context, obj *models.Studio) (*st } func (r *studioResolver) Aliases(ctx context.Context, obj *models.Studio) (ret []string, err error) { - if err := r.withTxn(ctx, func(ctx context.Context) error { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { ret, err = r.repository.Studio.GetAliases(ctx, obj.ID) return err }); err != nil { @@ -59,7 +59,7 @@ func (r *studioResolver) Aliases(ctx context.Context, obj *models.Studio) (ret [ func (r *studioResolver) SceneCount(ctx context.Context, obj *models.Studio) (ret *int, err error) { var res int - if err := r.withTxn(ctx, func(ctx context.Context) error { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { res, err = r.repository.Scene.CountByStudioID(ctx, obj.ID) return err }); err != nil { @@ -71,7 +71,7 @@ func (r *studioResolver) SceneCount(ctx context.Context, obj *models.Studio) (re func (r *studioResolver) ImageCount(ctx context.Context, obj *models.Studio) (ret *int, err error) { var res int - if err := r.withTxn(ctx, func(ctx context.Context) error { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { res, err = image.CountByStudioID(ctx, r.repository.Image, obj.ID) return err }); err != nil { @@ -83,7 +83,7 @@ func (r *studioResolver) ImageCount(ctx context.Context, obj *models.Studio) (re func (r *studioResolver) GalleryCount(ctx context.Context, obj *models.Studio) (ret *int, err error) { var res int - if err := r.withTxn(ctx, func(ctx context.Context) error { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { res, err = gallery.CountByStudioID(ctx, r.repository.Gallery, obj.ID) return err }); err != nil { @@ -102,7 +102,7 @@ func (r *studioResolver) ParentStudio(ctx context.Context, obj *models.Studio) ( } func (r *studioResolver) ChildStudios(ctx context.Context, obj *models.Studio) (ret []*models.Studio, err error) { - if err := r.withTxn(ctx, func(ctx context.Context) error { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { ret, err = r.repository.Studio.FindChildren(ctx, obj.ID) return err }); err != nil { @@ -114,7 +114,7 @@ func (r *studioResolver) ChildStudios(ctx context.Context, obj *models.Studio) ( func (r *studioResolver) StashIds(ctx context.Context, obj *models.Studio) ([]*models.StashID, error) { var ret []models.StashID - if err := r.withTxn(ctx, func(ctx context.Context) error { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { var err error ret, err = r.repository.Studio.GetStashIDs(ctx, obj.ID) return err @@ -126,6 +126,14 @@ func (r *studioResolver) StashIds(ctx context.Context, obj *models.Studio) ([]*m } func (r *studioResolver) Rating(ctx context.Context, obj *models.Studio) (*int, error) { + if obj.Rating.Valid { + rating := models.Rating100To5(int(obj.Rating.Int64)) + return &rating, nil + } + return nil, nil +} + +func (r *studioResolver) Rating100(ctx context.Context, obj *models.Studio) (*int, error) { if obj.Rating.Valid { rating := int(obj.Rating.Int64) return &rating, nil @@ -149,7 +157,7 @@ func (r *studioResolver) UpdatedAt(ctx context.Context, obj *models.Studio) (*ti } func (r *studioResolver) Movies(ctx context.Context, obj *models.Studio) (ret []*models.Movie, err error) { - if err := r.withTxn(ctx, func(ctx context.Context) error { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { ret, err = r.repository.Movie.FindByStudioID(ctx, obj.ID) return err }); err != nil { @@ -161,7 +169,7 @@ func (r *studioResolver) Movies(ctx context.Context, obj *models.Studio) (ret [] func (r *studioResolver) MovieCount(ctx context.Context, obj *models.Studio) (ret *int, err error) { var res int - if err := r.withTxn(ctx, func(ctx context.Context) error { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { res, err = r.repository.Movie.CountByStudioID(ctx, obj.ID) return err }); err != nil { diff --git a/internal/api/resolver_model_tag.go b/internal/api/resolver_model_tag.go index db6236a0b..70fee39e0 100644 --- a/internal/api/resolver_model_tag.go +++ b/internal/api/resolver_model_tag.go @@ -18,7 +18,7 @@ func (r *tagResolver) Description(ctx context.Context, obj *models.Tag) (*string } func (r *tagResolver) Parents(ctx context.Context, obj *models.Tag) (ret []*models.Tag, err error) { - if err := r.withTxn(ctx, func(ctx context.Context) error { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { ret, err = r.repository.Tag.FindByChildTagID(ctx, obj.ID) return err }); err != nil { @@ -29,7 +29,7 @@ func (r *tagResolver) Parents(ctx context.Context, obj *models.Tag) (ret []*mode } func (r *tagResolver) Children(ctx context.Context, obj *models.Tag) (ret []*models.Tag, err error) { - if err := r.withTxn(ctx, func(ctx context.Context) error { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { ret, err = r.repository.Tag.FindByParentTagID(ctx, obj.ID) return err }); err != nil { @@ -40,7 +40,7 @@ func (r *tagResolver) Children(ctx context.Context, obj *models.Tag) (ret []*mod } func (r *tagResolver) Aliases(ctx context.Context, obj *models.Tag) (ret []string, err error) { - if err := r.withTxn(ctx, func(ctx context.Context) error { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { ret, err = r.repository.Tag.GetAliases(ctx, obj.ID) return err }); err != nil { @@ -52,7 +52,7 @@ func (r *tagResolver) Aliases(ctx context.Context, obj *models.Tag) (ret []strin func (r *tagResolver) SceneCount(ctx context.Context, obj *models.Tag) (ret *int, err error) { var count int - if err := r.withTxn(ctx, func(ctx context.Context) error { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { count, err = r.repository.Scene.CountByTagID(ctx, obj.ID) return err }); err != nil { @@ -64,7 +64,7 @@ func (r *tagResolver) SceneCount(ctx context.Context, obj *models.Tag) (ret *int func (r *tagResolver) SceneMarkerCount(ctx context.Context, obj *models.Tag) (ret *int, err error) { var count int - if err := r.withTxn(ctx, func(ctx context.Context) error { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { count, err = r.repository.SceneMarker.CountByTagID(ctx, obj.ID) return err }); err != nil { @@ -76,7 +76,7 @@ func (r *tagResolver) SceneMarkerCount(ctx context.Context, obj *models.Tag) (re func (r *tagResolver) ImageCount(ctx context.Context, obj *models.Tag) (ret *int, err error) { var res int - if err := r.withTxn(ctx, func(ctx context.Context) error { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { res, err = image.CountByTagID(ctx, r.repository.Image, obj.ID) return err }); err != nil { @@ -88,7 +88,7 @@ func (r *tagResolver) ImageCount(ctx context.Context, obj *models.Tag) (ret *int func (r *tagResolver) GalleryCount(ctx context.Context, obj *models.Tag) (ret *int, err error) { var res int - if err := r.withTxn(ctx, func(ctx context.Context) error { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { res, err = gallery.CountByTagID(ctx, r.repository.Gallery, obj.ID) return err }); err != nil { @@ -100,7 +100,7 @@ func (r *tagResolver) GalleryCount(ctx context.Context, obj *models.Tag) (ret *i func (r *tagResolver) PerformerCount(ctx context.Context, obj *models.Tag) (ret *int, err error) { var count int - if err := r.withTxn(ctx, func(ctx context.Context) error { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { count, err = r.repository.Performer.CountByTagID(ctx, obj.ID) return err }); err != nil { diff --git a/internal/api/resolver_mutation_configure.go b/internal/api/resolver_mutation_configure.go index 0252192e7..96891d69d 100644 --- a/internal/api/resolver_mutation_configure.go +++ b/internal/api/resolver_mutation_configure.go @@ -58,7 +58,7 @@ func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input ConfigGen } validateDir := func(key string, value string, optional bool) error { - if err := checkConfigOverride(config.Metadata); err != nil { + if err := checkConfigOverride(key); err != nil { return err } @@ -365,6 +365,12 @@ func (r *mutationResolver) ConfigureInterface(ctx context.Context, input ConfigI setBool(config.CSSEnabled, input.CSSEnabled) + if input.Javascript != nil { + c.SetJavascript(*input.Javascript) + } + + setBool(config.JavascriptEnabled, input.JavascriptEnabled) + if input.CustomLocales != nil { c.SetCustomLocales(*input.CustomLocales) } diff --git a/internal/api/resolver_mutation_gallery.go b/internal/api/resolver_mutation_gallery.go index 05b609cb9..51ff989a3 100644 --- a/internal/api/resolver_mutation_gallery.go +++ b/internal/api/resolver_mutation_gallery.go @@ -68,7 +68,13 @@ func (r *mutationResolver) GalleryCreate(ctx context.Context, input GalleryCreat d := models.NewDate(*input.Date) newGallery.Date = &d } - newGallery.Rating = input.Rating + + if input.Rating100 != nil { + newGallery.Rating = input.Rating100 + } else if input.Rating != nil { + rating := models.Rating5To100(*input.Rating) + newGallery.Rating = &rating + } if input.StudioID != nil { studioID, _ := strconv.Atoi(*input.StudioID) @@ -177,8 +183,8 @@ func (r *mutationResolver) galleryUpdate(ctx context.Context, input models.Galle if input.Title != nil { // ensure title is not empty - if *input.Title == "" { - return nil, errors.New("title must not be empty") + if *input.Title == "" && originalGallery.IsUserCreated() { + return nil, errors.New("title must not be empty for user-created galleries") } updatedGallery.Title = models.NewOptionalString(*input.Title) @@ -187,7 +193,7 @@ func (r *mutationResolver) galleryUpdate(ctx context.Context, input models.Galle updatedGallery.Details = translator.optionalString(input.Details, "details") updatedGallery.URL = translator.optionalString(input.URL, "url") updatedGallery.Date = translator.optionalDate(input.Date, "date") - updatedGallery.Rating = translator.optionalInt(input.Rating, "rating") + updatedGallery.Rating = translator.ratingConversionOptional(input.Rating, input.Rating100) updatedGallery.StudioID, err = translator.optionalIntFromString(input.StudioID, "studio_id") if err != nil { return nil, fmt.Errorf("converting studio id: %w", err) @@ -262,8 +268,7 @@ func (r *mutationResolver) BulkGalleryUpdate(ctx context.Context, input BulkGall updatedGallery.Details = translator.optionalString(input.Details, "details") updatedGallery.URL = translator.optionalString(input.URL, "url") updatedGallery.Date = translator.optionalDate(input.Date, "date") - updatedGallery.Rating = translator.optionalInt(input.Rating, "rating") - + updatedGallery.Rating = translator.ratingConversionOptional(input.Rating, input.Rating100) var err error updatedGallery.StudioID, err = translator.optionalIntFromString(input.StudioID, "studio_id") if err != nil { diff --git a/internal/api/resolver_mutation_image.go b/internal/api/resolver_mutation_image.go index a6d0577f7..135888f8f 100644 --- a/internal/api/resolver_mutation_image.go +++ b/internal/api/resolver_mutation_image.go @@ -103,7 +103,7 @@ func (r *mutationResolver) imageUpdate(ctx context.Context, input ImageUpdateInp updatedImage := models.NewImagePartial() updatedImage.Title = translator.optionalString(input.Title, "title") - updatedImage.Rating = translator.optionalInt(input.Rating, "rating") + updatedImage.Rating = translator.ratingConversionOptional(input.Rating, input.Rating100) updatedImage.StudioID, err = translator.optionalIntFromString(input.StudioID, "studio_id") if err != nil { return nil, fmt.Errorf("converting studio id: %w", err) @@ -189,7 +189,7 @@ func (r *mutationResolver) BulkImageUpdate(ctx context.Context, input BulkImageU } updatedImage.Title = translator.optionalString(input.Title, "title") - updatedImage.Rating = translator.optionalInt(input.Rating, "rating") + updatedImage.Rating = translator.ratingConversionOptional(input.Rating, input.Rating100) updatedImage.StudioID, err = translator.optionalIntFromString(input.StudioID, "studio_id") if err != nil { return nil, fmt.Errorf("converting studio id: %w", err) diff --git a/internal/api/resolver_mutation_movie.go b/internal/api/resolver_mutation_movie.go index 0a22350b6..f3d3e529d 100644 --- a/internal/api/resolver_mutation_movie.go +++ b/internal/api/resolver_mutation_movie.go @@ -76,9 +76,11 @@ func (r *mutationResolver) MovieCreate(ctx context.Context, input MovieCreateInp newMovie.Date = models.SQLiteDate{String: *input.Date, Valid: true} } - if input.Rating != nil { - rating := int64(*input.Rating) - newMovie.Rating = sql.NullInt64{Int64: rating, Valid: true} + if input.Rating100 != nil { + newMovie.Rating = sql.NullInt64{Int64: int64(*input.Rating100), Valid: true} + } else if input.Rating != nil { + rating := models.Rating5To100(*input.Rating) + newMovie.Rating = sql.NullInt64{Int64: int64(rating), Valid: true} } if input.StudioID != nil { @@ -166,7 +168,7 @@ func (r *mutationResolver) MovieUpdate(ctx context.Context, input MovieUpdateInp updatedMovie.Aliases = translator.nullString(input.Aliases, "aliases") updatedMovie.Duration = translator.nullInt64(input.Duration, "duration") updatedMovie.Date = translator.sqliteDate(input.Date, "date") - updatedMovie.Rating = translator.nullInt64(input.Rating, "rating") + updatedMovie.Rating = translator.ratingConversion(input.Rating, input.Rating100) updatedMovie.StudioID = translator.nullInt64FromString(input.StudioID, "studio_id") updatedMovie.Director = translator.nullString(input.Director, "director") updatedMovie.Synopsis = translator.nullString(input.Synopsis, "synopsis") @@ -239,7 +241,7 @@ func (r *mutationResolver) BulkMovieUpdate(ctx context.Context, input BulkMovieU UpdatedAt: &models.SQLiteTimestamp{Timestamp: updatedTime}, } - updatedMovie.Rating = translator.nullInt64(input.Rating, "rating") + updatedMovie.Rating = translator.ratingConversion(input.Rating, input.Rating100) updatedMovie.StudioID = translator.nullInt64FromString(input.StudioID, "studio_id") updatedMovie.Director = translator.nullString(input.Director, "director") diff --git a/internal/api/resolver_mutation_performer.go b/internal/api/resolver_mutation_performer.go index b0ab18852..33e440fd7 100644 --- a/internal/api/resolver_mutation_performer.go +++ b/internal/api/resolver_mutation_performer.go @@ -2,7 +2,6 @@ package api import ( "context" - "database/sql" "fmt" "strconv" "time" @@ -54,78 +53,85 @@ func (r *mutationResolver) PerformerCreate(ctx context.Context, input PerformerC // Populate a new performer from the input currentTime := time.Now() newPerformer := models.Performer{ + Name: input.Name, Checksum: checksum, - CreatedAt: models.SQLiteTimestamp{Timestamp: currentTime}, - UpdatedAt: models.SQLiteTimestamp{Timestamp: currentTime}, + CreatedAt: currentTime, + UpdatedAt: currentTime, } - newPerformer.Name = sql.NullString{String: input.Name, Valid: true} if input.URL != nil { - newPerformer.URL = sql.NullString{String: *input.URL, Valid: true} + newPerformer.URL = *input.URL } if input.Gender != nil { - newPerformer.Gender = sql.NullString{String: input.Gender.String(), Valid: true} + newPerformer.Gender = *input.Gender } if input.Birthdate != nil { - newPerformer.Birthdate = models.SQLiteDate{String: *input.Birthdate, Valid: true} + d := models.NewDate(*input.Birthdate) + newPerformer.Birthdate = &d } if input.Ethnicity != nil { - newPerformer.Ethnicity = sql.NullString{String: *input.Ethnicity, Valid: true} + newPerformer.Ethnicity = *input.Ethnicity } if input.Country != nil { - newPerformer.Country = sql.NullString{String: *input.Country, Valid: true} + newPerformer.Country = *input.Country } if input.EyeColor != nil { - newPerformer.EyeColor = sql.NullString{String: *input.EyeColor, Valid: true} + newPerformer.EyeColor = *input.EyeColor } - if input.Height != nil { - newPerformer.Height = sql.NullString{String: *input.Height, Valid: true} + // prefer height_cm over height + if input.HeightCm != nil { + newPerformer.Height = input.HeightCm + } else if input.Height != nil { + h, err := strconv.Atoi(*input.Height) + if err != nil { + return nil, fmt.Errorf("invalid height: %s", *input.Height) + } + newPerformer.Height = &h } if input.Measurements != nil { - newPerformer.Measurements = sql.NullString{String: *input.Measurements, Valid: true} + newPerformer.Measurements = *input.Measurements } if input.FakeTits != nil { - newPerformer.FakeTits = sql.NullString{String: *input.FakeTits, Valid: true} + newPerformer.FakeTits = *input.FakeTits } if input.CareerLength != nil { - newPerformer.CareerLength = sql.NullString{String: *input.CareerLength, Valid: true} + newPerformer.CareerLength = *input.CareerLength } if input.Tattoos != nil { - newPerformer.Tattoos = sql.NullString{String: *input.Tattoos, Valid: true} + newPerformer.Tattoos = *input.Tattoos } if input.Piercings != nil { - newPerformer.Piercings = sql.NullString{String: *input.Piercings, Valid: true} + newPerformer.Piercings = *input.Piercings } if input.Aliases != nil { - newPerformer.Aliases = sql.NullString{String: *input.Aliases, Valid: true} + newPerformer.Aliases = *input.Aliases } if input.Twitter != nil { - newPerformer.Twitter = sql.NullString{String: *input.Twitter, Valid: true} + newPerformer.Twitter = *input.Twitter } if input.Instagram != nil { - newPerformer.Instagram = sql.NullString{String: *input.Instagram, Valid: true} + newPerformer.Instagram = *input.Instagram } if input.Favorite != nil { - newPerformer.Favorite = sql.NullBool{Bool: *input.Favorite, Valid: true} - } else { - newPerformer.Favorite = sql.NullBool{Bool: false, Valid: true} + newPerformer.Favorite = *input.Favorite } - if input.Rating != nil { - newPerformer.Rating = sql.NullInt64{Int64: int64(*input.Rating), Valid: true} - } else { - newPerformer.Rating = sql.NullInt64{Valid: false} + if input.Rating100 != nil { + newPerformer.Rating = input.Rating100 + } else if input.Rating != nil { + rating := models.Rating5To100(*input.Rating) + newPerformer.Rating = &rating } if input.Details != nil { - newPerformer.Details = sql.NullString{String: *input.Details, Valid: true} + newPerformer.Details = *input.Details } if input.DeathDate != nil { - newPerformer.DeathDate = models.SQLiteDate{String: *input.DeathDate, Valid: true} + d := models.NewDate(*input.DeathDate) + newPerformer.DeathDate = &d } if input.HairColor != nil { - newPerformer.HairColor = sql.NullString{String: *input.HairColor, Valid: true} + newPerformer.HairColor = *input.HairColor } if input.Weight != nil { - weight := int64(*input.Weight) - newPerformer.Weight = sql.NullInt64{Int64: weight, Valid: true} + newPerformer.Weight = input.Weight } if input.IgnoreAutoTag != nil { newPerformer.IgnoreAutoTag = *input.IgnoreAutoTag @@ -138,24 +144,23 @@ func (r *mutationResolver) PerformerCreate(ctx context.Context, input PerformerC } // Start the transaction and save the performer - var performer *models.Performer if err := r.withTxn(ctx, func(ctx context.Context) error { qb := r.repository.Performer - performer, err = qb.Create(ctx, newPerformer) + err = qb.Create(ctx, &newPerformer) if err != nil { return err } if len(input.TagIds) > 0 { - if err := r.updatePerformerTags(ctx, performer.ID, input.TagIds); err != nil { + if err := r.updatePerformerTags(ctx, newPerformer.ID, input.TagIds); err != nil { return err } } // update image table if len(imageData) > 0 { - if err := qb.UpdateImage(ctx, performer.ID, imageData); err != nil { + if err := qb.UpdateImage(ctx, newPerformer.ID, imageData); err != nil { return err } } @@ -163,7 +168,7 @@ func (r *mutationResolver) PerformerCreate(ctx context.Context, input PerformerC // Save the stash_ids if input.StashIds != nil { stashIDJoins := stashIDPtrSliceToSlice(input.StashIds) - if err := qb.UpdateStashIDs(ctx, performer.ID, stashIDJoins); err != nil { + if err := qb.UpdateStashIDs(ctx, newPerformer.ID, stashIDJoins); err != nil { return err } } @@ -173,17 +178,14 @@ func (r *mutationResolver) PerformerCreate(ctx context.Context, input PerformerC return nil, err } - r.hookExecutor.ExecutePostHooks(ctx, performer.ID, plugin.PerformerCreatePost, input, nil) - return r.getPerformer(ctx, performer.ID) + r.hookExecutor.ExecutePostHooks(ctx, newPerformer.ID, plugin.PerformerCreatePost, input, nil) + return r.getPerformer(ctx, newPerformer.ID) } func (r *mutationResolver) PerformerUpdate(ctx context.Context, input PerformerUpdateInput) (*models.Performer, error) { // Populate performer from the input performerID, _ := strconv.Atoi(input.ID) - updatedPerformer := models.PerformerPartial{ - ID: performerID, - UpdatedAt: &models.SQLiteTimestamp{Timestamp: time.Now()}, - } + updatedPerformer := models.NewPerformerPartial() translator := changesetTranslator{ inputMap: getUpdateInputMap(ctx), @@ -203,54 +205,62 @@ func (r *mutationResolver) PerformerUpdate(ctx context.Context, input PerformerU // generate checksum from performer name rather than image checksum := md5.FromString(*input.Name) - updatedPerformer.Name = &sql.NullString{String: *input.Name, Valid: true} - updatedPerformer.Checksum = &checksum + updatedPerformer.Name = models.NewOptionalString(*input.Name) + updatedPerformer.Checksum = models.NewOptionalString(checksum) } - updatedPerformer.URL = translator.nullString(input.URL, "url") + updatedPerformer.URL = translator.optionalString(input.URL, "url") if translator.hasField("gender") { if input.Gender != nil { - updatedPerformer.Gender = &sql.NullString{String: input.Gender.String(), Valid: true} + updatedPerformer.Gender = models.NewOptionalString(input.Gender.String()) } else { - updatedPerformer.Gender = &sql.NullString{String: "", Valid: false} + updatedPerformer.Gender = models.NewOptionalStringPtr(nil) } } - updatedPerformer.Birthdate = translator.sqliteDate(input.Birthdate, "birthdate") - updatedPerformer.Country = translator.nullString(input.Country, "country") - updatedPerformer.EyeColor = translator.nullString(input.EyeColor, "eye_color") - updatedPerformer.Measurements = translator.nullString(input.Measurements, "measurements") - updatedPerformer.Height = translator.nullString(input.Height, "height") - updatedPerformer.Ethnicity = translator.nullString(input.Ethnicity, "ethnicity") - updatedPerformer.FakeTits = translator.nullString(input.FakeTits, "fake_tits") - updatedPerformer.CareerLength = translator.nullString(input.CareerLength, "career_length") - updatedPerformer.Tattoos = translator.nullString(input.Tattoos, "tattoos") - updatedPerformer.Piercings = translator.nullString(input.Piercings, "piercings") - updatedPerformer.Aliases = translator.nullString(input.Aliases, "aliases") - updatedPerformer.Twitter = translator.nullString(input.Twitter, "twitter") - updatedPerformer.Instagram = translator.nullString(input.Instagram, "instagram") - updatedPerformer.Favorite = translator.nullBool(input.Favorite, "favorite") - updatedPerformer.Rating = translator.nullInt64(input.Rating, "rating") - updatedPerformer.Details = translator.nullString(input.Details, "details") - updatedPerformer.DeathDate = translator.sqliteDate(input.DeathDate, "death_date") - updatedPerformer.HairColor = translator.nullString(input.HairColor, "hair_color") - updatedPerformer.Weight = translator.nullInt64(input.Weight, "weight") - updatedPerformer.IgnoreAutoTag = input.IgnoreAutoTag + updatedPerformer.Birthdate = translator.optionalDate(input.Birthdate, "birthdate") + updatedPerformer.Country = translator.optionalString(input.Country, "country") + updatedPerformer.EyeColor = translator.optionalString(input.EyeColor, "eye_color") + updatedPerformer.Measurements = translator.optionalString(input.Measurements, "measurements") + // prefer height_cm over height + if translator.hasField("height_cm") { + updatedPerformer.Height = translator.optionalInt(input.HeightCm, "height_cm") + } else if translator.hasField("height") { + updatedPerformer.Height, err = translator.optionalIntFromString(input.Height, "height") + if err != nil { + return nil, err + } + } + + updatedPerformer.Ethnicity = translator.optionalString(input.Ethnicity, "ethnicity") + updatedPerformer.FakeTits = translator.optionalString(input.FakeTits, "fake_tits") + updatedPerformer.CareerLength = translator.optionalString(input.CareerLength, "career_length") + updatedPerformer.Tattoos = translator.optionalString(input.Tattoos, "tattoos") + updatedPerformer.Piercings = translator.optionalString(input.Piercings, "piercings") + updatedPerformer.Aliases = translator.optionalString(input.Aliases, "aliases") + updatedPerformer.Twitter = translator.optionalString(input.Twitter, "twitter") + updatedPerformer.Instagram = translator.optionalString(input.Instagram, "instagram") + updatedPerformer.Favorite = translator.optionalBool(input.Favorite, "favorite") + updatedPerformer.Rating = translator.ratingConversionOptional(input.Rating, input.Rating100) + updatedPerformer.Details = translator.optionalString(input.Details, "details") + updatedPerformer.DeathDate = translator.optionalDate(input.DeathDate, "death_date") + updatedPerformer.HairColor = translator.optionalString(input.HairColor, "hair_color") + updatedPerformer.Weight = translator.optionalInt(input.Weight, "weight") + updatedPerformer.IgnoreAutoTag = translator.optionalBool(input.IgnoreAutoTag, "ignore_auto_tag") // Start the transaction and save the p - var p *models.Performer if err := r.withTxn(ctx, func(ctx context.Context) error { qb := r.repository.Performer // need to get existing performer - existing, err := qb.Find(ctx, updatedPerformer.ID) + existing, err := qb.Find(ctx, performerID) if err != nil { return err } if existing == nil { - return fmt.Errorf("performer with id %d not found", updatedPerformer.ID) + return fmt.Errorf("performer with id %d not found", performerID) } if err := performer.ValidateDeathDate(existing, input.Birthdate, input.DeathDate); err != nil { @@ -259,26 +269,26 @@ func (r *mutationResolver) PerformerUpdate(ctx context.Context, input PerformerU } } - p, err = qb.Update(ctx, updatedPerformer) + _, err = qb.UpdatePartial(ctx, performerID, updatedPerformer) if err != nil { return err } // Save the tags if translator.hasField("tag_ids") { - if err := r.updatePerformerTags(ctx, p.ID, input.TagIds); err != nil { + if err := r.updatePerformerTags(ctx, performerID, input.TagIds); err != nil { return err } } // update image table if len(imageData) > 0 { - if err := qb.UpdateImage(ctx, p.ID, imageData); err != nil { + if err := qb.UpdateImage(ctx, performerID, imageData); err != nil { return err } } else if imageIncluded { // must be unsetting - if err := qb.DestroyImage(ctx, p.ID); err != nil { + if err := qb.DestroyImage(ctx, performerID); err != nil { return err } } @@ -296,8 +306,8 @@ func (r *mutationResolver) PerformerUpdate(ctx context.Context, input PerformerU return nil, err } - r.hookExecutor.ExecutePostHooks(ctx, p.ID, plugin.PerformerUpdatePost, input, translator.getFields()) - return r.getPerformer(ctx, p.ID) + r.hookExecutor.ExecutePostHooks(ctx, performerID, plugin.PerformerUpdatePost, input, translator.getFields()) + return r.getPerformer(ctx, performerID) } func (r *mutationResolver) updatePerformerTags(ctx context.Context, performerID int, tagsIDs []string) error { @@ -315,43 +325,48 @@ func (r *mutationResolver) BulkPerformerUpdate(ctx context.Context, input BulkPe } // Populate performer from the input - updatedTime := time.Now() - translator := changesetTranslator{ inputMap: getUpdateInputMap(ctx), } - updatedPerformer := models.PerformerPartial{ - UpdatedAt: &models.SQLiteTimestamp{Timestamp: updatedTime}, + updatedPerformer := models.NewPerformerPartial() + + updatedPerformer.URL = translator.optionalString(input.URL, "url") + updatedPerformer.Birthdate = translator.optionalDate(input.Birthdate, "birthdate") + updatedPerformer.Ethnicity = translator.optionalString(input.Ethnicity, "ethnicity") + updatedPerformer.Country = translator.optionalString(input.Country, "country") + updatedPerformer.EyeColor = translator.optionalString(input.EyeColor, "eye_color") + // prefer height_cm over height + if translator.hasField("height_cm") { + updatedPerformer.Height = translator.optionalInt(input.HeightCm, "height_cm") + } else if translator.hasField("height") { + updatedPerformer.Height, err = translator.optionalIntFromString(input.Height, "height") + if err != nil { + return nil, err + } } - updatedPerformer.URL = translator.nullString(input.URL, "url") - updatedPerformer.Birthdate = translator.sqliteDate(input.Birthdate, "birthdate") - updatedPerformer.Ethnicity = translator.nullString(input.Ethnicity, "ethnicity") - updatedPerformer.Country = translator.nullString(input.Country, "country") - updatedPerformer.EyeColor = translator.nullString(input.EyeColor, "eye_color") - updatedPerformer.Height = translator.nullString(input.Height, "height") - updatedPerformer.Measurements = translator.nullString(input.Measurements, "measurements") - updatedPerformer.FakeTits = translator.nullString(input.FakeTits, "fake_tits") - updatedPerformer.CareerLength = translator.nullString(input.CareerLength, "career_length") - updatedPerformer.Tattoos = translator.nullString(input.Tattoos, "tattoos") - updatedPerformer.Piercings = translator.nullString(input.Piercings, "piercings") - updatedPerformer.Aliases = translator.nullString(input.Aliases, "aliases") - updatedPerformer.Twitter = translator.nullString(input.Twitter, "twitter") - updatedPerformer.Instagram = translator.nullString(input.Instagram, "instagram") - updatedPerformer.Favorite = translator.nullBool(input.Favorite, "favorite") - updatedPerformer.Rating = translator.nullInt64(input.Rating, "rating") - updatedPerformer.Details = translator.nullString(input.Details, "details") - updatedPerformer.DeathDate = translator.sqliteDate(input.DeathDate, "death_date") - updatedPerformer.HairColor = translator.nullString(input.HairColor, "hair_color") - updatedPerformer.Weight = translator.nullInt64(input.Weight, "weight") - updatedPerformer.IgnoreAutoTag = input.IgnoreAutoTag + updatedPerformer.Measurements = translator.optionalString(input.Measurements, "measurements") + updatedPerformer.FakeTits = translator.optionalString(input.FakeTits, "fake_tits") + updatedPerformer.CareerLength = translator.optionalString(input.CareerLength, "career_length") + updatedPerformer.Tattoos = translator.optionalString(input.Tattoos, "tattoos") + updatedPerformer.Piercings = translator.optionalString(input.Piercings, "piercings") + updatedPerformer.Aliases = translator.optionalString(input.Aliases, "aliases") + updatedPerformer.Twitter = translator.optionalString(input.Twitter, "twitter") + updatedPerformer.Instagram = translator.optionalString(input.Instagram, "instagram") + updatedPerformer.Favorite = translator.optionalBool(input.Favorite, "favorite") + updatedPerformer.Rating = translator.ratingConversionOptional(input.Rating, input.Rating100) + updatedPerformer.Details = translator.optionalString(input.Details, "details") + updatedPerformer.DeathDate = translator.optionalDate(input.DeathDate, "death_date") + updatedPerformer.HairColor = translator.optionalString(input.HairColor, "hair_color") + updatedPerformer.Weight = translator.optionalInt(input.Weight, "weight") + updatedPerformer.IgnoreAutoTag = translator.optionalBool(input.IgnoreAutoTag, "ignore_auto_tag") if translator.hasField("gender") { if input.Gender != nil { - updatedPerformer.Gender = &sql.NullString{String: input.Gender.String(), Valid: true} + updatedPerformer.Gender = models.NewOptionalString(input.Gender.String()) } else { - updatedPerformer.Gender = &sql.NullString{String: "", Valid: false} + updatedPerformer.Gender = models.NewOptionalStringPtr(nil) } } @@ -378,7 +393,7 @@ func (r *mutationResolver) BulkPerformerUpdate(ctx context.Context, input BulkPe return err } - performer, err := qb.Update(ctx, updatedPerformer) + performer, err := qb.UpdatePartial(ctx, performerID, updatedPerformer) if err != nil { return err } diff --git a/internal/api/resolver_mutation_scene.go b/internal/api/resolver_mutation_scene.go index cc59c76c4..301bd3b8e 100644 --- a/internal/api/resolver_mutation_scene.go +++ b/internal/api/resolver_mutation_scene.go @@ -3,6 +3,7 @@ package api import ( "context" "database/sql" + "errors" "fmt" "strconv" "time" @@ -30,6 +31,79 @@ func (r *mutationResolver) getScene(ctx context.Context, id int) (ret *models.Sc return ret, nil } +func (r *mutationResolver) SceneCreate(ctx context.Context, input SceneCreateInput) (ret *models.Scene, err error) { + translator := changesetTranslator{ + inputMap: getUpdateInputMap(ctx), + } + + performerIDs, err := stringslice.StringSliceToIntSlice(input.PerformerIds) + if err != nil { + return nil, fmt.Errorf("converting performer ids: %w", err) + } + tagIDs, err := stringslice.StringSliceToIntSlice(input.TagIds) + if err != nil { + return nil, fmt.Errorf("converting tag ids: %w", err) + } + galleryIDs, err := stringslice.StringSliceToIntSlice(input.GalleryIds) + if err != nil { + return nil, fmt.Errorf("converting gallery ids: %w", err) + } + + moviesScenes, err := models.MoviesScenesFromInput(input.Movies) + if err != nil { + return nil, fmt.Errorf("converting movies scenes: %w", err) + } + + fileIDsInt, err := stringslice.StringSliceToIntSlice(input.FileIds) + if err != nil { + return nil, fmt.Errorf("converting file ids: %w", err) + } + + fileIDs := make([]file.ID, len(fileIDsInt)) + for i, v := range fileIDsInt { + fileIDs[i] = file.ID(v) + } + + newScene := models.Scene{ + Title: translator.string(input.Title, "title"), + Code: translator.string(input.Code, "code"), + Details: translator.string(input.Details, "details"), + Director: translator.string(input.Director, "director"), + URL: translator.string(input.URL, "url"), + Date: translator.datePtr(input.Date, "date"), + Rating: translator.ratingConversionInt(input.Rating, input.Rating100), + Organized: translator.bool(input.Organized, "organized"), + PerformerIDs: models.NewRelatedIDs(performerIDs), + TagIDs: models.NewRelatedIDs(tagIDs), + GalleryIDs: models.NewRelatedIDs(galleryIDs), + Movies: models.NewRelatedMovies(moviesScenes), + StashIDs: models.NewRelatedStashIDs(stashIDPtrSliceToSlice(input.StashIds)), + } + + newScene.StudioID, err = translator.intPtrFromString(input.StudioID, "studio_id") + if err != nil { + return nil, fmt.Errorf("converting studio id: %w", err) + } + + var coverImageData []byte + if input.CoverImage != nil && *input.CoverImage != "" { + var err error + coverImageData, err = utils.ProcessImageInput(ctx, *input.CoverImage) + if err != nil { + return nil, err + } + } + + if err := r.withTxn(ctx, func(ctx context.Context) error { + ret, err = r.Resolver.sceneService.Create(ctx, &newScene, fileIDs, coverImageData) + return err + }); err != nil { + return nil, err + } + + return ret, nil +} + func (r *mutationResolver) SceneUpdate(ctx context.Context, input models.SceneUpdateInput) (ret *models.Scene, err error) { translator := changesetTranslator{ inputMap: getUpdateInputMap(ctx), @@ -90,32 +164,19 @@ func (r *mutationResolver) ScenesUpdate(ctx context.Context, input []*models.Sce return newRet, nil } -func (r *mutationResolver) sceneUpdate(ctx context.Context, input models.SceneUpdateInput, translator changesetTranslator) (*models.Scene, error) { - // Populate scene from the input - sceneID, err := strconv.Atoi(input.ID) - if err != nil { - return nil, err - } - - qb := r.repository.Scene - - s, err := qb.Find(ctx, sceneID) - if err != nil { - return nil, err - } - - if s == nil { - return nil, fmt.Errorf("scene with id %d not found", sceneID) - } - - var coverImageData []byte - +func scenePartialFromInput(input models.SceneUpdateInput, translator changesetTranslator) (*models.ScenePartial, error) { updatedScene := models.NewScenePartial() updatedScene.Title = translator.optionalString(input.Title, "title") + updatedScene.Code = translator.optionalString(input.Code, "code") updatedScene.Details = translator.optionalString(input.Details, "details") + updatedScene.Director = translator.optionalString(input.Director, "director") updatedScene.URL = translator.optionalString(input.URL, "url") updatedScene.Date = translator.optionalDate(input.Date, "date") - updatedScene.Rating = translator.optionalInt(input.Rating, "rating") + updatedScene.Rating = translator.ratingConversionOptional(input.Rating, input.Rating100) + updatedScene.OCounter = translator.optionalInt(input.OCounter, "o_counter") + updatedScene.PlayCount = translator.optionalInt(input.PlayCount, "play_count") + updatedScene.PlayDuration = translator.optionalFloat64(input.PlayDuration, "play_duration") + var err error updatedScene.StudioID, err = translator.optionalIntFromString(input.StudioID, "studio_id") if err != nil { return nil, fmt.Errorf("converting studio id: %w", err) @@ -131,36 +192,6 @@ func (r *mutationResolver) sceneUpdate(ctx context.Context, input models.SceneUp converted := file.ID(primaryFileID) updatedScene.PrimaryFileID = &converted - - // if file hash has changed, we should migrate generated files - // after commit - if err := s.LoadFiles(ctx, r.repository.Scene); err != nil { - return nil, err - } - - // ensure that new primary file is associated with scene - var f *file.VideoFile - for _, ff := range s.Files.List() { - if ff.ID == converted { - f = ff - } - } - - if f == nil { - return nil, fmt.Errorf("file with id %d not associated with scene", converted) - } - - fileNamingAlgorithm := config.GetInstance().GetVideoFileNamingAlgorithm() - oldHash := scene.GetHash(s.Files.Primary(), fileNamingAlgorithm) - newHash := scene.GetHash(f, fileNamingAlgorithm) - - if oldHash != "" && newHash != "" && oldHash != newHash { - // perform migration after commit - txn.AddPostCommitHook(ctx, func(ctx context.Context) error { - scene.MigrateHash(manager.GetInstance().Paths, oldHash, newHash) - return nil - }) - } } if translator.hasField("performer_ids") { @@ -200,39 +231,107 @@ func (r *mutationResolver) sceneUpdate(ctx context.Context, input models.SceneUp } } + return &updatedScene, nil +} + +func (r *mutationResolver) sceneUpdate(ctx context.Context, input models.SceneUpdateInput, translator changesetTranslator) (*models.Scene, error) { + // Populate scene from the input + sceneID, err := strconv.Atoi(input.ID) + if err != nil { + return nil, err + } + + qb := r.repository.Scene + + s, err := qb.Find(ctx, sceneID) + if err != nil { + return nil, err + } + + if s == nil { + return nil, fmt.Errorf("scene with id %d not found", sceneID) + } + + var coverImageData []byte + + updatedScene, err := scenePartialFromInput(input, translator) + if err != nil { + return nil, err + } + + // ensure that title is set where scene has no file + if updatedScene.Title.Set && updatedScene.Title.Value == "" { + if err := s.LoadFiles(ctx, r.repository.Scene); err != nil { + return nil, err + } + + if len(s.Files.List()) == 0 { + return nil, errors.New("title must be set if scene has no files") + } + } + + if updatedScene.PrimaryFileID != nil { + newPrimaryFileID := *updatedScene.PrimaryFileID + + // if file hash has changed, we should migrate generated files + // after commit + if err := s.LoadFiles(ctx, r.repository.Scene); err != nil { + return nil, err + } + + // ensure that new primary file is associated with scene + var f *file.VideoFile + for _, ff := range s.Files.List() { + if ff.ID == newPrimaryFileID { + f = ff + } + } + + if f == nil { + return nil, fmt.Errorf("file with id %d not associated with scene", newPrimaryFileID) + } + } + if input.CoverImage != nil && *input.CoverImage != "" { var err error coverImageData, err = utils.ProcessImageInput(ctx, *input.CoverImage) if err != nil { return nil, err } - - // update the cover after updating the scene } - s, err = qb.UpdatePartial(ctx, sceneID, updatedScene) + s, err = qb.UpdatePartial(ctx, sceneID, *updatedScene) if err != nil { return nil, err } - // update cover table - if len(coverImageData) > 0 { - if err := qb.UpdateCover(ctx, sceneID, coverImageData); err != nil { - return nil, err - } - } - - // only update the cover image if provided and everything else was successful - if coverImageData != nil { - err = scene.SetScreenshot(manager.GetInstance().Paths, s.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm()), coverImageData) - if err != nil { - return nil, err - } + if err := r.sceneUpdateCoverImage(ctx, s, coverImageData); err != nil { + return nil, err } return s, nil } +func (r *mutationResolver) sceneUpdateCoverImage(ctx context.Context, s *models.Scene, coverImageData []byte) error { + if len(coverImageData) > 0 { + qb := r.repository.Scene + + // update cover table + if err := qb.UpdateCover(ctx, s.ID, coverImageData); err != nil { + return err + } + + if s.Path != "" { + // update the file-based screenshot after commit + txn.AddPostCommitHook(ctx, func(ctx context.Context) error { + return scene.SetScreenshot(manager.GetInstance().Paths, s.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm()), coverImageData) + }) + } + } + + return nil +} + func (r *mutationResolver) BulkSceneUpdate(ctx context.Context, input BulkSceneUpdateInput) ([]*models.Scene, error) { sceneIDs, err := stringslice.StringSliceToIntSlice(input.Ids) if err != nil { @@ -246,10 +345,12 @@ func (r *mutationResolver) BulkSceneUpdate(ctx context.Context, input BulkSceneU updatedScene := models.NewScenePartial() updatedScene.Title = translator.optionalString(input.Title, "title") + updatedScene.Code = translator.optionalString(input.Code, "code") updatedScene.Details = translator.optionalString(input.Details, "details") + updatedScene.Director = translator.optionalString(input.Director, "director") updatedScene.URL = translator.optionalString(input.URL, "url") updatedScene.Date = translator.optionalDate(input.Date, "date") - updatedScene.Rating = translator.optionalInt(input.Rating, "rating") + updatedScene.Rating = translator.ratingConversionOptional(input.Rating, input.Rating100) updatedScene.StudioID, err = translator.optionalIntFromString(input.StudioID, "studio_id") if err != nil { return nil, fmt.Errorf("converting studio id: %w", err) @@ -482,6 +583,84 @@ func (r *mutationResolver) ScenesDestroy(ctx context.Context, input models.Scene return true, nil } +func (r *mutationResolver) SceneAssignFile(ctx context.Context, input AssignSceneFileInput) (bool, error) { + sceneID, err := strconv.Atoi(input.SceneID) + if err != nil { + return false, fmt.Errorf("converting scene ID: %w", err) + } + + fileIDInt, err := strconv.Atoi(input.FileID) + if err != nil { + return false, fmt.Errorf("converting file ID: %w", err) + } + + fileID := file.ID(fileIDInt) + + if err := r.withTxn(ctx, func(ctx context.Context) error { + return r.Resolver.sceneService.AssignFile(ctx, sceneID, fileID) + }); err != nil { + return false, fmt.Errorf("assigning file to scene: %w", err) + } + + return true, nil +} + +func (r *mutationResolver) SceneMerge(ctx context.Context, input SceneMergeInput) (*models.Scene, error) { + srcIDs, err := stringslice.StringSliceToIntSlice(input.Source) + if err != nil { + return nil, fmt.Errorf("converting source IDs: %w", err) + } + + destID, err := strconv.Atoi(input.Destination) + if err != nil { + return nil, fmt.Errorf("converting destination ID %s: %w", input.Destination, err) + } + + var values *models.ScenePartial + if input.Values != nil { + translator := changesetTranslator{ + inputMap: getNamedUpdateInputMap(ctx, "input.values"), + } + + values, err = scenePartialFromInput(*input.Values, translator) + if err != nil { + return nil, err + } + } else { + v := models.NewScenePartial() + values = &v + } + + var coverImageData []byte + + if input.Values.CoverImage != nil && *input.Values.CoverImage != "" { + var err error + coverImageData, err = utils.ProcessImageInput(ctx, *input.Values.CoverImage) + if err != nil { + return nil, err + } + } + + var ret *models.Scene + if err := r.withTxn(ctx, func(ctx context.Context) error { + if err := r.Resolver.sceneService.Merge(ctx, srcIDs, destID, *values); err != nil { + return err + } + + ret, err = r.Resolver.repository.Scene.Find(ctx, destID) + + if err == nil && ret != nil { + err = r.sceneUpdateCoverImage(ctx, ret, coverImageData) + } + + return err + }); err != nil { + return nil, err + } + + return ret, nil +} + func (r *mutationResolver) getSceneMarker(ctx context.Context, id int) (ret *models.SceneMarker, err error) { if err := r.withTxn(ctx, func(ctx context.Context) error { ret, err = r.repository.SceneMarker.Find(ctx, id) @@ -679,6 +858,42 @@ func (r *mutationResolver) changeMarker(ctx context.Context, changeType int, cha return sceneMarker, nil } +func (r *mutationResolver) SceneSaveActivity(ctx context.Context, id string, resumeTime *float64, playDuration *float64) (ret bool, err error) { + sceneID, err := strconv.Atoi(id) + if err != nil { + return false, err + } + + if err := r.withTxn(ctx, func(ctx context.Context) error { + qb := r.repository.Scene + + ret, err = qb.SaveActivity(ctx, sceneID, resumeTime, playDuration) + return err + }); err != nil { + return false, err + } + + return ret, nil +} + +func (r *mutationResolver) SceneIncrementPlayCount(ctx context.Context, id string) (ret int, err error) { + sceneID, err := strconv.Atoi(id) + if err != nil { + return 0, err + } + + if err := r.withTxn(ctx, func(ctx context.Context) error { + qb := r.repository.Scene + + ret, err = qb.IncrementWatchCount(ctx, sceneID) + return err + }); err != nil { + return 0, err + } + + return ret, nil +} + func (r *mutationResolver) SceneIncrementO(ctx context.Context, id string) (ret int, err error) { sceneID, err := strconv.Atoi(id) if err != nil { diff --git a/internal/api/resolver_mutation_stash_box.go b/internal/api/resolver_mutation_stash_box.go index 22cc1799e..d1a7e2de2 100644 --- a/internal/api/resolver_mutation_stash_box.go +++ b/internal/api/resolver_mutation_stash_box.go @@ -51,7 +51,7 @@ func (r *mutationResolver) SubmitStashBoxSceneDraft(ctx context.Context, input S } var res *string - err = r.withTxn(ctx, func(ctx context.Context) error { + err = r.withReadTxn(ctx, func(ctx context.Context) error { qb := r.repository.Scene scene, err := qb.Find(ctx, id) if err != nil { @@ -82,7 +82,7 @@ func (r *mutationResolver) SubmitStashBoxPerformerDraft(ctx context.Context, inp } var res *string - err = r.withTxn(ctx, func(ctx context.Context) error { + err = r.withReadTxn(ctx, func(ctx context.Context) error { qb := r.repository.Performer performer, err := qb.Find(ctx, id) if err != nil { diff --git a/internal/api/resolver_mutation_studio.go b/internal/api/resolver_mutation_studio.go index e9ee8965b..98c871323 100644 --- a/internal/api/resolver_mutation_studio.go +++ b/internal/api/resolver_mutation_studio.go @@ -58,11 +58,18 @@ func (r *mutationResolver) StudioCreate(ctx context.Context, input StudioCreateI newStudio.ParentID = sql.NullInt64{Int64: parentID, Valid: true} } - if input.Rating != nil { - newStudio.Rating = sql.NullInt64{Int64: int64(*input.Rating), Valid: true} - } else { - newStudio.Rating = sql.NullInt64{Valid: false} + if input.Rating100 != nil { + newStudio.Rating = sql.NullInt64{ + Int64: int64(*input.Rating100), + Valid: true, + } + } else if input.Rating != nil { + newStudio.Rating = sql.NullInt64{ + Int64: int64(models.Rating5To100(*input.Rating)), + Valid: true, + } } + if input.Details != nil { newStudio.Details = sql.NullString{String: *input.Details, Valid: true} } @@ -150,7 +157,7 @@ func (r *mutationResolver) StudioUpdate(ctx context.Context, input StudioUpdateI updatedStudio.URL = translator.nullString(input.URL, "url") updatedStudio.Details = translator.nullString(input.Details, "details") updatedStudio.ParentID = translator.nullInt64FromString(input.ParentID, "parent_id") - updatedStudio.Rating = translator.nullInt64(input.Rating, "rating") + updatedStudio.Rating = translator.ratingConversion(input.Rating, input.Rating100) updatedStudio.IgnoreAutoTag = input.IgnoreAutoTag // Start the transaction and save the studio diff --git a/internal/api/resolver_query_configuration.go b/internal/api/resolver_query_configuration.go index 64a6f0364..941fb9a49 100644 --- a/internal/api/resolver_query_configuration.go +++ b/internal/api/resolver_query_configuration.go @@ -142,6 +142,8 @@ func makeConfigInterfaceResult() *ConfigInterfaceResult { showStudioAsText := config.GetShowStudioAsText() css := config.GetCSS() cssEnabled := config.GetCSSEnabled() + javascript := config.GetJavascript() + javascriptEnabled := config.GetJavascriptEnabled() customLocales := config.GetCustomLocales() customLocalesEnabled := config.GetCustomLocalesEnabled() language := config.GetLanguage() @@ -166,6 +168,8 @@ func makeConfigInterfaceResult() *ConfigInterfaceResult { ContinuePlaylistDefault: &continuePlaylistDefault, CSS: &css, CSSEnabled: &cssEnabled, + Javascript: &javascript, + JavascriptEnabled: &javascriptEnabled, CustomLocales: &customLocales, CustomLocalesEnabled: &customLocalesEnabled, Language: &language, diff --git a/internal/api/resolver_query_find_gallery.go b/internal/api/resolver_query_find_gallery.go index ee12471d1..e8d47d70b 100644 --- a/internal/api/resolver_query_find_gallery.go +++ b/internal/api/resolver_query_find_gallery.go @@ -13,7 +13,7 @@ func (r *queryResolver) FindGallery(ctx context.Context, id string) (ret *models return nil, err } - if err := r.withTxn(ctx, func(ctx context.Context) error { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { ret, err = r.repository.Gallery.Find(ctx, idInt) return err }); err != nil { @@ -24,7 +24,7 @@ func (r *queryResolver) FindGallery(ctx context.Context, id string) (ret *models } func (r *queryResolver) FindGalleries(ctx context.Context, galleryFilter *models.GalleryFilterType, filter *models.FindFilterType) (ret *FindGalleriesResultType, err error) { - if err := r.withTxn(ctx, func(ctx context.Context) error { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { galleries, total, err := r.repository.Gallery.Query(ctx, galleryFilter, filter) if err != nil { return err diff --git a/internal/api/resolver_query_find_image.go b/internal/api/resolver_query_find_image.go index ad9bf6c94..6468ba9f3 100644 --- a/internal/api/resolver_query_find_image.go +++ b/internal/api/resolver_query_find_image.go @@ -12,7 +12,7 @@ import ( func (r *queryResolver) FindImage(ctx context.Context, id *string, checksum *string) (*models.Image, error) { var image *models.Image - if err := r.withTxn(ctx, func(ctx context.Context) error { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { qb := r.repository.Image var err error @@ -47,7 +47,7 @@ func (r *queryResolver) FindImage(ctx context.Context, id *string, checksum *str } func (r *queryResolver) FindImages(ctx context.Context, imageFilter *models.ImageFilterType, imageIds []int, filter *models.FindFilterType) (ret *FindImagesResultType, err error) { - if err := r.withTxn(ctx, func(ctx context.Context) error { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { qb := r.repository.Image fields := graphql.CollectAllFields(ctx) diff --git a/internal/api/resolver_query_find_movie.go b/internal/api/resolver_query_find_movie.go index 7505c7f36..a7e72dbdc 100644 --- a/internal/api/resolver_query_find_movie.go +++ b/internal/api/resolver_query_find_movie.go @@ -13,7 +13,7 @@ func (r *queryResolver) FindMovie(ctx context.Context, id string) (ret *models.M return nil, err } - if err := r.withTxn(ctx, func(ctx context.Context) error { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { ret, err = r.repository.Movie.Find(ctx, idInt) return err }); err != nil { @@ -24,7 +24,7 @@ func (r *queryResolver) FindMovie(ctx context.Context, id string) (ret *models.M } func (r *queryResolver) FindMovies(ctx context.Context, movieFilter *models.MovieFilterType, filter *models.FindFilterType) (ret *FindMoviesResultType, err error) { - if err := r.withTxn(ctx, func(ctx context.Context) error { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { movies, total, err := r.repository.Movie.Query(ctx, movieFilter, filter) if err != nil { return err @@ -44,7 +44,7 @@ func (r *queryResolver) FindMovies(ctx context.Context, movieFilter *models.Movi } func (r *queryResolver) AllMovies(ctx context.Context) (ret []*models.Movie, err error) { - if err := r.withTxn(ctx, func(ctx context.Context) error { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { ret, err = r.repository.Movie.All(ctx) return err }); err != nil { diff --git a/internal/api/resolver_query_find_performer.go b/internal/api/resolver_query_find_performer.go index 4314b0f69..437ac8fcf 100644 --- a/internal/api/resolver_query_find_performer.go +++ b/internal/api/resolver_query_find_performer.go @@ -13,7 +13,7 @@ func (r *queryResolver) FindPerformer(ctx context.Context, id string) (ret *mode return nil, err } - if err := r.withTxn(ctx, func(ctx context.Context) error { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { ret, err = r.repository.Performer.Find(ctx, idInt) return err }); err != nil { @@ -24,7 +24,7 @@ func (r *queryResolver) FindPerformer(ctx context.Context, id string) (ret *mode } func (r *queryResolver) FindPerformers(ctx context.Context, performerFilter *models.PerformerFilterType, filter *models.FindFilterType) (ret *FindPerformersResultType, err error) { - if err := r.withTxn(ctx, func(ctx context.Context) error { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { performers, total, err := r.repository.Performer.Query(ctx, performerFilter, filter) if err != nil { return err @@ -43,7 +43,7 @@ func (r *queryResolver) FindPerformers(ctx context.Context, performerFilter *mod } func (r *queryResolver) AllPerformers(ctx context.Context) (ret []*models.Performer, err error) { - if err := r.withTxn(ctx, func(ctx context.Context) error { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { ret, err = r.repository.Performer.All(ctx) return err }); err != nil { diff --git a/internal/api/resolver_query_find_saved_filter.go b/internal/api/resolver_query_find_saved_filter.go index 7b934f581..4f196fd65 100644 --- a/internal/api/resolver_query_find_saved_filter.go +++ b/internal/api/resolver_query_find_saved_filter.go @@ -13,7 +13,7 @@ func (r *queryResolver) FindSavedFilter(ctx context.Context, id string) (ret *mo return nil, err } - if err := r.withTxn(ctx, func(ctx context.Context) error { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { ret, err = r.repository.SavedFilter.Find(ctx, idInt) return err }); err != nil { @@ -23,7 +23,7 @@ func (r *queryResolver) FindSavedFilter(ctx context.Context, id string) (ret *mo } func (r *queryResolver) FindSavedFilters(ctx context.Context, mode *models.FilterMode) (ret []*models.SavedFilter, err error) { - if err := r.withTxn(ctx, func(ctx context.Context) error { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { if mode != nil { ret, err = r.repository.SavedFilter.FindByMode(ctx, *mode) } else { @@ -37,7 +37,7 @@ func (r *queryResolver) FindSavedFilters(ctx context.Context, mode *models.Filte } func (r *queryResolver) FindDefaultFilter(ctx context.Context, mode models.FilterMode) (ret *models.SavedFilter, err error) { - if err := r.withTxn(ctx, func(ctx context.Context) error { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { ret, err = r.repository.SavedFilter.FindDefault(ctx, mode) return err }); err != nil { diff --git a/internal/api/resolver_query_find_scene.go b/internal/api/resolver_query_find_scene.go index 9f049805f..95519cd49 100644 --- a/internal/api/resolver_query_find_scene.go +++ b/internal/api/resolver_query_find_scene.go @@ -12,7 +12,7 @@ import ( func (r *queryResolver) FindScene(ctx context.Context, id *string, checksum *string) (*models.Scene, error) { var scene *models.Scene - if err := r.withTxn(ctx, func(ctx context.Context) error { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { qb := r.repository.Scene var err error if id != nil { @@ -43,7 +43,7 @@ func (r *queryResolver) FindScene(ctx context.Context, id *string, checksum *str func (r *queryResolver) FindSceneByHash(ctx context.Context, input SceneHashInput) (*models.Scene, error) { var scene *models.Scene - if err := r.withTxn(ctx, func(ctx context.Context) error { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { qb := r.repository.Scene if input.Checksum != nil { scenes, err := qb.FindByChecksum(ctx, *input.Checksum) @@ -74,7 +74,7 @@ func (r *queryResolver) FindSceneByHash(ctx context.Context, input SceneHashInpu } func (r *queryResolver) FindScenes(ctx context.Context, sceneFilter *models.SceneFilterType, sceneIDs []int, filter *models.FindFilterType) (ret *FindScenesResultType, err error) { - if err := r.withTxn(ctx, func(ctx context.Context) error { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { var scenes []*models.Scene var err error @@ -135,7 +135,7 @@ func (r *queryResolver) FindScenes(ctx context.Context, sceneFilter *models.Scen } func (r *queryResolver) FindScenesByPathRegex(ctx context.Context, filter *models.FindFilterType) (ret *FindScenesResultType, err error) { - if err := r.withTxn(ctx, func(ctx context.Context) error { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { sceneFilter := &models.SceneFilterType{} @@ -192,7 +192,7 @@ func (r *queryResolver) FindScenesByPathRegex(ctx context.Context, filter *model func (r *queryResolver) ParseSceneFilenames(ctx context.Context, filter *models.FindFilterType, config manager.SceneParserInput) (ret *SceneParserResultType, err error) { parser := manager.NewSceneFilenameParser(filter, config) - if err := r.withTxn(ctx, func(ctx context.Context) error { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { result, count, err := parser.Parse(ctx, manager.SceneFilenameParserRepository{ Scene: r.repository.Scene, Performer: r.repository.Performer, @@ -223,7 +223,7 @@ func (r *queryResolver) FindDuplicateScenes(ctx context.Context, distance *int) if distance != nil { dist = *distance } - if err := r.withTxn(ctx, func(ctx context.Context) error { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { ret, err = r.repository.Scene.FindDuplicates(ctx, dist) return err }); err != nil { diff --git a/internal/api/resolver_query_find_scene_marker.go b/internal/api/resolver_query_find_scene_marker.go index 03b9e261a..4bd70e658 100644 --- a/internal/api/resolver_query_find_scene_marker.go +++ b/internal/api/resolver_query_find_scene_marker.go @@ -7,7 +7,7 @@ import ( ) func (r *queryResolver) FindSceneMarkers(ctx context.Context, sceneMarkerFilter *models.SceneMarkerFilterType, filter *models.FindFilterType) (ret *FindSceneMarkersResultType, err error) { - if err := r.withTxn(ctx, func(ctx context.Context) error { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { sceneMarkers, total, err := r.repository.SceneMarker.Query(ctx, sceneMarkerFilter, filter) if err != nil { return err diff --git a/internal/api/resolver_query_find_studio.go b/internal/api/resolver_query_find_studio.go index 0bd17b9ad..51cac6208 100644 --- a/internal/api/resolver_query_find_studio.go +++ b/internal/api/resolver_query_find_studio.go @@ -13,7 +13,7 @@ func (r *queryResolver) FindStudio(ctx context.Context, id string) (ret *models. return nil, err } - if err := r.withTxn(ctx, func(ctx context.Context) error { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { var err error ret, err = r.repository.Studio.Find(ctx, idInt) return err @@ -25,7 +25,7 @@ func (r *queryResolver) FindStudio(ctx context.Context, id string) (ret *models. } func (r *queryResolver) FindStudios(ctx context.Context, studioFilter *models.StudioFilterType, filter *models.FindFilterType) (ret *FindStudiosResultType, err error) { - if err := r.withTxn(ctx, func(ctx context.Context) error { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { studios, total, err := r.repository.Studio.Query(ctx, studioFilter, filter) if err != nil { return err @@ -45,7 +45,7 @@ func (r *queryResolver) FindStudios(ctx context.Context, studioFilter *models.St } func (r *queryResolver) AllStudios(ctx context.Context) (ret []*models.Studio, err error) { - if err := r.withTxn(ctx, func(ctx context.Context) error { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { ret, err = r.repository.Studio.All(ctx) return err }); err != nil { diff --git a/internal/api/resolver_query_find_tag.go b/internal/api/resolver_query_find_tag.go index 77bd57f98..fd4b04ad2 100644 --- a/internal/api/resolver_query_find_tag.go +++ b/internal/api/resolver_query_find_tag.go @@ -13,7 +13,7 @@ func (r *queryResolver) FindTag(ctx context.Context, id string) (ret *models.Tag return nil, err } - if err := r.withTxn(ctx, func(ctx context.Context) error { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { ret, err = r.repository.Tag.Find(ctx, idInt) return err }); err != nil { @@ -24,7 +24,7 @@ func (r *queryResolver) FindTag(ctx context.Context, id string) (ret *models.Tag } func (r *queryResolver) FindTags(ctx context.Context, tagFilter *models.TagFilterType, filter *models.FindFilterType) (ret *FindTagsResultType, err error) { - if err := r.withTxn(ctx, func(ctx context.Context) error { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { tags, total, err := r.repository.Tag.Query(ctx, tagFilter, filter) if err != nil { return err @@ -44,7 +44,7 @@ func (r *queryResolver) FindTags(ctx context.Context, tagFilter *models.TagFilte } func (r *queryResolver) AllTags(ctx context.Context) (ret []*models.Tag, err error) { - if err := r.withTxn(ctx, func(ctx context.Context) error { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { ret, err = r.repository.Tag.All(ctx) return err }); err != nil { diff --git a/internal/api/resolver_query_scene.go b/internal/api/resolver_query_scene.go index b6da7b901..f4dea464e 100644 --- a/internal/api/resolver_query_scene.go +++ b/internal/api/resolver_query_scene.go @@ -14,7 +14,7 @@ import ( func (r *queryResolver) SceneStreams(ctx context.Context, id *string) ([]*manager.SceneStreamEndpoint, error) { // find the scene var scene *models.Scene - if err := r.withTxn(ctx, func(ctx context.Context) error { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { idInt, _ := strconv.Atoi(*id) var err error scene, err = r.repository.Scene.Find(ctx, idInt) diff --git a/internal/api/routes_custom.go b/internal/api/routes_custom.go new file mode 100644 index 000000000..731bbc586 --- /dev/null +++ b/internal/api/routes_custom.go @@ -0,0 +1,35 @@ +package api + +import ( + "net/http" + "strings" + + "github.com/go-chi/chi" + "github.com/stashapp/stash/internal/manager/config" +) + +type customRoutes struct { + servedFolders config.URLMap +} + +func (rs customRoutes) Routes() chi.Router { + r := chi.NewRouter() + + r.HandleFunc("/*", func(w http.ResponseWriter, r *http.Request) { + r.URL.Path = strings.Replace(r.URL.Path, "/custom", "", 1) + + // http.FileServer redirects to / if the path ends with index.html + r.URL.Path = strings.TrimSuffix(r.URL.Path, "/index.html") + + // map the path to the applicable filesystem location + var dir string + r.URL.Path, dir = rs.servedFolders.GetFilesystemLocation(r.URL.Path) + if dir != "" { + http.FileServer(http.Dir(dir)).ServeHTTP(w, r) + } else { + http.NotFound(w, r) + } + }) + + return r +} diff --git a/internal/api/routes_image.go b/internal/api/routes_image.go index b89821155..7ac8c99ae 100644 --- a/internal/api/routes_image.go +++ b/internal/api/routes_image.go @@ -143,7 +143,7 @@ func (rs imageRoutes) ImageCtx(next http.Handler) http.Handler { imageID, _ := strconv.Atoi(imageIdentifierQueryParam) var image *models.Image - _ = txn.WithTxn(r.Context(), rs.txnManager, func(ctx context.Context) error { + _ = txn.WithReadTxn(r.Context(), rs.txnManager, func(ctx context.Context) error { qb := rs.imageFinder if imageID == 0 { images, _ := qb.FindByChecksum(ctx, imageIdentifierQueryParam) diff --git a/internal/api/routes_movie.go b/internal/api/routes_movie.go index 032fefca1..c29718566 100644 --- a/internal/api/routes_movie.go +++ b/internal/api/routes_movie.go @@ -41,7 +41,7 @@ func (rs movieRoutes) FrontImage(w http.ResponseWriter, r *http.Request) { defaultParam := r.URL.Query().Get("default") var image []byte if defaultParam != "true" { - readTxnErr := txn.WithTxn(r.Context(), rs.txnManager, func(ctx context.Context) error { + readTxnErr := txn.WithReadTxn(r.Context(), rs.txnManager, func(ctx context.Context) error { image, _ = rs.movieFinder.GetFrontImage(ctx, movie.ID) return nil }) @@ -67,7 +67,7 @@ func (rs movieRoutes) BackImage(w http.ResponseWriter, r *http.Request) { defaultParam := r.URL.Query().Get("default") var image []byte if defaultParam != "true" { - readTxnErr := txn.WithTxn(r.Context(), rs.txnManager, func(ctx context.Context) error { + readTxnErr := txn.WithReadTxn(r.Context(), rs.txnManager, func(ctx context.Context) error { image, _ = rs.movieFinder.GetBackImage(ctx, movie.ID) return nil }) @@ -97,7 +97,7 @@ func (rs movieRoutes) MovieCtx(next http.Handler) http.Handler { } var movie *models.Movie - _ = txn.WithTxn(r.Context(), rs.txnManager, func(ctx context.Context) error { + _ = txn.WithReadTxn(r.Context(), rs.txnManager, func(ctx context.Context) error { movie, _ = rs.movieFinder.Find(ctx, movieID) return nil }) diff --git a/internal/api/routes_performer.go b/internal/api/routes_performer.go index 40e41833c..c8295467a 100644 --- a/internal/api/routes_performer.go +++ b/internal/api/routes_performer.go @@ -41,7 +41,7 @@ func (rs performerRoutes) Image(w http.ResponseWriter, r *http.Request) { var image []byte if defaultParam != "true" { - readTxnErr := txn.WithTxn(r.Context(), rs.txnManager, func(ctx context.Context) error { + readTxnErr := txn.WithReadTxn(r.Context(), rs.txnManager, func(ctx context.Context) error { image, _ = rs.performerFinder.GetImage(ctx, performer.ID) return nil }) @@ -54,7 +54,7 @@ func (rs performerRoutes) Image(w http.ResponseWriter, r *http.Request) { } if len(image) == 0 || defaultParam == "true" { - image, _ = getRandomPerformerImageUsingName(performer.Name.String, performer.Gender.String, config.GetInstance().GetCustomPerformerImageLocation()) + image, _ = getRandomPerformerImageUsingName(performer.Name, performer.Gender, config.GetInstance().GetCustomPerformerImageLocation()) } if err := utils.ServeImage(image, w, r); err != nil { @@ -71,7 +71,7 @@ func (rs performerRoutes) PerformerCtx(next http.Handler) http.Handler { } var performer *models.Performer - _ = txn.WithTxn(r.Context(), rs.txnManager, func(ctx context.Context) error { + _ = txn.WithReadTxn(r.Context(), rs.txnManager, func(ctx context.Context) error { var err error performer, err = rs.performerFinder.Find(ctx, performerID) return err diff --git a/internal/api/routes_scene.go b/internal/api/routes_scene.go index da76e3526..d1b1b02c8 100644 --- a/internal/api/routes_scene.go +++ b/internal/api/routes_scene.go @@ -264,7 +264,7 @@ func (rs sceneRoutes) getChapterVttTitle(ctx context.Context, marker *models.Sce } var title string - if err := txn.WithTxn(ctx, rs.txnManager, func(ctx context.Context) error { + if err := txn.WithReadTxn(ctx, rs.txnManager, func(ctx context.Context) error { qb := rs.tagFinder primaryTag, err := qb.Find(ctx, marker.PrimaryTagID) if err != nil { @@ -293,7 +293,7 @@ func (rs sceneRoutes) getChapterVttTitle(ctx context.Context, marker *models.Sce func (rs sceneRoutes) ChapterVtt(w http.ResponseWriter, r *http.Request) { scene := r.Context().Value(sceneKey).(*models.Scene) var sceneMarkers []*models.SceneMarker - readTxnErr := txn.WithTxn(r.Context(), rs.txnManager, func(ctx context.Context) error { + readTxnErr := txn.WithReadTxn(r.Context(), rs.txnManager, func(ctx context.Context) error { var err error sceneMarkers, err = rs.sceneMarkerFinder.FindBySceneID(ctx, scene.ID) return err @@ -349,7 +349,7 @@ func (rs sceneRoutes) Caption(w http.ResponseWriter, r *http.Request, lang strin s := r.Context().Value(sceneKey).(*models.Scene) var captions []*models.VideoCaption - readTxnErr := txn.WithTxn(r.Context(), rs.txnManager, func(ctx context.Context) error { + readTxnErr := txn.WithReadTxn(r.Context(), rs.txnManager, func(ctx context.Context) error { var err error primaryFile := s.Files.Primary() if primaryFile == nil { @@ -377,14 +377,14 @@ func (rs sceneRoutes) Caption(w http.ResponseWriter, r *http.Request, lang strin sub, err := video.ReadSubs(caption.Path(s.Path)) if err != nil { logger.Warnf("error while reading subs: %v", err) - http.Error(w, readTxnErr.Error(), http.StatusInternalServerError) + http.Error(w, err.Error(), http.StatusInternalServerError) return } var b bytes.Buffer err = sub.WriteToWebVTT(&b) if err != nil { - http.Error(w, readTxnErr.Error(), http.StatusInternalServerError) + http.Error(w, err.Error(), http.StatusInternalServerError) return } @@ -423,7 +423,7 @@ func (rs sceneRoutes) SceneMarkerStream(w http.ResponseWriter, r *http.Request) scene := r.Context().Value(sceneKey).(*models.Scene) sceneMarkerID, _ := strconv.Atoi(chi.URLParam(r, "sceneMarkerId")) var sceneMarker *models.SceneMarker - readTxnErr := txn.WithTxn(r.Context(), rs.txnManager, func(ctx context.Context) error { + readTxnErr := txn.WithReadTxn(r.Context(), rs.txnManager, func(ctx context.Context) error { var err error sceneMarker, err = rs.sceneMarkerFinder.Find(ctx, sceneMarkerID) return err @@ -450,7 +450,7 @@ func (rs sceneRoutes) SceneMarkerPreview(w http.ResponseWriter, r *http.Request) scene := r.Context().Value(sceneKey).(*models.Scene) sceneMarkerID, _ := strconv.Atoi(chi.URLParam(r, "sceneMarkerId")) var sceneMarker *models.SceneMarker - readTxnErr := txn.WithTxn(r.Context(), rs.txnManager, func(ctx context.Context) error { + readTxnErr := txn.WithReadTxn(r.Context(), rs.txnManager, func(ctx context.Context) error { var err error sceneMarker, err = rs.sceneMarkerFinder.Find(ctx, sceneMarkerID) return err @@ -487,7 +487,7 @@ func (rs sceneRoutes) SceneMarkerScreenshot(w http.ResponseWriter, r *http.Reque scene := r.Context().Value(sceneKey).(*models.Scene) sceneMarkerID, _ := strconv.Atoi(chi.URLParam(r, "sceneMarkerId")) var sceneMarker *models.SceneMarker - readTxnErr := txn.WithTxn(r.Context(), rs.txnManager, func(ctx context.Context) error { + readTxnErr := txn.WithReadTxn(r.Context(), rs.txnManager, func(ctx context.Context) error { var err error sceneMarker, err = rs.sceneMarkerFinder.Find(ctx, sceneMarkerID) return err @@ -528,7 +528,7 @@ func (rs sceneRoutes) SceneCtx(next http.Handler) http.Handler { sceneID, _ := strconv.Atoi(sceneIdentifierQueryParam) var scene *models.Scene - _ = txn.WithTxn(r.Context(), rs.txnManager, func(ctx context.Context) error { + _ = txn.WithReadTxn(r.Context(), rs.txnManager, func(ctx context.Context) error { qb := rs.sceneFinder if sceneID == 0 { var scenes []*models.Scene diff --git a/internal/api/routes_studio.go b/internal/api/routes_studio.go index c0b51b715..2ddeb51a3 100644 --- a/internal/api/routes_studio.go +++ b/internal/api/routes_studio.go @@ -41,7 +41,7 @@ func (rs studioRoutes) Image(w http.ResponseWriter, r *http.Request) { var image []byte if defaultParam != "true" { - readTxnErr := txn.WithTxn(r.Context(), rs.txnManager, func(ctx context.Context) error { + readTxnErr := txn.WithReadTxn(r.Context(), rs.txnManager, func(ctx context.Context) error { image, _ = rs.studioFinder.GetImage(ctx, studio.ID) return nil }) @@ -71,7 +71,7 @@ func (rs studioRoutes) StudioCtx(next http.Handler) http.Handler { } var studio *models.Studio - _ = txn.WithTxn(r.Context(), rs.txnManager, func(ctx context.Context) error { + _ = txn.WithReadTxn(r.Context(), rs.txnManager, func(ctx context.Context) error { var err error studio, err = rs.studioFinder.Find(ctx, studioID) return err diff --git a/internal/api/routes_tag.go b/internal/api/routes_tag.go index 1773e0daa..1f72928c2 100644 --- a/internal/api/routes_tag.go +++ b/internal/api/routes_tag.go @@ -41,7 +41,7 @@ func (rs tagRoutes) Image(w http.ResponseWriter, r *http.Request) { var image []byte if defaultParam != "true" { - readTxnErr := txn.WithTxn(r.Context(), rs.txnManager, func(ctx context.Context) error { + readTxnErr := txn.WithReadTxn(r.Context(), rs.txnManager, func(ctx context.Context) error { image, _ = rs.tagFinder.GetImage(ctx, tag.ID) return nil }) @@ -71,7 +71,7 @@ func (rs tagRoutes) TagCtx(next http.Handler) http.Handler { } var tag *models.Tag - _ = txn.WithTxn(r.Context(), rs.txnManager, func(ctx context.Context) error { + _ = txn.WithReadTxn(r.Context(), rs.txnManager, func(ctx context.Context) error { var err error tag, err = rs.tagFinder.Find(ctx, tagID) return err diff --git a/internal/api/server.go b/internal/api/server.go index 5f7c96c85..124c89739 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -181,6 +181,21 @@ func Start() error { http.ServeFile(w, r, fn) }) + r.HandleFunc("/javascript", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/javascript") + if !c.GetJavascriptEnabled() { + return + } + + // search for custom.js in current directory, then $HOME/.stash + fn := c.GetJavascriptPath() + exists, _ := fsutil.FileExists(fn) + if !exists { + return + } + + http.ServeFile(w, r, fn) + }) r.HandleFunc("/customlocales", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") if c.GetCustomLocalesEnabled() { @@ -216,18 +231,9 @@ func Start() error { // Serve static folders customServedFolders := c.GetCustomServedFolders() if customServedFolders != nil { - r.HandleFunc("/custom/*", func(w http.ResponseWriter, r *http.Request) { - r.URL.Path = strings.Replace(r.URL.Path, "/custom", "", 1) - - // map the path to the applicable filesystem location - var dir string - r.URL.Path, dir = customServedFolders.GetFilesystemLocation(r.URL.Path) - if dir != "" { - http.FileServer(http.Dir(dir)).ServeHTTP(w, r) - } else { - http.NotFound(w, r) - } - }) + r.Mount("/custom", customRoutes{ + servedFolders: customServedFolders, + }.Routes()) } customUILocation := c.GetCustomUILocation() diff --git a/internal/api/urlbuilders/performer.go b/internal/api/urlbuilders/performer.go index e7e0b2626..841078e2c 100644 --- a/internal/api/urlbuilders/performer.go +++ b/internal/api/urlbuilders/performer.go @@ -1,8 +1,9 @@ package urlbuilders import ( - "github.com/stashapp/stash/pkg/models" "strconv" + + "github.com/stashapp/stash/pkg/models" ) type PerformerURLBuilder struct { @@ -15,7 +16,7 @@ func NewPerformerURLBuilder(baseURL string, performer *models.Performer) Perform return PerformerURLBuilder{ BaseURL: baseURL, PerformerID: strconv.Itoa(performer.ID), - UpdatedAt: strconv.FormatInt(performer.UpdatedAt.Timestamp.Unix(), 10), + UpdatedAt: strconv.FormatInt(performer.UpdatedAt.Unix(), 10), } } diff --git a/internal/autotag/gallery_test.go b/internal/autotag/gallery_test.go index ac7da4e26..cae45e6c9 100644 --- a/internal/autotag/gallery_test.go +++ b/internal/autotag/gallery_test.go @@ -22,14 +22,14 @@ func TestGalleryPerformers(t *testing.T) { const performerID = 2 performer := models.Performer{ ID: performerID, - Name: models.NullString(performerName), + Name: performerName, } const reversedPerformerName = "name performer" const reversedPerformerID = 3 reversedPerformer := models.Performer{ ID: reversedPerformerID, - Name: models.NullString(reversedPerformerName), + Name: reversedPerformerName, } testTables := generateTestTable(performerName, galleryExt) diff --git a/internal/autotag/image_test.go b/internal/autotag/image_test.go index 653cb2c2d..b98842398 100644 --- a/internal/autotag/image_test.go +++ b/internal/autotag/image_test.go @@ -19,14 +19,14 @@ func TestImagePerformers(t *testing.T) { const performerID = 2 performer := models.Performer{ ID: performerID, - Name: models.NullString(performerName), + Name: performerName, } const reversedPerformerName = "name performer" const reversedPerformerID = 3 reversedPerformer := models.Performer{ ID: reversedPerformerID, - Name: models.NullString(reversedPerformerName), + Name: reversedPerformerName, } testTables := generateTestTable(performerName, imageExt) diff --git a/internal/autotag/integration_test.go b/internal/autotag/integration_test.go index 7c5952652..e49c3637a 100644 --- a/internal/autotag/integration_test.go +++ b/internal/autotag/integration_test.go @@ -87,11 +87,10 @@ func createPerformer(ctx context.Context, pqb models.PerformerWriter) error { // create the performer performer := models.Performer{ Checksum: testName, - Name: sql.NullString{Valid: true, String: testName}, - Favorite: sql.NullBool{Valid: true, Bool: false}, + Name: testName, } - _, err := pqb.Create(ctx, performer) + err := pqb.Create(ctx, &performer) if err != nil { return err } @@ -480,6 +479,10 @@ func withTxn(f func(ctx context.Context) error) error { return txn.WithTxn(context.TODO(), db, f) } +func withDB(f func(ctx context.Context) error) error { + return txn.WithDatabase(context.TODO(), db, f) +} + func populateDB() error { if err := withTxn(func(ctx context.Context) error { err := createPerformer(ctx, r.Performer) @@ -539,9 +542,13 @@ func TestParsePerformerScenes(t *testing.T) { return } + tagger := Tagger{ + TxnManager: db, + } + for _, p := range performers { - if err := withTxn(func(ctx context.Context) error { - return PerformerScenes(ctx, p, nil, r.Scene, nil) + if err := withDB(func(ctx context.Context) error { + return tagger.PerformerScenes(ctx, p, nil, r.Scene) }); err != nil { t.Errorf("Error auto-tagging performers: %s", err) } @@ -586,14 +593,18 @@ func TestParseStudioScenes(t *testing.T) { return } + tagger := Tagger{ + TxnManager: db, + } + for _, s := range studios { - if err := withTxn(func(ctx context.Context) error { + if err := withDB(func(ctx context.Context) error { aliases, err := r.Studio.GetAliases(ctx, s.ID) if err != nil { return err } - return StudioScenes(ctx, s, nil, aliases, r.Scene, nil) + return tagger.StudioScenes(ctx, s, nil, aliases, r.Scene) }); err != nil { t.Errorf("Error auto-tagging performers: %s", err) } @@ -642,14 +653,18 @@ func TestParseTagScenes(t *testing.T) { return } + tagger := Tagger{ + TxnManager: db, + } + for _, s := range tags { - if err := withTxn(func(ctx context.Context) error { + if err := withDB(func(ctx context.Context) error { aliases, err := r.Tag.GetAliases(ctx, s.ID) if err != nil { return err } - return TagScenes(ctx, s, nil, aliases, r.Scene, nil) + return tagger.TagScenes(ctx, s, nil, aliases, r.Scene) }); err != nil { t.Errorf("Error auto-tagging performers: %s", err) } @@ -694,9 +709,13 @@ func TestParsePerformerImages(t *testing.T) { return } + tagger := Tagger{ + TxnManager: db, + } + for _, p := range performers { - if err := withTxn(func(ctx context.Context) error { - return PerformerImages(ctx, p, nil, r.Image, nil) + if err := withDB(func(ctx context.Context) error { + return tagger.PerformerImages(ctx, p, nil, r.Image) }); err != nil { t.Errorf("Error auto-tagging performers: %s", err) } @@ -742,14 +761,18 @@ func TestParseStudioImages(t *testing.T) { return } + tagger := Tagger{ + TxnManager: db, + } + for _, s := range studios { - if err := withTxn(func(ctx context.Context) error { + if err := withDB(func(ctx context.Context) error { aliases, err := r.Studio.GetAliases(ctx, s.ID) if err != nil { return err } - return StudioImages(ctx, s, nil, aliases, r.Image, nil) + return tagger.StudioImages(ctx, s, nil, aliases, r.Image) }); err != nil { t.Errorf("Error auto-tagging performers: %s", err) } @@ -798,14 +821,18 @@ func TestParseTagImages(t *testing.T) { return } + tagger := Tagger{ + TxnManager: db, + } + for _, s := range tags { - if err := withTxn(func(ctx context.Context) error { + if err := withDB(func(ctx context.Context) error { aliases, err := r.Tag.GetAliases(ctx, s.ID) if err != nil { return err } - return TagImages(ctx, s, nil, aliases, r.Image, nil) + return tagger.TagImages(ctx, s, nil, aliases, r.Image) }); err != nil { t.Errorf("Error auto-tagging performers: %s", err) } @@ -851,9 +878,13 @@ func TestParsePerformerGalleries(t *testing.T) { return } + tagger := Tagger{ + TxnManager: db, + } + for _, p := range performers { - if err := withTxn(func(ctx context.Context) error { - return PerformerGalleries(ctx, p, nil, r.Gallery, nil) + if err := withDB(func(ctx context.Context) error { + return tagger.PerformerGalleries(ctx, p, nil, r.Gallery) }); err != nil { t.Errorf("Error auto-tagging performers: %s", err) } @@ -899,14 +930,18 @@ func TestParseStudioGalleries(t *testing.T) { return } + tagger := Tagger{ + TxnManager: db, + } + for _, s := range studios { - if err := withTxn(func(ctx context.Context) error { + if err := withDB(func(ctx context.Context) error { aliases, err := r.Studio.GetAliases(ctx, s.ID) if err != nil { return err } - return StudioGalleries(ctx, s, nil, aliases, r.Gallery, nil) + return tagger.StudioGalleries(ctx, s, nil, aliases, r.Gallery) }); err != nil { t.Errorf("Error auto-tagging performers: %s", err) } @@ -955,14 +990,18 @@ func TestParseTagGalleries(t *testing.T) { return } + tagger := Tagger{ + TxnManager: db, + } + for _, s := range tags { - if err := withTxn(func(ctx context.Context) error { + if err := withDB(func(ctx context.Context) error { aliases, err := r.Tag.GetAliases(ctx, s.ID) if err != nil { return err } - return TagGalleries(ctx, s, nil, aliases, r.Gallery, nil) + return tagger.TagGalleries(ctx, s, nil, aliases, r.Gallery) }); err != nil { t.Errorf("Error auto-tagging performers: %s", err) } diff --git a/internal/autotag/performer.go b/internal/autotag/performer.go index f240dc0c5..c18bf0b0a 100644 --- a/internal/autotag/performer.go +++ b/internal/autotag/performer.go @@ -9,6 +9,7 @@ import ( "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/scene" "github.com/stashapp/stash/pkg/sliceutil/intslice" + "github.com/stashapp/stash/pkg/txn" ) type SceneQueryPerformerUpdater interface { @@ -33,14 +34,14 @@ func getPerformerTagger(p *models.Performer, cache *match.Cache) tagger { return tagger{ ID: p.ID, Type: "performer", - Name: p.Name.String, + Name: p.Name, cache: cache, } } // PerformerScenes searches for scenes whose path matches the provided performer name and tags the scene with the performer. -func PerformerScenes(ctx context.Context, p *models.Performer, paths []string, rw SceneQueryPerformerUpdater, cache *match.Cache) error { - t := getPerformerTagger(p, cache) +func (tagger *Tagger) PerformerScenes(ctx context.Context, p *models.Performer, paths []string, rw SceneQueryPerformerUpdater) error { + t := getPerformerTagger(p, tagger.Cache) return t.tagScenes(ctx, paths, rw, func(o *models.Scene) (bool, error) { if err := o.LoadPerformerIDs(ctx, rw); err != nil { @@ -52,7 +53,9 @@ func PerformerScenes(ctx context.Context, p *models.Performer, paths []string, r return false, nil } - if err := scene.AddPerformer(ctx, rw, o, p.ID); err != nil { + if err := txn.WithTxn(ctx, tagger.TxnManager, func(ctx context.Context) error { + return scene.AddPerformer(ctx, rw, o, p.ID) + }); err != nil { return false, err } @@ -61,8 +64,8 @@ func PerformerScenes(ctx context.Context, p *models.Performer, paths []string, r } // PerformerImages searches for images whose path matches the provided performer name and tags the image with the performer. -func PerformerImages(ctx context.Context, p *models.Performer, paths []string, rw ImageQueryPerformerUpdater, cache *match.Cache) error { - t := getPerformerTagger(p, cache) +func (tagger *Tagger) PerformerImages(ctx context.Context, p *models.Performer, paths []string, rw ImageQueryPerformerUpdater) error { + t := getPerformerTagger(p, tagger.Cache) return t.tagImages(ctx, paths, rw, func(o *models.Image) (bool, error) { if err := o.LoadPerformerIDs(ctx, rw); err != nil { @@ -74,7 +77,9 @@ func PerformerImages(ctx context.Context, p *models.Performer, paths []string, r return false, nil } - if err := image.AddPerformer(ctx, rw, o, p.ID); err != nil { + if err := txn.WithTxn(ctx, tagger.TxnManager, func(ctx context.Context) error { + return image.AddPerformer(ctx, rw, o, p.ID) + }); err != nil { return false, err } @@ -83,8 +88,8 @@ func PerformerImages(ctx context.Context, p *models.Performer, paths []string, r } // PerformerGalleries searches for galleries whose path matches the provided performer name and tags the gallery with the performer. -func PerformerGalleries(ctx context.Context, p *models.Performer, paths []string, rw GalleryQueryPerformerUpdater, cache *match.Cache) error { - t := getPerformerTagger(p, cache) +func (tagger *Tagger) PerformerGalleries(ctx context.Context, p *models.Performer, paths []string, rw GalleryQueryPerformerUpdater) error { + t := getPerformerTagger(p, tagger.Cache) return t.tagGalleries(ctx, paths, rw, func(o *models.Gallery) (bool, error) { if err := o.LoadPerformerIDs(ctx, rw); err != nil { @@ -96,7 +101,9 @@ func PerformerGalleries(ctx context.Context, p *models.Performer, paths []string return false, nil } - if err := gallery.AddPerformer(ctx, rw, o, p.ID); err != nil { + if err := txn.WithTxn(ctx, tagger.TxnManager, func(ctx context.Context) error { + return gallery.AddPerformer(ctx, rw, o, p.ID) + }); err != nil { return false, err } diff --git a/internal/autotag/performer_test.go b/internal/autotag/performer_test.go index 71161cbfe..c2590b19a 100644 --- a/internal/autotag/performer_test.go +++ b/internal/autotag/performer_test.go @@ -9,6 +9,7 @@ import ( "github.com/stashapp/stash/pkg/models/mocks" "github.com/stashapp/stash/pkg/scene" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" ) func TestPerformerScenes(t *testing.T) { @@ -60,11 +61,13 @@ func testPerformerScenes(t *testing.T, performerName, expectedRegex string) { performer := models.Performer{ ID: performerID, - Name: models.NullString(performerName), + Name: performerName, } organized := false - perPage := models.PerPageAll + perPage := 1000 + sort := "id" + direction := models.SortDirectionEnumAsc expectedSceneFilter := &models.SceneFilterType{ Organized: &organized, @@ -75,15 +78,17 @@ func testPerformerScenes(t *testing.T, performerName, expectedRegex string) { } expectedFindFilter := &models.FindFilterType{ - PerPage: &perPage, + PerPage: &perPage, + Sort: &sort, + Direction: &direction, } - mockSceneReader.On("Query", testCtx, scene.QueryOptions(expectedSceneFilter, expectedFindFilter, false)). + mockSceneReader.On("Query", mock.Anything, scene.QueryOptions(expectedSceneFilter, expectedFindFilter, false)). Return(mocks.SceneQueryResult(scenes, len(scenes)), nil).Once() for i := range matchingPaths { sceneID := i + 1 - mockSceneReader.On("UpdatePartial", testCtx, sceneID, models.ScenePartial{ + mockSceneReader.On("UpdatePartial", mock.Anything, sceneID, models.ScenePartial{ PerformerIDs: &models.UpdateIDs{ IDs: []int{performerID}, Mode: models.RelationshipUpdateModeAdd, @@ -91,7 +96,11 @@ func testPerformerScenes(t *testing.T, performerName, expectedRegex string) { }).Return(nil, nil).Once() } - err := PerformerScenes(testCtx, &performer, nil, mockSceneReader, nil) + tagger := Tagger{ + TxnManager: &mocks.TxnManager{}, + } + + err := tagger.PerformerScenes(testCtx, &performer, nil, mockSceneReader) assert := assert.New(t) @@ -140,11 +149,13 @@ func testPerformerImages(t *testing.T, performerName, expectedRegex string) { performer := models.Performer{ ID: performerID, - Name: models.NullString(performerName), + Name: performerName, } organized := false - perPage := models.PerPageAll + perPage := 1000 + sort := "id" + direction := models.SortDirectionEnumAsc expectedImageFilter := &models.ImageFilterType{ Organized: &organized, @@ -155,15 +166,17 @@ func testPerformerImages(t *testing.T, performerName, expectedRegex string) { } expectedFindFilter := &models.FindFilterType{ - PerPage: &perPage, + PerPage: &perPage, + Sort: &sort, + Direction: &direction, } - mockImageReader.On("Query", testCtx, image.QueryOptions(expectedImageFilter, expectedFindFilter, false)). + mockImageReader.On("Query", mock.Anything, image.QueryOptions(expectedImageFilter, expectedFindFilter, false)). Return(mocks.ImageQueryResult(images, len(images)), nil).Once() for i := range matchingPaths { imageID := i + 1 - mockImageReader.On("UpdatePartial", testCtx, imageID, models.ImagePartial{ + mockImageReader.On("UpdatePartial", mock.Anything, imageID, models.ImagePartial{ PerformerIDs: &models.UpdateIDs{ IDs: []int{performerID}, Mode: models.RelationshipUpdateModeAdd, @@ -171,7 +184,11 @@ func testPerformerImages(t *testing.T, performerName, expectedRegex string) { }).Return(nil, nil).Once() } - err := PerformerImages(testCtx, &performer, nil, mockImageReader, nil) + tagger := Tagger{ + TxnManager: &mocks.TxnManager{}, + } + + err := tagger.PerformerImages(testCtx, &performer, nil, mockImageReader) assert := assert.New(t) @@ -221,11 +238,13 @@ func testPerformerGalleries(t *testing.T, performerName, expectedRegex string) { performer := models.Performer{ ID: performerID, - Name: models.NullString(performerName), + Name: performerName, } organized := false - perPage := models.PerPageAll + perPage := 1000 + sort := "id" + direction := models.SortDirectionEnumAsc expectedGalleryFilter := &models.GalleryFilterType{ Organized: &organized, @@ -236,14 +255,16 @@ func testPerformerGalleries(t *testing.T, performerName, expectedRegex string) { } expectedFindFilter := &models.FindFilterType{ - PerPage: &perPage, + PerPage: &perPage, + Sort: &sort, + Direction: &direction, } - mockGalleryReader.On("Query", testCtx, expectedGalleryFilter, expectedFindFilter).Return(galleries, len(galleries), nil).Once() + mockGalleryReader.On("Query", mock.Anything, expectedGalleryFilter, expectedFindFilter).Return(galleries, len(galleries), nil).Once() for i := range matchingPaths { galleryID := i + 1 - mockGalleryReader.On("UpdatePartial", testCtx, galleryID, models.GalleryPartial{ + mockGalleryReader.On("UpdatePartial", mock.Anything, galleryID, models.GalleryPartial{ PerformerIDs: &models.UpdateIDs{ IDs: []int{performerID}, Mode: models.RelationshipUpdateModeAdd, @@ -251,7 +272,11 @@ func testPerformerGalleries(t *testing.T, performerName, expectedRegex string) { }).Return(nil, nil).Once() } - err := PerformerGalleries(testCtx, &performer, nil, mockGalleryReader, nil) + tagger := Tagger{ + TxnManager: &mocks.TxnManager{}, + } + + err := tagger.PerformerGalleries(testCtx, &performer, nil, mockGalleryReader) assert := assert.New(t) diff --git a/internal/autotag/scene_test.go b/internal/autotag/scene_test.go index 1e9766836..cb0ff32db 100644 --- a/internal/autotag/scene_test.go +++ b/internal/autotag/scene_test.go @@ -152,14 +152,14 @@ func TestScenePerformers(t *testing.T) { const performerID = 2 performer := models.Performer{ ID: performerID, - Name: models.NullString(performerName), + Name: performerName, } const reversedPerformerName = "name performer" const reversedPerformerID = 3 reversedPerformer := models.Performer{ ID: reversedPerformerID, - Name: models.NullString(reversedPerformerName), + Name: reversedPerformerName, } testTables := generateTestTable(performerName, sceneExt) diff --git a/internal/autotag/studio.go b/internal/autotag/studio.go index 4a7099dc1..238e3463e 100644 --- a/internal/autotag/studio.go +++ b/internal/autotag/studio.go @@ -8,8 +8,12 @@ import ( "github.com/stashapp/stash/pkg/match" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/scene" + "github.com/stashapp/stash/pkg/txn" ) +// the following functions aren't used in Tagger because they assume +// use within a transaction + func addSceneStudio(ctx context.Context, sceneWriter scene.PartialUpdater, o *models.Scene, studioID int) (bool, error) { // don't set if already set if o.StudioID != nil { @@ -86,12 +90,28 @@ type SceneFinderUpdater interface { } // StudioScenes searches for scenes whose path matches the provided studio name and tags the scene with the studio, if studio is not already set on the scene. -func StudioScenes(ctx context.Context, p *models.Studio, paths []string, aliases []string, rw SceneFinderUpdater, cache *match.Cache) error { - t := getStudioTagger(p, aliases, cache) +func (tagger *Tagger) StudioScenes(ctx context.Context, p *models.Studio, paths []string, aliases []string, rw SceneFinderUpdater) error { + t := getStudioTagger(p, aliases, tagger.Cache) for _, tt := range t { if err := tt.tagScenes(ctx, paths, rw, func(o *models.Scene) (bool, error) { - return addSceneStudio(ctx, rw, o, p.ID) + // don't set if already set + if o.StudioID != nil { + return false, nil + } + + // set the studio id + scenePartial := models.ScenePartial{ + StudioID: models.NewOptionalInt(p.ID), + } + + if err := txn.WithTxn(ctx, tagger.TxnManager, func(ctx context.Context) error { + _, err := rw.UpdatePartial(ctx, o.ID, scenePartial) + return err + }); err != nil { + return false, err + } + return true, nil }); err != nil { return err } @@ -107,12 +127,28 @@ type ImageFinderUpdater interface { } // StudioImages searches for images whose path matches the provided studio name and tags the image with the studio, if studio is not already set on the image. -func StudioImages(ctx context.Context, p *models.Studio, paths []string, aliases []string, rw ImageFinderUpdater, cache *match.Cache) error { - t := getStudioTagger(p, aliases, cache) +func (tagger *Tagger) StudioImages(ctx context.Context, p *models.Studio, paths []string, aliases []string, rw ImageFinderUpdater) error { + t := getStudioTagger(p, aliases, tagger.Cache) for _, tt := range t { if err := tt.tagImages(ctx, paths, rw, func(i *models.Image) (bool, error) { - return addImageStudio(ctx, rw, i, p.ID) + // don't set if already set + if i.StudioID != nil { + return false, nil + } + + // set the studio id + imagePartial := models.ImagePartial{ + StudioID: models.NewOptionalInt(p.ID), + } + + if err := txn.WithTxn(ctx, tagger.TxnManager, func(ctx context.Context) error { + _, err := rw.UpdatePartial(ctx, i.ID, imagePartial) + return err + }); err != nil { + return false, err + } + return true, nil }); err != nil { return err } @@ -128,12 +164,28 @@ type GalleryFinderUpdater interface { } // StudioGalleries searches for galleries whose path matches the provided studio name and tags the gallery with the studio, if studio is not already set on the gallery. -func StudioGalleries(ctx context.Context, p *models.Studio, paths []string, aliases []string, rw GalleryFinderUpdater, cache *match.Cache) error { - t := getStudioTagger(p, aliases, cache) +func (tagger *Tagger) StudioGalleries(ctx context.Context, p *models.Studio, paths []string, aliases []string, rw GalleryFinderUpdater) error { + t := getStudioTagger(p, aliases, tagger.Cache) for _, tt := range t { if err := tt.tagGalleries(ctx, paths, rw, func(o *models.Gallery) (bool, error) { - return addGalleryStudio(ctx, rw, o, p.ID) + // don't set if already set + if o.StudioID != nil { + return false, nil + } + + // set the studio id + galleryPartial := models.GalleryPartial{ + StudioID: models.NewOptionalInt(p.ID), + } + + if err := txn.WithTxn(ctx, tagger.TxnManager, func(ctx context.Context) error { + _, err := rw.UpdatePartial(ctx, o.ID, galleryPartial) + return err + }); err != nil { + return false, err + } + return true, nil }); err != nil { return err } diff --git a/internal/autotag/studio_test.go b/internal/autotag/studio_test.go index f7513ad03..7e20fe318 100644 --- a/internal/autotag/studio_test.go +++ b/internal/autotag/studio_test.go @@ -9,6 +9,7 @@ import ( "github.com/stashapp/stash/pkg/models/mocks" "github.com/stashapp/stash/pkg/scene" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" ) type testStudioCase struct { @@ -110,7 +111,9 @@ func testStudioScenes(t *testing.T, tc testStudioCase) { } organized := false - perPage := models.PerPageAll + perPage := 1000 + sort := "id" + direction := models.SortDirectionEnumAsc expectedSceneFilter := &models.SceneFilterType{ Organized: &organized, @@ -121,7 +124,9 @@ func testStudioScenes(t *testing.T, tc testStudioCase) { } expectedFindFilter := &models.FindFilterType{ - PerPage: &perPage, + PerPage: &perPage, + Sort: &sort, + Direction: &direction, } // if alias provided, then don't find by name @@ -140,19 +145,23 @@ func testStudioScenes(t *testing.T, tc testStudioCase) { }, } - mockSceneReader.On("Query", testCtx, scene.QueryOptions(expectedAliasFilter, expectedFindFilter, false)). + mockSceneReader.On("Query", mock.Anything, scene.QueryOptions(expectedAliasFilter, expectedFindFilter, false)). Return(mocks.SceneQueryResult(scenes, len(scenes)), nil).Once() } for i := range matchingPaths { sceneID := i + 1 expectedStudioID := studioID - mockSceneReader.On("UpdatePartial", testCtx, sceneID, models.ScenePartial{ + mockSceneReader.On("UpdatePartial", mock.Anything, sceneID, models.ScenePartial{ StudioID: models.NewOptionalInt(expectedStudioID), }).Return(nil, nil).Once() } - err := StudioScenes(testCtx, &studio, nil, aliases, mockSceneReader, nil) + tagger := Tagger{ + TxnManager: &mocks.TxnManager{}, + } + + err := tagger.StudioScenes(testCtx, &studio, nil, aliases, mockSceneReader) assert := assert.New(t) @@ -201,7 +210,9 @@ func testStudioImages(t *testing.T, tc testStudioCase) { } organized := false - perPage := models.PerPageAll + perPage := 1000 + sort := "id" + direction := models.SortDirectionEnumAsc expectedImageFilter := &models.ImageFilterType{ Organized: &organized, @@ -212,11 +223,13 @@ func testStudioImages(t *testing.T, tc testStudioCase) { } expectedFindFilter := &models.FindFilterType{ - PerPage: &perPage, + PerPage: &perPage, + Sort: &sort, + Direction: &direction, } // if alias provided, then don't find by name - onNameQuery := mockImageReader.On("Query", testCtx, image.QueryOptions(expectedImageFilter, expectedFindFilter, false)) + onNameQuery := mockImageReader.On("Query", mock.Anything, image.QueryOptions(expectedImageFilter, expectedFindFilter, false)) if aliasName == "" { onNameQuery.Return(mocks.ImageQueryResult(images, len(images)), nil).Once() } else { @@ -230,19 +243,23 @@ func testStudioImages(t *testing.T, tc testStudioCase) { }, } - mockImageReader.On("Query", testCtx, image.QueryOptions(expectedAliasFilter, expectedFindFilter, false)). + mockImageReader.On("Query", mock.Anything, image.QueryOptions(expectedAliasFilter, expectedFindFilter, false)). Return(mocks.ImageQueryResult(images, len(images)), nil).Once() } for i := range matchingPaths { imageID := i + 1 expectedStudioID := studioID - mockImageReader.On("UpdatePartial", testCtx, imageID, models.ImagePartial{ + mockImageReader.On("UpdatePartial", mock.Anything, imageID, models.ImagePartial{ StudioID: models.NewOptionalInt(expectedStudioID), }).Return(nil, nil).Once() } - err := StudioImages(testCtx, &studio, nil, aliases, mockImageReader, nil) + tagger := Tagger{ + TxnManager: &mocks.TxnManager{}, + } + + err := tagger.StudioImages(testCtx, &studio, nil, aliases, mockImageReader) assert := assert.New(t) @@ -291,7 +308,9 @@ func testStudioGalleries(t *testing.T, tc testStudioCase) { } organized := false - perPage := models.PerPageAll + perPage := 1000 + sort := "id" + direction := models.SortDirectionEnumAsc expectedGalleryFilter := &models.GalleryFilterType{ Organized: &organized, @@ -302,11 +321,13 @@ func testStudioGalleries(t *testing.T, tc testStudioCase) { } expectedFindFilter := &models.FindFilterType{ - PerPage: &perPage, + PerPage: &perPage, + Sort: &sort, + Direction: &direction, } // if alias provided, then don't find by name - onNameQuery := mockGalleryReader.On("Query", testCtx, expectedGalleryFilter, expectedFindFilter) + onNameQuery := mockGalleryReader.On("Query", mock.Anything, expectedGalleryFilter, expectedFindFilter) if aliasName == "" { onNameQuery.Return(galleries, len(galleries), nil).Once() } else { @@ -320,18 +341,22 @@ func testStudioGalleries(t *testing.T, tc testStudioCase) { }, } - mockGalleryReader.On("Query", testCtx, expectedAliasFilter, expectedFindFilter).Return(galleries, len(galleries), nil).Once() + mockGalleryReader.On("Query", mock.Anything, expectedAliasFilter, expectedFindFilter).Return(galleries, len(galleries), nil).Once() } for i := range matchingPaths { galleryID := i + 1 expectedStudioID := studioID - mockGalleryReader.On("UpdatePartial", testCtx, galleryID, models.GalleryPartial{ + mockGalleryReader.On("UpdatePartial", mock.Anything, galleryID, models.GalleryPartial{ StudioID: models.NewOptionalInt(expectedStudioID), }).Return(nil, nil).Once() } - err := StudioGalleries(testCtx, &studio, nil, aliases, mockGalleryReader, nil) + tagger := Tagger{ + TxnManager: &mocks.TxnManager{}, + } + + err := tagger.StudioGalleries(testCtx, &studio, nil, aliases, mockGalleryReader) assert := assert.New(t) diff --git a/internal/autotag/tag.go b/internal/autotag/tag.go index ab90b62cc..94c7c1bb3 100644 --- a/internal/autotag/tag.go +++ b/internal/autotag/tag.go @@ -9,6 +9,7 @@ import ( "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/scene" "github.com/stashapp/stash/pkg/sliceutil/intslice" + "github.com/stashapp/stash/pkg/txn" ) type SceneQueryTagUpdater interface { @@ -50,8 +51,8 @@ func getTagTaggers(p *models.Tag, aliases []string, cache *match.Cache) []tagger } // TagScenes searches for scenes whose path matches the provided tag name and tags the scene with the tag. -func TagScenes(ctx context.Context, p *models.Tag, paths []string, aliases []string, rw SceneQueryTagUpdater, cache *match.Cache) error { - t := getTagTaggers(p, aliases, cache) +func (tagger *Tagger) TagScenes(ctx context.Context, p *models.Tag, paths []string, aliases []string, rw SceneQueryTagUpdater) error { + t := getTagTaggers(p, aliases, tagger.Cache) for _, tt := range t { if err := tt.tagScenes(ctx, paths, rw, func(o *models.Scene) (bool, error) { @@ -64,7 +65,9 @@ func TagScenes(ctx context.Context, p *models.Tag, paths []string, aliases []str return false, nil } - if err := scene.AddTag(ctx, rw, o, p.ID); err != nil { + if err := txn.WithTxn(ctx, tagger.TxnManager, func(ctx context.Context) error { + return scene.AddTag(ctx, rw, o, p.ID) + }); err != nil { return false, err } @@ -77,8 +80,8 @@ func TagScenes(ctx context.Context, p *models.Tag, paths []string, aliases []str } // TagImages searches for images whose path matches the provided tag name and tags the image with the tag. -func TagImages(ctx context.Context, p *models.Tag, paths []string, aliases []string, rw ImageQueryTagUpdater, cache *match.Cache) error { - t := getTagTaggers(p, aliases, cache) +func (tagger *Tagger) TagImages(ctx context.Context, p *models.Tag, paths []string, aliases []string, rw ImageQueryTagUpdater) error { + t := getTagTaggers(p, aliases, tagger.Cache) for _, tt := range t { if err := tt.tagImages(ctx, paths, rw, func(o *models.Image) (bool, error) { @@ -91,7 +94,9 @@ func TagImages(ctx context.Context, p *models.Tag, paths []string, aliases []str return false, nil } - if err := image.AddTag(ctx, rw, o, p.ID); err != nil { + if err := txn.WithTxn(ctx, tagger.TxnManager, func(ctx context.Context) error { + return image.AddTag(ctx, rw, o, p.ID) + }); err != nil { return false, err } @@ -104,8 +109,8 @@ func TagImages(ctx context.Context, p *models.Tag, paths []string, aliases []str } // TagGalleries searches for galleries whose path matches the provided tag name and tags the gallery with the tag. -func TagGalleries(ctx context.Context, p *models.Tag, paths []string, aliases []string, rw GalleryQueryTagUpdater, cache *match.Cache) error { - t := getTagTaggers(p, aliases, cache) +func (tagger *Tagger) TagGalleries(ctx context.Context, p *models.Tag, paths []string, aliases []string, rw GalleryQueryTagUpdater) error { + t := getTagTaggers(p, aliases, tagger.Cache) for _, tt := range t { if err := tt.tagGalleries(ctx, paths, rw, func(o *models.Gallery) (bool, error) { @@ -118,7 +123,9 @@ func TagGalleries(ctx context.Context, p *models.Tag, paths []string, aliases [] return false, nil } - if err := gallery.AddTag(ctx, rw, o, p.ID); err != nil { + if err := txn.WithTxn(ctx, tagger.TxnManager, func(ctx context.Context) error { + return gallery.AddTag(ctx, rw, o, p.ID) + }); err != nil { return false, err } diff --git a/internal/autotag/tag_test.go b/internal/autotag/tag_test.go index e4fe3fa13..04f10875c 100644 --- a/internal/autotag/tag_test.go +++ b/internal/autotag/tag_test.go @@ -9,6 +9,7 @@ import ( "github.com/stashapp/stash/pkg/models/mocks" "github.com/stashapp/stash/pkg/scene" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" ) type testTagCase struct { @@ -111,7 +112,9 @@ func testTagScenes(t *testing.T, tc testTagCase) { } organized := false - perPage := models.PerPageAll + perPage := 1000 + sort := "id" + direction := models.SortDirectionEnumAsc expectedSceneFilter := &models.SceneFilterType{ Organized: &organized, @@ -122,7 +125,9 @@ func testTagScenes(t *testing.T, tc testTagCase) { } expectedFindFilter := &models.FindFilterType{ - PerPage: &perPage, + PerPage: &perPage, + Sort: &sort, + Direction: &direction, } // if alias provided, then don't find by name @@ -140,13 +145,13 @@ func testTagScenes(t *testing.T, tc testTagCase) { }, } - mockSceneReader.On("Query", testCtx, scene.QueryOptions(expectedAliasFilter, expectedFindFilter, false)). + mockSceneReader.On("Query", mock.Anything, scene.QueryOptions(expectedAliasFilter, expectedFindFilter, false)). Return(mocks.SceneQueryResult(scenes, len(scenes)), nil).Once() } for i := range matchingPaths { sceneID := i + 1 - mockSceneReader.On("UpdatePartial", testCtx, sceneID, models.ScenePartial{ + mockSceneReader.On("UpdatePartial", mock.Anything, sceneID, models.ScenePartial{ TagIDs: &models.UpdateIDs{ IDs: []int{tagID}, Mode: models.RelationshipUpdateModeAdd, @@ -154,7 +159,11 @@ func testTagScenes(t *testing.T, tc testTagCase) { }).Return(nil, nil).Once() } - err := TagScenes(testCtx, &tag, nil, aliases, mockSceneReader, nil) + tagger := Tagger{ + TxnManager: &mocks.TxnManager{}, + } + + err := tagger.TagScenes(testCtx, &tag, nil, aliases, mockSceneReader) assert := assert.New(t) @@ -204,7 +213,9 @@ func testTagImages(t *testing.T, tc testTagCase) { } organized := false - perPage := models.PerPageAll + perPage := 1000 + sort := "id" + direction := models.SortDirectionEnumAsc expectedImageFilter := &models.ImageFilterType{ Organized: &organized, @@ -215,7 +226,9 @@ func testTagImages(t *testing.T, tc testTagCase) { } expectedFindFilter := &models.FindFilterType{ - PerPage: &perPage, + PerPage: &perPage, + Sort: &sort, + Direction: &direction, } // if alias provided, then don't find by name @@ -233,14 +246,14 @@ func testTagImages(t *testing.T, tc testTagCase) { }, } - mockImageReader.On("Query", testCtx, image.QueryOptions(expectedAliasFilter, expectedFindFilter, false)). + mockImageReader.On("Query", mock.Anything, image.QueryOptions(expectedAliasFilter, expectedFindFilter, false)). Return(mocks.ImageQueryResult(images, len(images)), nil).Once() } for i := range matchingPaths { imageID := i + 1 - mockImageReader.On("UpdatePartial", testCtx, imageID, models.ImagePartial{ + mockImageReader.On("UpdatePartial", mock.Anything, imageID, models.ImagePartial{ TagIDs: &models.UpdateIDs{ IDs: []int{tagID}, Mode: models.RelationshipUpdateModeAdd, @@ -248,7 +261,11 @@ func testTagImages(t *testing.T, tc testTagCase) { }).Return(nil, nil).Once() } - err := TagImages(testCtx, &tag, nil, aliases, mockImageReader, nil) + tagger := Tagger{ + TxnManager: &mocks.TxnManager{}, + } + + err := tagger.TagImages(testCtx, &tag, nil, aliases, mockImageReader) assert := assert.New(t) @@ -299,7 +316,9 @@ func testTagGalleries(t *testing.T, tc testTagCase) { } organized := false - perPage := models.PerPageAll + perPage := 1000 + sort := "id" + direction := models.SortDirectionEnumAsc expectedGalleryFilter := &models.GalleryFilterType{ Organized: &organized, @@ -310,7 +329,9 @@ func testTagGalleries(t *testing.T, tc testTagCase) { } expectedFindFilter := &models.FindFilterType{ - PerPage: &perPage, + PerPage: &perPage, + Sort: &sort, + Direction: &direction, } // if alias provided, then don't find by name @@ -328,13 +349,13 @@ func testTagGalleries(t *testing.T, tc testTagCase) { }, } - mockGalleryReader.On("Query", testCtx, expectedAliasFilter, expectedFindFilter).Return(galleries, len(galleries), nil).Once() + mockGalleryReader.On("Query", mock.Anything, expectedAliasFilter, expectedFindFilter).Return(galleries, len(galleries), nil).Once() } for i := range matchingPaths { galleryID := i + 1 - mockGalleryReader.On("UpdatePartial", testCtx, galleryID, models.GalleryPartial{ + mockGalleryReader.On("UpdatePartial", mock.Anything, galleryID, models.GalleryPartial{ TagIDs: &models.UpdateIDs{ IDs: []int{tagID}, Mode: models.RelationshipUpdateModeAdd, @@ -343,7 +364,11 @@ func testTagGalleries(t *testing.T, tc testTagCase) { } - err := TagGalleries(testCtx, &tag, nil, aliases, mockGalleryReader, nil) + tagger := Tagger{ + TxnManager: &mocks.TxnManager{}, + } + + err := tagger.TagGalleries(testCtx, &tag, nil, aliases, mockGalleryReader) assert := assert.New(t) diff --git a/internal/autotag/tagger.go b/internal/autotag/tagger.go index 0e53200ec..1a6e3df31 100644 --- a/internal/autotag/tagger.go +++ b/internal/autotag/tagger.go @@ -23,8 +23,14 @@ import ( "github.com/stashapp/stash/pkg/match" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/scene" + "github.com/stashapp/stash/pkg/txn" ) +type Tagger struct { + TxnManager txn.Manager + Cache *match.Cache +} + type tagger struct { ID int Type string @@ -58,11 +64,11 @@ func (t *tagger) tagPerformers(ctx context.Context, performerReader match.Perfor added, err := addFunc(t.ID, p.ID) if err != nil { - return t.addError("performer", p.Name.String, err) + return t.addError("performer", p.Name, err) } if added { - t.addLog("performer", p.Name.String) + t.addLog("performer", p.Name) } } @@ -112,12 +118,7 @@ func (t *tagger) tagTags(ctx context.Context, tagReader match.TagAutoTagQueryer, } func (t *tagger) tagScenes(ctx context.Context, paths []string, sceneReader scene.Queryer, addFunc addSceneLinkFunc) error { - others, err := match.PathToScenes(ctx, t.Name, paths, sceneReader) - if err != nil { - return err - } - - for _, p := range others { + return match.PathToScenesFn(ctx, t.Name, paths, sceneReader, func(ctx context.Context, p *models.Scene) error { added, err := addFunc(p) if err != nil { @@ -127,18 +128,13 @@ func (t *tagger) tagScenes(ctx context.Context, paths []string, sceneReader scen if added { t.addLog("scene", p.DisplayName()) } - } - return nil + return nil + }) } func (t *tagger) tagImages(ctx context.Context, paths []string, imageReader image.Queryer, addFunc addImageLinkFunc) error { - others, err := match.PathToImages(ctx, t.Name, paths, imageReader) - if err != nil { - return err - } - - for _, p := range others { + return match.PathToImagesFn(ctx, t.Name, paths, imageReader, func(ctx context.Context, p *models.Image) error { added, err := addFunc(p) if err != nil { @@ -148,18 +144,13 @@ func (t *tagger) tagImages(ctx context.Context, paths []string, imageReader imag if added { t.addLog("image", p.DisplayName()) } - } - return nil + return nil + }) } func (t *tagger) tagGalleries(ctx context.Context, paths []string, galleryReader gallery.Queryer, addFunc addGalleryLinkFunc) error { - others, err := match.PathToGalleries(ctx, t.Name, paths, galleryReader) - if err != nil { - return err - } - - for _, p := range others { + return match.PathToGalleriesFn(ctx, t.Name, paths, galleryReader, func(ctx context.Context, p *models.Gallery) error { added, err := addFunc(p) if err != nil { @@ -169,7 +160,7 @@ func (t *tagger) tagGalleries(ctx context.Context, paths []string, galleryReader if added { t.addLog("gallery", p.DisplayName()) } - } - return nil + return nil + }) } diff --git a/internal/dlna/cds.go b/internal/dlna/cds.go index eedeef082..4deb017f2 100644 --- a/internal/dlna/cds.go +++ b/internal/dlna/cds.go @@ -360,7 +360,7 @@ func (me *contentDirectoryService) handleBrowseMetadata(obj object, host string) } else { var scene *models.Scene - if err := txn.WithTxn(context.TODO(), me.txnManager, func(ctx context.Context) error { + if err := txn.WithReadTxn(context.TODO(), me.txnManager, func(ctx context.Context) error { scene, err = me.repository.SceneFinder.Find(ctx, sceneID) if scene != nil { err = scene.LoadPrimaryFile(ctx, me.repository.FileFinder) @@ -443,7 +443,7 @@ func getRootObjects() []interface{} { func (me *contentDirectoryService) getVideos(sceneFilter *models.SceneFilterType, parentID string, host string) []interface{} { var objs []interface{} - if err := txn.WithTxn(context.TODO(), me.txnManager, func(ctx context.Context) error { + if err := txn.WithReadTxn(context.TODO(), me.txnManager, func(ctx context.Context) error { sort := "title" findFilter := &models.FindFilterType{ PerPage: &pageSize, @@ -486,7 +486,7 @@ func (me *contentDirectoryService) getVideos(sceneFilter *models.SceneFilterType func (me *contentDirectoryService) getPageVideos(sceneFilter *models.SceneFilterType, parentID string, page int, host string) []interface{} { var objs []interface{} - if err := txn.WithTxn(context.TODO(), me.txnManager, func(ctx context.Context) error { + if err := txn.WithReadTxn(context.TODO(), me.txnManager, func(ctx context.Context) error { pager := scenePager{ sceneFilter: sceneFilter, parentID: parentID, @@ -527,7 +527,7 @@ func (me *contentDirectoryService) getAllScenes(host string) []interface{} { func (me *contentDirectoryService) getStudios() []interface{} { var objs []interface{} - if err := txn.WithTxn(context.TODO(), me.txnManager, func(ctx context.Context) error { + if err := txn.WithReadTxn(context.TODO(), me.txnManager, func(ctx context.Context) error { studios, err := me.repository.StudioFinder.All(ctx) if err != nil { return err @@ -566,7 +566,7 @@ func (me *contentDirectoryService) getStudioScenes(paths []string, host string) func (me *contentDirectoryService) getTags() []interface{} { var objs []interface{} - if err := txn.WithTxn(context.TODO(), me.txnManager, func(ctx context.Context) error { + if err := txn.WithReadTxn(context.TODO(), me.txnManager, func(ctx context.Context) error { tags, err := me.repository.TagFinder.All(ctx) if err != nil { return err @@ -605,14 +605,14 @@ func (me *contentDirectoryService) getTagScenes(paths []string, host string) []i func (me *contentDirectoryService) getPerformers() []interface{} { var objs []interface{} - if err := txn.WithTxn(context.TODO(), me.txnManager, func(ctx context.Context) error { + if err := txn.WithReadTxn(context.TODO(), me.txnManager, func(ctx context.Context) error { performers, err := me.repository.PerformerFinder.All(ctx) if err != nil { return err } for _, s := range performers { - objs = append(objs, makeStorageFolder("performers/"+strconv.Itoa(s.ID), s.Name.String, "performers")) + objs = append(objs, makeStorageFolder("performers/"+strconv.Itoa(s.ID), s.Name, "performers")) } return nil @@ -644,7 +644,7 @@ func (me *contentDirectoryService) getPerformerScenes(paths []string, host strin func (me *contentDirectoryService) getMovies() []interface{} { var objs []interface{} - if err := txn.WithTxn(context.TODO(), me.txnManager, func(ctx context.Context) error { + if err := txn.WithReadTxn(context.TODO(), me.txnManager, func(ctx context.Context) error { movies, err := me.repository.MovieFinder.All(ctx) if err != nil { return err diff --git a/internal/dlna/dms.go b/internal/dlna/dms.go index d5e7cc84e..fdef80db1 100644 --- a/internal/dlna/dms.go +++ b/internal/dlna/dms.go @@ -439,7 +439,7 @@ func (me *Server) serveIcon(w http.ResponseWriter, r *http.Request) { } var scene *models.Scene - err := txn.WithTxn(r.Context(), me.txnManager, func(ctx context.Context) error { + err := txn.WithReadTxn(r.Context(), me.txnManager, func(ctx context.Context) error { idInt, err := strconv.Atoi(sceneId) if err != nil { return nil @@ -579,7 +579,7 @@ func (me *Server) initMux(mux *http.ServeMux) { mux.HandleFunc(resPath, func(w http.ResponseWriter, r *http.Request) { sceneId := r.URL.Query().Get("scene") var scene *models.Scene - err := txn.WithTxn(r.Context(), me.txnManager, func(ctx context.Context) error { + err := txn.WithReadTxn(r.Context(), me.txnManager, func(ctx context.Context) error { sceneIdInt, err := strconv.Atoi(sceneId) if err != nil { return nil diff --git a/internal/identify/identify.go b/internal/identify/identify.go index 98bcaa34e..c828f4164 100644 --- a/internal/identify/identify.go +++ b/internal/identify/identify.go @@ -280,6 +280,16 @@ func getScenePartial(scene *models.Scene, scraped *scraper.ScrapedScene, fieldOp partial.URL = models.NewOptionalString(*scraped.URL) } } + if scraped.Director != nil && (scene.Director != *scraped.Director) { + if shouldSetSingleValueField(fieldOptions["director"], scene.Director != "") { + partial.Director = models.NewOptionalString(*scraped.Director) + } + } + if scraped.Code != nil && (scene.Code != *scraped.Code) { + if shouldSetSingleValueField(fieldOptions["code"], scene.Code != "") { + partial.Code = models.NewOptionalString(*scraped.Code) + } + } if setOrganized && !scene.Organized { // just reuse the boolean since we know it's true diff --git a/internal/identify/performer.go b/internal/identify/performer.go index 435524cc4..d417d8bac 100644 --- a/internal/identify/performer.go +++ b/internal/identify/performer.go @@ -2,7 +2,6 @@ package identify import ( "context" - "database/sql" "fmt" "strconv" "time" @@ -12,7 +11,7 @@ import ( ) type PerformerCreator interface { - Create(ctx context.Context, newPerformer models.Performer) (*models.Performer, error) + Create(ctx context.Context, newPerformer *models.Performer) error UpdateStashIDs(ctx context.Context, performerID int, stashIDs []models.StashID) error } @@ -33,13 +32,14 @@ func getPerformerID(ctx context.Context, endpoint string, w PerformerCreator, p } func createMissingPerformer(ctx context.Context, endpoint string, w PerformerCreator, p *models.ScrapedPerformer) (*int, error) { - created, err := w.Create(ctx, scrapedToPerformerInput(p)) + performerInput := scrapedToPerformerInput(p) + err := w.Create(ctx, &performerInput) if err != nil { return nil, fmt.Errorf("error creating performer: %w", err) } if endpoint != "" && p.RemoteSiteID != nil { - if err := w.UpdateStashIDs(ctx, created.ID, []models.StashID{ + if err := w.UpdateStashIDs(ctx, performerInput.ID, []models.StashID{ { Endpoint: endpoint, StashID: *p.RemoteSiteID, @@ -49,65 +49,75 @@ func createMissingPerformer(ctx context.Context, endpoint string, w PerformerCre } } - return &created.ID, nil + return &performerInput.ID, nil } func scrapedToPerformerInput(performer *models.ScrapedPerformer) models.Performer { currentTime := time.Now() ret := models.Performer{ - Name: sql.NullString{String: *performer.Name, Valid: true}, + Name: *performer.Name, Checksum: md5.FromString(*performer.Name), - CreatedAt: models.SQLiteTimestamp{Timestamp: currentTime}, - UpdatedAt: models.SQLiteTimestamp{Timestamp: currentTime}, - Favorite: sql.NullBool{Bool: false, Valid: true}, + CreatedAt: currentTime, + UpdatedAt: currentTime, } if performer.Birthdate != nil { - ret.Birthdate = models.SQLiteDate{String: *performer.Birthdate, Valid: true} + d := models.NewDate(*performer.Birthdate) + ret.Birthdate = &d } if performer.DeathDate != nil { - ret.DeathDate = models.SQLiteDate{String: *performer.DeathDate, Valid: true} + d := models.NewDate(*performer.DeathDate) + ret.DeathDate = &d } if performer.Gender != nil { - ret.Gender = sql.NullString{String: *performer.Gender, Valid: true} + ret.Gender = models.GenderEnum(*performer.Gender) } if performer.Ethnicity != nil { - ret.Ethnicity = sql.NullString{String: *performer.Ethnicity, Valid: true} + ret.Ethnicity = *performer.Ethnicity } if performer.Country != nil { - ret.Country = sql.NullString{String: *performer.Country, Valid: true} + ret.Country = *performer.Country } if performer.EyeColor != nil { - ret.EyeColor = sql.NullString{String: *performer.EyeColor, Valid: true} + ret.EyeColor = *performer.EyeColor } if performer.HairColor != nil { - ret.HairColor = sql.NullString{String: *performer.HairColor, Valid: true} + ret.HairColor = *performer.HairColor } if performer.Height != nil { - ret.Height = sql.NullString{String: *performer.Height, Valid: true} + h, err := strconv.Atoi(*performer.Height) // height is stored as an int + if err == nil { + ret.Height = &h + } + } + if performer.Weight != nil { + h, err := strconv.Atoi(*performer.Weight) + if err == nil { + ret.Weight = &h + } } if performer.Measurements != nil { - ret.Measurements = sql.NullString{String: *performer.Measurements, Valid: true} + ret.Measurements = *performer.Measurements } if performer.FakeTits != nil { - ret.FakeTits = sql.NullString{String: *performer.FakeTits, Valid: true} + ret.FakeTits = *performer.FakeTits } if performer.CareerLength != nil { - ret.CareerLength = sql.NullString{String: *performer.CareerLength, Valid: true} + ret.CareerLength = *performer.CareerLength } if performer.Tattoos != nil { - ret.Tattoos = sql.NullString{String: *performer.Tattoos, Valid: true} + ret.Tattoos = *performer.Tattoos } if performer.Piercings != nil { - ret.Piercings = sql.NullString{String: *performer.Piercings, Valid: true} + ret.Piercings = *performer.Piercings } if performer.Aliases != nil { - ret.Aliases = sql.NullString{String: *performer.Aliases, Valid: true} + ret.Aliases = *performer.Aliases } if performer.Twitter != nil { - ret.Twitter = sql.NullString{String: *performer.Twitter, Valid: true} + ret.Twitter = *performer.Twitter } if performer.Instagram != nil { - ret.Instagram = sql.NullString{String: *performer.Instagram, Valid: true} + ret.Instagram = *performer.Instagram } return ret diff --git a/internal/identify/performer_test.go b/internal/identify/performer_test.go index eeed8a1e7..764b4ec79 100644 --- a/internal/identify/performer_test.go +++ b/internal/identify/performer_test.go @@ -1,15 +1,16 @@ package identify import ( - "database/sql" "errors" "reflect" "strconv" "testing" + "time" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models/mocks" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" ) @@ -24,9 +25,10 @@ func Test_getPerformerID(t *testing.T) { name := "name" mockPerformerReaderWriter := mocks.PerformerReaderWriter{} - mockPerformerReaderWriter.On("Create", testCtx, mock.Anything).Return(&models.Performer{ - ID: validStoredID, - }, nil) + mockPerformerReaderWriter.On("Create", testCtx, mock.Anything).Run(func(args mock.Arguments) { + p := args.Get(1).(*models.Performer) + p.ID = validStoredID + }).Return(nil) type args struct { endpoint string @@ -132,14 +134,16 @@ func Test_createMissingPerformer(t *testing.T) { performerID := 1 mockPerformerReaderWriter := mocks.PerformerReaderWriter{} - mockPerformerReaderWriter.On("Create", testCtx, mock.MatchedBy(func(p models.Performer) bool { - return p.Name.String == validName - })).Return(&models.Performer{ - ID: performerID, - }, nil) - mockPerformerReaderWriter.On("Create", testCtx, mock.MatchedBy(func(p models.Performer) bool { - return p.Name.String == invalidName - })).Return(nil, errors.New("error creating performer")) + mockPerformerReaderWriter.On("Create", testCtx, mock.MatchedBy(func(p *models.Performer) bool { + return p.Name == validName + })).Run(func(args mock.Arguments) { + p := args.Get(1).(*models.Performer) + p.ID = performerID + }).Return(nil) + + mockPerformerReaderWriter.On("Create", testCtx, mock.MatchedBy(func(p *models.Performer) bool { + return p.Name == invalidName + })).Return(errors.New("error creating performer")) mockPerformerReaderWriter.On("UpdateStashIDs", testCtx, performerID, []models.StashID{ { @@ -230,7 +234,7 @@ func Test_scrapedToPerformerInput(t *testing.T) { md5 := "b068931cc450442b63f5b3d276ea4297" var stringValues []string - for i := 0; i < 16; i++ { + for i := 0; i < 17; i++ { stringValues = append(stringValues, strconv.Itoa(i)) } @@ -241,6 +245,16 @@ func Test_scrapedToPerformerInput(t *testing.T) { return &ret } + nextIntVal := func() *int { + ret := upTo + upTo = (upTo + 1) % len(stringValues) + return &ret + } + + dateToDatePtr := func(d models.Date) *models.Date { + return &d + } + tests := []struct { name string performer *models.ScrapedPerformer @@ -258,6 +272,7 @@ func Test_scrapedToPerformerInput(t *testing.T) { EyeColor: nextVal(), HairColor: nextVal(), Height: nextVal(), + Weight: nextVal(), Measurements: nextVal(), FakeTits: nextVal(), CareerLength: nextVal(), @@ -268,34 +283,25 @@ func Test_scrapedToPerformerInput(t *testing.T) { Instagram: nextVal(), }, models.Performer{ - Name: models.NullString(name), - Checksum: md5, - Favorite: sql.NullBool{ - Bool: false, - Valid: true, - }, - Birthdate: models.SQLiteDate{ - String: *nextVal(), - Valid: true, - }, - DeathDate: models.SQLiteDate{ - String: *nextVal(), - Valid: true, - }, - Gender: models.NullString(*nextVal()), - Ethnicity: models.NullString(*nextVal()), - Country: models.NullString(*nextVal()), - EyeColor: models.NullString(*nextVal()), - HairColor: models.NullString(*nextVal()), - Height: models.NullString(*nextVal()), - Measurements: models.NullString(*nextVal()), - FakeTits: models.NullString(*nextVal()), - CareerLength: models.NullString(*nextVal()), - Tattoos: models.NullString(*nextVal()), - Piercings: models.NullString(*nextVal()), - Aliases: models.NullString(*nextVal()), - Twitter: models.NullString(*nextVal()), - Instagram: models.NullString(*nextVal()), + Name: name, + Checksum: md5, + Birthdate: dateToDatePtr(models.NewDate(*nextVal())), + DeathDate: dateToDatePtr(models.NewDate(*nextVal())), + Gender: models.GenderEnum(*nextVal()), + Ethnicity: *nextVal(), + Country: *nextVal(), + EyeColor: *nextVal(), + HairColor: *nextVal(), + Height: nextIntVal(), + Weight: nextIntVal(), + Measurements: *nextVal(), + FakeTits: *nextVal(), + CareerLength: *nextVal(), + Tattoos: *nextVal(), + Piercings: *nextVal(), + Aliases: *nextVal(), + Twitter: *nextVal(), + Instagram: *nextVal(), }, }, { @@ -304,12 +310,8 @@ func Test_scrapedToPerformerInput(t *testing.T) { Name: &name, }, models.Performer{ - Name: models.NullString(name), + Name: name, Checksum: md5, - Favorite: sql.NullBool{ - Bool: false, - Valid: true, - }, }, }, } @@ -318,12 +320,10 @@ func Test_scrapedToPerformerInput(t *testing.T) { got := scrapedToPerformerInput(tt.performer) // clear created/updated dates - got.CreatedAt = models.SQLiteTimestamp{} + got.CreatedAt = time.Time{} got.UpdatedAt = got.CreatedAt - if !reflect.DeepEqual(got, tt.want) { - t.Errorf("scrapedToPerformerInput() = %v, want %v", got, tt.want) - } + assert.Equal(t, tt.want, got) }) } } diff --git a/internal/manager/config/config.go b/internal/manager/config/config.go index 6e453bed5..8c47b1989 100644 --- a/internal/manager/config/config.go +++ b/internal/manager/config/config.go @@ -139,6 +139,7 @@ const ( ContinuePlaylistDefault = "continue_playlist_default" ShowStudioAsText = "show_studio_as_text" CSSEnabled = "cssEnabled" + JavascriptEnabled = "javascriptEnabled" CustomLocalesEnabled = "customLocalesEnabled" ShowScrubber = "show_scrubber" @@ -465,7 +466,15 @@ func (i *Instance) getStringMapString(key string) map[string]string { i.RLock() defer i.RUnlock() - return i.viper(key).GetStringMapString(key) + ret := i.viper(key).GetStringMapString(key) + + // GetStringMapString returns an empty map regardless of whether the + // key exists or not. + if len(ret) == 0 { + return nil + } + + return ret } type StashConfig struct { @@ -1077,6 +1086,49 @@ func (i *Instance) GetCSSEnabled() bool { return i.getBool(CSSEnabled) } +func (i *Instance) GetJavascriptPath() string { + // use custom.js in the same directory as the config file + configFileUsed := i.GetConfigFile() + configDir := filepath.Dir(configFileUsed) + + fn := filepath.Join(configDir, "custom.js") + + return fn +} + +func (i *Instance) GetJavascript() string { + fn := i.GetJavascriptPath() + + exists, _ := fsutil.FileExists(fn) + if !exists { + return "" + } + + buf, err := os.ReadFile(fn) + + if err != nil { + return "" + } + + return string(buf) +} + +func (i *Instance) SetJavascript(javascript string) { + fn := i.GetJavascriptPath() + i.Lock() + defer i.Unlock() + + buf := []byte(javascript) + + if err := os.WriteFile(fn, buf, 0777); err != nil { + logger.Warnf("error while writing %v bytes to %v: %v", len(buf), fn, err) + } +} + +func (i *Instance) GetJavascriptEnabled() bool { + return i.getBool(JavascriptEnabled) +} + func (i *Instance) GetCustomLocalesPath() string { // use custom-locales.json in the same directory as the config file configFileUsed := i.GetConfigFile() diff --git a/internal/manager/config/config_concurrency_test.go b/internal/manager/config/config_concurrency_test.go index 7b60bfb4c..81bb7e816 100644 --- a/internal/manager/config/config_concurrency_test.go +++ b/internal/manager/config/config_concurrency_test.go @@ -86,6 +86,8 @@ func TestConcurrentConfigAccess(t *testing.T) { i.Set(ImageLightboxSlideshowDelay, *i.GetImageLightboxOptions().SlideshowDelay) i.GetCSSPath() i.GetCSS() + i.GetJavascriptPath() + i.GetJavascript() i.GetCustomLocalesPath() i.GetCustomLocales() i.Set(CSSEnabled, i.GetCSSEnabled()) diff --git a/internal/manager/config/map.go b/internal/manager/config/map.go index 3394d7040..b13cf73ac 100644 --- a/internal/manager/config/map.go +++ b/internal/manager/config/map.go @@ -32,15 +32,19 @@ func toSnakeCase(v string) string { func fromSnakeCase(v string) string { var buf bytes.Buffer + leadingUnderscore := true capvar := false for i, c := range v { switch { - case c == '_' && i > 0: + case c == '_' && !leadingUnderscore && i > 0: capvar = true + case c == '_' && leadingUnderscore: + buf.WriteRune(c) case capvar: buf.WriteRune(unicode.ToUpper(c)) capvar = false default: + leadingUnderscore = false buf.WriteRune(c) } } @@ -54,7 +58,13 @@ func toSnakeCaseMap(m map[string]interface{}) map[string]interface{} { for key, val := range m { adjKey := toSnakeCase(key) - nm[adjKey] = val + + switch v := val.(type) { + case map[string]interface{}: + nm[adjKey] = toSnakeCaseMap(v) + default: + nm[adjKey] = val + } } return nm @@ -68,13 +78,15 @@ func convertMapValue(val interface{}) interface{} { case map[interface{}]interface{}: ret := cast.ToStringMap(v) for k, vv := range ret { - ret[k] = convertMapValue(vv) + adjKey := fromSnakeCase(k) + ret[adjKey] = convertMapValue(vv) } return ret case map[string]interface{}: ret := make(map[string]interface{}) for k, vv := range v { - ret[k] = convertMapValue(vv) + adjKey := fromSnakeCase(k) + ret[adjKey] = convertMapValue(vv) } return ret case []interface{}: diff --git a/internal/manager/filename_parser.go b/internal/manager/filename_parser.go index f02f95c73..9ee876a8c 100644 --- a/internal/manager/filename_parser.go +++ b/internal/manager/filename_parser.go @@ -26,10 +26,13 @@ type SceneParserInput struct { type SceneParserResult struct { Scene *models.Scene `json:"scene"` Title *string `json:"title"` + Code *string `json:"code"` Details *string `json:"details"` + Director *string `json:"director"` URL *string `json:"url"` Date *string `json:"date"` Rating *int `json:"rating"` + Rating100 *int `json:"rating100"` StudioID *string `json:"studio_id"` GalleryIds []string `json:"gallery_ids"` PerformerIds []string `json:"performer_ids"` @@ -111,6 +114,7 @@ func initParserFields() { ret["d"] = newParserField("d", `(?:\.|-|_)`, false) ret["rating"] = newParserField("rating", `\d`, true) + ret["rating100"] = newParserField("rating100", `\d`, true) ret["performer"] = newParserField("performer", ".*", true) ret["studio"] = newParserField("studio", ".*", true) ret["movie"] = newParserField("movie", ".*", true) @@ -254,6 +258,10 @@ func validateRating(rating int) bool { return rating >= 1 && rating <= 5 } +func validateRating100(rating100 int) bool { + return rating100 >= 1 && rating100 <= 100 +} + func validateDate(dateStr string) bool { splits := strings.Split(dateStr, "-") if len(splits) != 3 { @@ -345,6 +353,13 @@ func (h *sceneHolder) setField(field parserField, value interface{}) { case "rating": rating, _ := strconv.Atoi(value.(string)) if validateRating(rating) { + // convert to 1-100 scale + rating = models.Rating5To100(rating) + h.result.Rating = &rating + } + case "rating100": + rating, _ := strconv.Atoi(value.(string)) + if validateRating100(rating) { h.result.Rating = &rating } case "performer": diff --git a/internal/manager/generator.go b/internal/manager/generator.go index 0b14941e2..d2ca95016 100644 --- a/internal/manager/generator.go +++ b/internal/manager/generator.go @@ -43,8 +43,8 @@ func (g *generatorInfo) calculateFrameRate(videoStream *ffmpeg.FFProbeStream) er numberOfFrames, _ := strconv.Atoi(videoStream.NbFrames) - if numberOfFrames == 0 && isValidFloat64(framerate) && g.VideoFile.Duration > 0 { // TODO: test - numberOfFrames = int(framerate * g.VideoFile.Duration) + if numberOfFrames == 0 && isValidFloat64(framerate) && g.VideoFile.VideoStreamDuration > 0 { // TODO: test + numberOfFrames = int(framerate * g.VideoFile.VideoStreamDuration) } // If we are missing the frame count or frame rate then seek through the file and extract the info with regex @@ -68,7 +68,7 @@ func (g *generatorInfo) calculateFrameRate(videoStream *ffmpeg.FFProbeStream) er "number of frames or framerate is 0. nb_frames <%s> framerate <%f> duration <%f>", videoStream.NbFrames, framerate, - g.VideoFile.Duration, + g.VideoFile.VideoStreamDuration, ) } diff --git a/internal/manager/generator_interactive_heatmap_speed.go b/internal/manager/generator_interactive_heatmap_speed.go index 9155f7b1f..c9b295983 100644 --- a/internal/manager/generator_interactive_heatmap_speed.go +++ b/internal/manager/generator_interactive_heatmap_speed.go @@ -11,16 +11,18 @@ import ( "sort" "github.com/lucasb-eyer/go-colorful" + "github.com/stashapp/stash/pkg/logger" ) type InteractiveHeatmapSpeedGenerator struct { - InteractiveSpeed int - Funscript Script - FunscriptPath string - HeatmapPath string - Width int - Height int - NumSegments int + sceneDurationMilli int64 + InteractiveSpeed int + Funscript Script + FunscriptPath string + HeatmapPath string + Width int + Height int + NumSegments int } type Script struct { @@ -52,13 +54,14 @@ type GradientTable []struct { Pos float64 } -func NewInteractiveHeatmapSpeedGenerator(funscriptPath string, heatmapPath string) *InteractiveHeatmapSpeedGenerator { +func NewInteractiveHeatmapSpeedGenerator(funscriptPath string, heatmapPath string, sceneDuration float64) *InteractiveHeatmapSpeedGenerator { return &InteractiveHeatmapSpeedGenerator{ - FunscriptPath: funscriptPath, - HeatmapPath: heatmapPath, - Width: 320, - Height: 15, - NumSegments: 150, + sceneDurationMilli: int64(sceneDuration * 1000), + FunscriptPath: funscriptPath, + HeatmapPath: heatmapPath, + Width: 320, + Height: 15, + NumSegments: 150, } } @@ -69,6 +72,10 @@ func (g *InteractiveHeatmapSpeedGenerator) Generate() error { return err } + if len(funscript.Actions) == 0 { + return fmt.Errorf("no valid actions in funscript") + } + g.Funscript = funscript g.Funscript.UpdateIntensityAndSpeed() @@ -102,14 +109,20 @@ func (g *InteractiveHeatmapSpeedGenerator) LoadFunscriptData(path string) (Scrip sort.SliceStable(funscript.Actions, func(i, j int) bool { return funscript.Actions[i].At < funscript.Actions[j].At }) // trim actions with negative timestamps to avoid index range errors when generating heatmap - - isValid := func(x int64) bool { return x >= 0 } + // #3181 - also trim actions that occur after the scene duration + loggedBadTimestamp := false + isValid := func(x int64) bool { + return x >= 0 && x < g.sceneDurationMilli + } i := 0 for _, x := range funscript.Actions { if isValid(x.At) { funscript.Actions[i] = x i++ + } else if !loggedBadTimestamp { + loggedBadTimestamp = true + logger.Warnf("Invalid timestamp %d in %s: subsequent invalid timestamps will not be logged", x.At, path) } } @@ -215,6 +228,10 @@ func (funscript Script) getGradientTable(numSegments int) GradientTable { for _, a := range funscript.Actions { segment := int(float64(a.At) / float64(maxts+1) * float64(numSegments)) + // #3181 - sanity check. Clamp segment to numSegments-1 + if segment >= numSegments { + segment = numSegments - 1 + } segments[segment].count++ segments[segment].intensity += int(a.Intensity) } diff --git a/internal/manager/generator_sprite.go b/internal/manager/generator_sprite.go index 72138b387..47110462d 100644 --- a/internal/manager/generator_sprite.go +++ b/internal/manager/generator_sprite.go @@ -39,12 +39,12 @@ func NewSpriteGenerator(videoFile ffmpeg.VideoFile, videoChecksum string, imageO chunkCount := rows * cols // For files with small duration / low frame count try to seek using frame number intead of seconds - if videoFile.Duration < 5 || (0 < videoFile.FrameCount && videoFile.FrameCount <= int64(chunkCount)) { // some files can have FrameCount == 0, only use SlowSeek if duration < 5 - if videoFile.Duration <= 0 { - s := fmt.Sprintf("video %s: duration(%.3f)/frame count(%d) invalid, skipping sprite creation", videoFile.Path, videoFile.Duration, videoFile.FrameCount) + if videoFile.VideoStreamDuration < 5 || (0 < videoFile.FrameCount && videoFile.FrameCount <= int64(chunkCount)) { // some files can have FrameCount == 0, only use SlowSeek if duration < 5 + if videoFile.VideoStreamDuration <= 0 { + s := fmt.Sprintf("video %s: duration(%.3f)/frame count(%d) invalid, skipping sprite creation", videoFile.Path, videoFile.VideoStreamDuration, videoFile.FrameCount) return nil, errors.New(s) } - logger.Warnf("[generator] video %s too short (%.3fs, %d frames), using frame seeking", videoFile.Path, videoFile.Duration, videoFile.FrameCount) + logger.Warnf("[generator] video %s too short (%.3fs, %d frames), using frame seeking", videoFile.Path, videoFile.VideoStreamDuration, videoFile.FrameCount) slowSeek = true // do an actual frame count of the file ( number of frames = read frames) ffprobe := GetInstance().FFProbe @@ -102,7 +102,7 @@ func (g *SpriteGenerator) generateSpriteImage() error { if !g.SlowSeek { logger.Infof("[generator] generating sprite image for %s", g.Info.VideoFile.Path) // generate `ChunkCount` thumbnails - stepSize := g.Info.VideoFile.Duration / float64(g.Info.ChunkCount) + stepSize := g.Info.VideoFile.VideoStreamDuration / float64(g.Info.ChunkCount) for i := 0; i < g.Info.ChunkCount; i++ { time := float64(i) * stepSize diff --git a/internal/manager/import.go b/internal/manager/import.go index 0762096c2..f9fb57c8f 100644 --- a/internal/manager/import.go +++ b/internal/manager/import.go @@ -79,7 +79,7 @@ func performImport(ctx context.Context, i importer, duplicateBehaviour ImportDup if duplicateBehaviour == ImportDuplicateEnumFail { return fmt.Errorf("existing object with name '%s'", name) } else if duplicateBehaviour == ImportDuplicateEnumIgnore { - logger.Info("Skipping existing object") + logger.Infof("Skipping existing object %q", name) return nil } diff --git a/internal/manager/manager.go b/internal/manager/manager.go index 206b80ed2..9f2492ea7 100644 --- a/internal/manager/manager.go +++ b/internal/manager/manager.go @@ -169,6 +169,9 @@ func initialize() error { db := sqlite.NewDatabase() + // start with empty paths + emptyPaths := paths.Paths{} + instance = &Manager{ Config: cfg, Logger: l, @@ -178,14 +181,18 @@ func initialize() error { Database: db, Repository: sqliteRepository(db), + Paths: &emptyPaths, scanSubs: &subscriptionManager{}, } instance.SceneService = &scene.Service{ - File: db.File, - Repository: db.Scene, - MarkerDestroyer: instance.Repository.SceneMarker, + File: db.File, + Repository: db.Scene, + MarkerRepository: instance.Repository.SceneMarker, + PluginCache: instance.PluginCache, + Paths: instance.Paths, + Config: cfg, } instance.ImageService = &image.Service{ @@ -444,7 +451,7 @@ func (s *Manager) PostInit(ctx context.Context) error { logger.Warnf("could not set initial configuration: %v", err) } - s.Paths = paths.NewPaths(s.Config.GetGeneratedPath()) + *s.Paths = paths.NewPaths(s.Config.GetGeneratedPath()) s.RefreshConfig() s.SessionStore = session.NewStore(s.Config) s.PluginCache.RegisterSessionStore(s.SessionStore) @@ -518,7 +525,7 @@ func (s *Manager) initScraperCache() *scraper.Cache { } func (s *Manager) RefreshConfig() { - s.Paths = paths.NewPaths(s.Config.GetGeneratedPath()) + *s.Paths = paths.NewPaths(s.Config.GetGeneratedPath()) config := s.Config if config.Validate() == nil { if err := fsutil.EnsureDir(s.Paths.Generated.Screenshots); err != nil { diff --git a/internal/manager/repository.go b/internal/manager/repository.go index 3d64ee73e..713e017b4 100644 --- a/internal/manager/repository.go +++ b/internal/manager/repository.go @@ -67,6 +67,10 @@ func (r *Repository) WithTxn(ctx context.Context, fn txn.TxnFunc) error { return txn.WithTxn(ctx, r, fn) } +func (r *Repository) WithReadTxn(ctx context.Context, fn txn.TxnFunc) error { + return txn.WithReadTxn(ctx, r, fn) +} + func (r *Repository) WithDB(ctx context.Context, fn txn.TxnFunc) error { return txn.WithDatabase(ctx, r, fn) } @@ -92,6 +96,9 @@ func sqliteRepository(d *sqlite.Database) Repository { } type SceneService interface { + Create(ctx context.Context, input *models.Scene, fileIDs []file.ID, coverImage []byte) (*models.Scene, error) + AssignFile(ctx context.Context, sceneID int, fileID file.ID) error + Merge(ctx context.Context, sourceIDs []int, destinationID int, values models.ScenePartial) error Destroy(ctx context.Context, scene *models.Scene, fileDeleter *scene.FileDeleter, deleteGenerated, deleteFile bool) error } diff --git a/internal/manager/running_streams.go b/internal/manager/running_streams.go index 5e329e6fb..b715c0c5c 100644 --- a/internal/manager/running_streams.go +++ b/internal/manager/running_streams.go @@ -5,6 +5,7 @@ import ( "errors" "io" "net/http" + "time" "github.com/stashapp/stash/internal/manager/config" "github.com/stashapp/stash/internal/static" @@ -46,7 +47,21 @@ func (c *StreamRequestContext) Cancel() { if err != nil { logger.Warnf("unable to write end of stream: %v", err) } - _ = bw.Flush() + + // flush the buffer, but don't wait indefinitely + timeout := make(chan struct{}, 1) + go func() { + _ = bw.Flush() + close(timeout) + }() + + const waitTime = time.Second + + select { + case <-timeout: + case <-time.After(waitTime): + logger.Warnf("unable to flush buffer - closing connection") + } } conn.Close() @@ -91,17 +106,19 @@ func (s *SceneServer) StreamSceneDirect(scene *models.Scene, w http.ResponseWrit func (s *SceneServer) ServeScreenshot(scene *models.Scene, w http.ResponseWriter, r *http.Request) { const defaultSceneImage = "scene/scene.svg" - filepath := GetInstance().Paths.Scene.GetScreenshotPath(scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm())) + if scene.Path != "" { + filepath := GetInstance().Paths.Scene.GetScreenshotPath(scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm())) - // fall back to the scene image blob if the file isn't present - screenshotExists, _ := fsutil.FileExists(filepath) - if screenshotExists { - http.ServeFile(w, r, filepath) - return + // fall back to the scene image blob if the file isn't present + screenshotExists, _ := fsutil.FileExists(filepath) + if screenshotExists { + http.ServeFile(w, r, filepath) + return + } } var cover []byte - readTxnErr := txn.WithTxn(r.Context(), s.TxnManager, func(ctx context.Context) error { + readTxnErr := txn.WithReadTxn(r.Context(), s.TxnManager, func(ctx context.Context) error { cover, _ = s.SceneCoverGetter.GetCover(ctx, scene.ID) return nil }) diff --git a/internal/manager/scene.go b/internal/manager/scene.go index 5539ad231..30d1948d8 100644 --- a/internal/manager/scene.go +++ b/internal/manager/scene.go @@ -79,7 +79,7 @@ func GetSceneStreamPaths(scene *models.Scene, directStreamURL *url.URL, maxStrea pf := scene.Files.Primary() if pf == nil { - return nil, fmt.Errorf("nil file") + return nil, nil } var ret []*SceneStreamEndpoint diff --git a/internal/manager/task_autotag.go b/internal/manager/task_autotag.go index 09086117a..16df5d240 100644 --- a/internal/manager/task_autotag.go +++ b/internal/manager/task_autotag.go @@ -73,7 +73,7 @@ func (j *autoTagJob) autoTagSpecific(ctx context.Context, progress *job.Progress studioCount := len(studioIds) tagCount := len(tagIds) - if err := j.txnManager.WithTxn(ctx, func(ctx context.Context) error { + if err := j.txnManager.WithReadTxn(ctx, func(ctx context.Context) error { r := j.txnManager performerQuery := r.Performer studioQuery := r.Studio @@ -121,6 +121,11 @@ func (j *autoTagJob) autoTagPerformers(ctx context.Context, progress *job.Progre return } + tagger := autotag.Tagger{ + TxnManager: j.txnManager, + Cache: &j.cache, + } + for _, performerId := range performerIds { var performers []*models.Performer @@ -162,21 +167,21 @@ func (j *autoTagJob) autoTagPerformers(ctx context.Context, progress *job.Progre return nil } - if err := j.txnManager.WithTxn(ctx, func(ctx context.Context) error { + if err := func() error { r := j.txnManager - if err := autotag.PerformerScenes(ctx, performer, paths, r.Scene, &j.cache); err != nil { + if err := tagger.PerformerScenes(ctx, performer, paths, r.Scene); err != nil { return fmt.Errorf("processing scenes: %w", err) } - if err := autotag.PerformerImages(ctx, performer, paths, r.Image, &j.cache); err != nil { + if err := tagger.PerformerImages(ctx, performer, paths, r.Image); err != nil { return fmt.Errorf("processing images: %w", err) } - if err := autotag.PerformerGalleries(ctx, performer, paths, r.Gallery, &j.cache); err != nil { + if err := tagger.PerformerGalleries(ctx, performer, paths, r.Gallery); err != nil { return fmt.Errorf("processing galleries: %w", err) } return nil - }); err != nil { - return fmt.Errorf("error auto-tagging performer '%s': %s", performer.Name.String, err.Error()) + }(); err != nil { + return fmt.Errorf("error auto-tagging performer '%s': %s", performer.Name, err.Error()) } progress.Increment() @@ -196,6 +201,10 @@ func (j *autoTagJob) autoTagStudios(ctx context.Context, progress *job.Progress, } r := j.txnManager + tagger := autotag.Tagger{ + TxnManager: j.txnManager, + Cache: &j.cache, + } for _, studioId := range studioIds { var studios []*models.Studio @@ -238,24 +247,24 @@ func (j *autoTagJob) autoTagStudios(ctx context.Context, progress *job.Progress, return nil } - if err := j.txnManager.WithTxn(ctx, func(ctx context.Context) error { + if err := func() error { aliases, err := r.Studio.GetAliases(ctx, studio.ID) if err != nil { return fmt.Errorf("getting studio aliases: %w", err) } - if err := autotag.StudioScenes(ctx, studio, paths, aliases, r.Scene, &j.cache); err != nil { + if err := tagger.StudioScenes(ctx, studio, paths, aliases, r.Scene); err != nil { return fmt.Errorf("processing scenes: %w", err) } - if err := autotag.StudioImages(ctx, studio, paths, aliases, r.Image, &j.cache); err != nil { + if err := tagger.StudioImages(ctx, studio, paths, aliases, r.Image); err != nil { return fmt.Errorf("processing images: %w", err) } - if err := autotag.StudioGalleries(ctx, studio, paths, aliases, r.Gallery, &j.cache); err != nil { + if err := tagger.StudioGalleries(ctx, studio, paths, aliases, r.Gallery); err != nil { return fmt.Errorf("processing galleries: %w", err) } return nil - }); err != nil { + }(); err != nil { return fmt.Errorf("error auto-tagging studio '%s': %s", studio.Name.String, err.Error()) } @@ -276,6 +285,10 @@ func (j *autoTagJob) autoTagTags(ctx context.Context, progress *job.Progress, pa } r := j.txnManager + tagger := autotag.Tagger{ + TxnManager: j.txnManager, + Cache: &j.cache, + } for _, tagId := range tagIds { var tags []*models.Tag @@ -312,24 +325,24 @@ func (j *autoTagJob) autoTagTags(ctx context.Context, progress *job.Progress, pa return nil } - if err := j.txnManager.WithTxn(ctx, func(ctx context.Context) error { + if err := func() error { aliases, err := r.Tag.GetAliases(ctx, tag.ID) if err != nil { return fmt.Errorf("getting tag aliases: %w", err) } - if err := autotag.TagScenes(ctx, tag, paths, aliases, r.Scene, &j.cache); err != nil { + if err := tagger.TagScenes(ctx, tag, paths, aliases, r.Scene); err != nil { return fmt.Errorf("processing scenes: %w", err) } - if err := autotag.TagImages(ctx, tag, paths, aliases, r.Image, &j.cache); err != nil { + if err := tagger.TagImages(ctx, tag, paths, aliases, r.Image); err != nil { return fmt.Errorf("processing images: %w", err) } - if err := autotag.TagGalleries(ctx, tag, paths, aliases, r.Gallery, &j.cache); err != nil { + if err := tagger.TagGalleries(ctx, tag, paths, aliases, r.Gallery); err != nil { return fmt.Errorf("processing galleries: %w", err) } return nil - }); err != nil { + }(); err != nil { return fmt.Errorf("error auto-tagging tag '%s': %s", tag.Name, err.Error()) } @@ -484,7 +497,7 @@ func (t *autoTagFilesTask) processScenes(ctx context.Context, r Repository) erro more := true for more { var scenes []*models.Scene - if err := t.txnManager.WithTxn(ctx, func(ctx context.Context) error { + if err := t.txnManager.WithReadTxn(ctx, func(ctx context.Context) error { var err error scenes, err = scene.Query(ctx, r.Scene, sceneFilter, findFilter) return err @@ -541,7 +554,7 @@ func (t *autoTagFilesTask) processImages(ctx context.Context, r Repository) erro more := true for more { var images []*models.Image - if err := t.txnManager.WithTxn(ctx, func(ctx context.Context) error { + if err := t.txnManager.WithReadTxn(ctx, func(ctx context.Context) error { var err error images, err = image.Query(ctx, r.Image, imageFilter, findFilter) return err @@ -598,7 +611,7 @@ func (t *autoTagFilesTask) processGalleries(ctx context.Context, r Repository) e more := true for more { var galleries []*models.Gallery - if err := t.txnManager.WithTxn(ctx, func(ctx context.Context) error { + if err := t.txnManager.WithReadTxn(ctx, func(ctx context.Context) error { var err error galleries, _, err = r.Gallery.Query(ctx, galleryFilter, findFilter) return err @@ -644,7 +657,7 @@ func (t *autoTagFilesTask) processGalleries(ctx context.Context, r Repository) e func (t *autoTagFilesTask) process(ctx context.Context) { r := t.txnManager - if err := r.WithTxn(ctx, func(ctx context.Context) error { + if err := r.WithReadTxn(ctx, func(ctx context.Context) error { total, err := t.getCount(ctx, t.txnManager) if err != nil { return err @@ -697,6 +710,11 @@ func (t *autoTagSceneTask) Start(ctx context.Context, wg *sync.WaitGroup) { defer wg.Done() r := t.txnManager if err := t.txnManager.WithTxn(ctx, func(ctx context.Context) error { + if t.scene.Path == "" { + // nothing to do + return nil + } + if t.performers { if err := autotag.ScenePerformers(ctx, t.scene, r.Scene, r.Performer, t.cache); err != nil { return fmt.Errorf("error tagging scene performers for %s: %v", t.scene.DisplayName(), err) diff --git a/internal/manager/task_export.go b/internal/manager/task_export.go index d0f22ee0b..d75ad2eed 100644 --- a/internal/manager/task_export.go +++ b/internal/manager/task_export.go @@ -261,7 +261,10 @@ func (t *ExportTask) zipWalkFunc(outDir string, z *zip.Writer) filepath.WalkFunc func (t *ExportTask) zipFile(fn, outDir string, z *zip.Writer) error { bn := filepath.Base(fn) - f, err := z.Create(filepath.Join(outDir, bn)) + p := filepath.Join(outDir, bn) + p = filepath.ToSlash(p) + + f, err := z.Create(p) if err != nil { return fmt.Errorf("error creating zip entry for %s: %s", fn, err.Error()) } @@ -527,6 +530,10 @@ func exportScene(ctx context.Context, wg *sync.WaitGroup, jobChan <-chan *models newSceneJSON.Galleries = gallery.GetRefs(galleries) + newSceneJSON.ResumeTime = s.ResumeTime + newSceneJSON.PlayCount = s.PlayCount + newSceneJSON.PlayDuration = s.PlayDuration + performers, err := performerReader.FindBySceneID(ctx, s.ID) if err != nil { logger.Errorf("[scenes] <%s> error getting scene performer names: %s", sceneHash, err.Error()) @@ -580,7 +587,7 @@ func exportScene(ctx context.Context, wg *sync.WaitGroup, jobChan <-chan *models basename := filepath.Base(s.Path) hash := s.OSHash - fn := newSceneJSON.Filename(basename, hash) + fn := newSceneJSON.Filename(s.ID, basename, hash) if err := t.json.saveScene(fn, newSceneJSON); err != nil { logger.Errorf("[scenes] <%s> failed to save json: %s", sceneHash, err.Error()) diff --git a/internal/manager/task_generate.go b/internal/manager/task_generate.go index e75f51960..088a9ea3c 100644 --- a/internal/manager/task_generate.go +++ b/internal/manager/task_generate.go @@ -109,7 +109,7 @@ func (j *GenerateJob) Execute(ctx context.Context, progress *job.Progress) { Overwrite: j.overwrite, } - if err := j.txnManager.WithTxn(ctx, func(ctx context.Context) error { + if err := j.txnManager.WithReadTxn(ctx, func(ctx context.Context) error { qb := j.txnManager.Scene if len(j.input.SceneIDs) == 0 && len(j.input.MarkerIDs) == 0 { totals = j.queueTasks(ctx, g, queue) @@ -137,7 +137,7 @@ func (j *GenerateJob) Execute(ctx context.Context, progress *job.Progress) { } return nil - }); err != nil { + }); err != nil && ctx.Err() == nil { logger.Error(err.Error()) return } @@ -295,21 +295,23 @@ func (j *GenerateJob) queueSceneJobs(ctx context.Context, g *generate.Generator, generator: g, } - sceneHash := scene.GetHash(task.fileNamingAlgorithm) - addTask := false - if j.overwrite || !task.doesVideoPreviewExist(sceneHash) { - totals.previews++ - addTask = true - } + if task.required() { + sceneHash := scene.GetHash(task.fileNamingAlgorithm) + addTask := false + if j.overwrite || !task.doesVideoPreviewExist(sceneHash) { + totals.previews++ + addTask = true + } - if utils.IsTrue(j.input.ImagePreviews) && (j.overwrite || !task.doesImagePreviewExist(sceneHash)) { - totals.imagePreviews++ - addTask = true - } + if utils.IsTrue(j.input.ImagePreviews) && (j.overwrite || !task.doesImagePreviewExist(sceneHash)) { + totals.imagePreviews++ + addTask = true + } - if addTask { - totals.tasks++ - queue <- task + if addTask { + totals.tasks++ + queue <- task + } } } diff --git a/internal/manager/task_generate_interactive_heatmap_speed.go b/internal/manager/task_generate_interactive_heatmap_speed.go index 27c780764..414584a77 100644 --- a/internal/manager/task_generate_interactive_heatmap_speed.go +++ b/internal/manager/task_generate_interactive_heatmap_speed.go @@ -30,7 +30,7 @@ func (t *GenerateInteractiveHeatmapSpeedTask) Start(ctx context.Context) { funscriptPath := video.GetFunscriptPath(t.Scene.Path) heatmapPath := instance.Paths.Scene.GetInteractiveHeatmapPath(videoChecksum) - generator := NewInteractiveHeatmapSpeedGenerator(funscriptPath, heatmapPath) + generator := NewInteractiveHeatmapSpeedGenerator(funscriptPath, heatmapPath, t.Scene.Files.Primary().Duration) err := generator.Generate() @@ -46,10 +46,9 @@ func (t *GenerateInteractiveHeatmapSpeedTask) Start(ctx context.Context) { primaryFile.InteractiveSpeed = &median qb := t.TxnManager.File return qb.Update(ctx, primaryFile) - }); err != nil { + }); err != nil && ctx.Err() == nil { logger.Error(err.Error()) } - } func (t *GenerateInteractiveHeatmapSpeedTask) shouldGenerate() bool { @@ -58,7 +57,7 @@ func (t *GenerateInteractiveHeatmapSpeedTask) shouldGenerate() bool { return false } sceneHash := t.Scene.GetHash(t.fileNamingAlgorithm) - return !t.doesHeatmapExist(sceneHash) || t.Overwrite + return !t.doesHeatmapExist(sceneHash) || primaryFile.InteractiveSpeed == nil || t.Overwrite } func (t *GenerateInteractiveHeatmapSpeedTask) doesHeatmapExist(sceneChecksum string) bool { diff --git a/internal/manager/task_generate_markers.go b/internal/manager/task_generate_markers.go index aca8dcb2c..32bd2d5ef 100644 --- a/internal/manager/task_generate_markers.go +++ b/internal/manager/task_generate_markers.go @@ -5,7 +5,7 @@ import ( "fmt" "path/filepath" - "github.com/stashapp/stash/pkg/ffmpeg" + "github.com/stashapp/stash/pkg/file" "github.com/stashapp/stash/pkg/fsutil" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" @@ -42,9 +42,13 @@ func (t *GenerateMarkersTask) Start(ctx context.Context) { if t.Marker != nil { var scene *models.Scene - if err := t.TxnManager.WithTxn(ctx, func(ctx context.Context) error { + if err := t.TxnManager.WithReadTxn(ctx, func(ctx context.Context) error { var err error scene, err = t.TxnManager.Scene.Find(ctx, int(t.Marker.SceneID.Int64)) + if err == nil && scene != nil { + err = scene.LoadPrimaryFile(ctx, t.TxnManager.File) + } + return err }); err != nil { logger.Errorf("error finding scene for marker: %s", err.Error()) @@ -56,10 +60,10 @@ func (t *GenerateMarkersTask) Start(ctx context.Context) { return } - ffprobe := instance.FFProbe - videoFile, err := ffprobe.NewVideoFile(t.Scene.Path) - if err != nil { - logger.Errorf("error reading video file: %s", err.Error()) + videoFile := scene.Files.Primary() + + if videoFile == nil { + // nothing to do return } @@ -69,7 +73,7 @@ func (t *GenerateMarkersTask) Start(ctx context.Context) { func (t *GenerateMarkersTask) generateSceneMarkers(ctx context.Context) { var sceneMarkers []*models.SceneMarker - if err := t.TxnManager.WithTxn(ctx, func(ctx context.Context) error { + if err := t.TxnManager.WithReadTxn(ctx, func(ctx context.Context) error { var err error sceneMarkers, err = t.TxnManager.SceneMarker.FindBySceneID(ctx, t.Scene.ID) return err @@ -78,14 +82,9 @@ func (t *GenerateMarkersTask) generateSceneMarkers(ctx context.Context) { return } - if len(sceneMarkers) == 0 { - return - } + videoFile := t.Scene.Files.Primary() - ffprobe := instance.FFProbe - videoFile, err := ffprobe.NewVideoFile(t.Scene.Path) - if err != nil { - logger.Errorf("error reading video file: %s", err.Error()) + if len(sceneMarkers) == 0 || videoFile == nil { return } @@ -105,7 +104,7 @@ func (t *GenerateMarkersTask) generateSceneMarkers(ctx context.Context) { } } -func (t *GenerateMarkersTask) generateMarker(videoFile *ffmpeg.VideoFile, scene *models.Scene, sceneMarker *models.SceneMarker) { +func (t *GenerateMarkersTask) generateMarker(videoFile *file.VideoFile, scene *models.Scene, sceneMarker *models.SceneMarker) { sceneHash := t.Scene.GetHash(t.fileNamingAlgorithm) seconds := int(sceneMarker.Seconds) @@ -139,7 +138,7 @@ func (t *GenerateMarkersTask) markersNeeded(ctx context.Context) int { return 0 } - if len(sceneMarkers) == 0 { + if len(sceneMarkers) == 0 || t.Scene.Files.Primary() == nil { return 0 } diff --git a/internal/manager/task_generate_phash.go b/internal/manager/task_generate_phash.go index a986c96f1..6ba840694 100644 --- a/internal/manager/task_generate_phash.go +++ b/internal/manager/task_generate_phash.go @@ -44,8 +44,8 @@ func (t *GeneratePhashTask) Start(ctx context.Context) { }) return qb.Update(ctx, t.File) - }); err != nil { - logger.Error(err.Error()) + }); err != nil && ctx.Err() == nil { + logger.Errorf("Error setting phash: %v", err) } } diff --git a/internal/manager/task_generate_preview.go b/internal/manager/task_generate_preview.go index 2e39a6d7c..37ec51ec2 100644 --- a/internal/manager/task_generate_preview.go +++ b/internal/manager/task_generate_preview.go @@ -40,7 +40,7 @@ func (t *GeneratePreviewTask) Start(ctx context.Context) { videoChecksum := t.Scene.GetHash(t.fileNamingAlgorithm) - if err := t.generateVideo(videoChecksum, videoFile.Duration); err != nil { + if err := t.generateVideo(videoChecksum, videoFile.VideoStreamDuration); err != nil { logger.Errorf("error generating preview: %v", err) logErrorOutput(err) return @@ -73,6 +73,10 @@ func (t GeneratePreviewTask) generateWebp(videoChecksum string) error { } func (t GeneratePreviewTask) required() bool { + if t.Scene.Path == "" { + return false + } + sceneHash := t.Scene.GetHash(t.fileNamingAlgorithm) videoExists := t.doesVideoPreviewExist(sceneHash) imageExists := !t.ImagePreview || t.doesImagePreviewExist(sceneHash) diff --git a/internal/manager/task_generate_screenshot.go b/internal/manager/task_generate_screenshot.go index 3d7e528df..c235d00b1 100644 --- a/internal/manager/task_generate_screenshot.go +++ b/internal/manager/task_generate_screenshot.go @@ -23,6 +23,9 @@ func (t *GenerateScreenshotTask) Start(ctx context.Context) { scenePath := t.Scene.Path videoFile := t.Scene.Files.Primary() + if videoFile == nil { + return + } var at float64 if t.ScreenshotAt == nil { @@ -88,7 +91,7 @@ func (t *GenerateScreenshotTask) Start(ctx context.Context) { } return nil - }); err != nil { + }); err != nil && ctx.Err() == nil { logger.Error(err.Error()) } } diff --git a/internal/manager/task_import.go b/internal/manager/task_import.go index bd887b2e1..33d862c2e 100644 --- a/internal/manager/task_import.go +++ b/internal/manager/task_import.go @@ -581,7 +581,7 @@ func (t *ImportTask) ImportTag(ctx context.Context, tagJSON *jsonschema.Tag, pen if err := t.ImportTag(ctx, childTagJSON, pendingParent, fail, readerWriter); err != nil { var parentError tag.ParentTagNotExistError if errors.As(err, &parentError) { - pendingParent[parentError.MissingParent()] = append(pendingParent[parentError.MissingParent()], tagJSON) + pendingParent[parentError.MissingParent()] = append(pendingParent[parentError.MissingParent()], childTagJSON) continue } diff --git a/internal/manager/task_stash_box_tag.go b/internal/manager/task_stash_box_tag.go index cf7add510..a9f7fd4ad 100644 --- a/internal/manager/task_stash_box_tag.go +++ b/internal/manager/task_stash_box_tag.go @@ -2,8 +2,8 @@ package manager import ( "context" - "database/sql" "fmt" + "strconv" "time" "github.com/stashapp/stash/pkg/hash/md5" @@ -31,7 +31,7 @@ func (t *StashBoxPerformerTagTask) Description() string { if t.name != nil { name = *t.name } else if t.performer != nil { - name = t.performer.Name.String + name = t.performer.Name } return fmt.Sprintf("Tagging performer %s from stash-box", name) @@ -50,7 +50,7 @@ func (t *StashBoxPerformerTagTask) stashBoxPerformerTag(ctx context.Context) { if t.refresh { var performerID string - txnErr := txn.WithTxn(ctx, instance.Repository, func(ctx context.Context) error { + txnErr := txn.WithReadTxn(ctx, instance.Repository, func(ctx context.Context) error { stashids, _ := instance.Repository.Performer.GetStashIDs(ctx, t.performer.ID) for _, id := range stashids { if id.Endpoint == t.box.Endpoint { @@ -70,7 +70,7 @@ func (t *StashBoxPerformerTagTask) stashBoxPerformerTag(ctx context.Context) { if t.name != nil { name = *t.name } else { - name = t.performer.Name.String + name = t.performer.Name } performer, err = client.FindStashBoxPerformerByName(ctx, name) } @@ -86,84 +86,73 @@ func (t *StashBoxPerformerTagTask) stashBoxPerformerTag(ctx context.Context) { } if performer != nil { - updatedTime := time.Now() - if t.performer != nil { - partial := models.PerformerPartial{ - ID: t.performer.ID, - UpdatedAt: &models.SQLiteTimestamp{Timestamp: updatedTime}, - } + partial := models.NewPerformerPartial() if performer.Aliases != nil && !excluded["aliases"] { - value := getNullString(performer.Aliases) - partial.Aliases = &value + partial.Aliases = models.NewOptionalString(*performer.Aliases) } if performer.Birthdate != nil && *performer.Birthdate != "" && !excluded["birthdate"] { value := getDate(performer.Birthdate) - partial.Birthdate = &value + partial.Birthdate = models.NewOptionalDate(*value) } if performer.CareerLength != nil && !excluded["career_length"] { - value := getNullString(performer.CareerLength) - partial.CareerLength = &value + partial.CareerLength = models.NewOptionalString(*performer.CareerLength) } if performer.Country != nil && !excluded["country"] { - value := getNullString(performer.Country) - partial.Country = &value + partial.Country = models.NewOptionalString(*performer.Country) } if performer.Ethnicity != nil && !excluded["ethnicity"] { - value := getNullString(performer.Ethnicity) - partial.Ethnicity = &value + partial.Ethnicity = models.NewOptionalString(*performer.Ethnicity) } if performer.EyeColor != nil && !excluded["eye_color"] { - value := getNullString(performer.EyeColor) - partial.EyeColor = &value + partial.EyeColor = models.NewOptionalString(*performer.EyeColor) } if performer.FakeTits != nil && !excluded["fake_tits"] { - value := getNullString(performer.FakeTits) - partial.FakeTits = &value + partial.FakeTits = models.NewOptionalString(*performer.FakeTits) } if performer.Gender != nil && !excluded["gender"] { - value := getNullString(performer.Gender) - partial.Gender = &value + partial.Gender = models.NewOptionalString(*performer.Gender) } if performer.Height != nil && !excluded["height"] { - value := getNullString(performer.Height) - partial.Height = &value + h, err := strconv.Atoi(*performer.Height) + if err == nil { + partial.Height = models.NewOptionalInt(h) + } + } + if performer.Weight != nil && !excluded["weight"] { + w, err := strconv.Atoi(*performer.Weight) + if err == nil { + partial.Weight = models.NewOptionalInt(w) + } } if performer.Instagram != nil && !excluded["instagram"] { - value := getNullString(performer.Instagram) - partial.Instagram = &value + partial.Instagram = models.NewOptionalString(*performer.Instagram) } if performer.Measurements != nil && !excluded["measurements"] { - value := getNullString(performer.Measurements) - partial.Measurements = &value + partial.Measurements = models.NewOptionalString(*performer.Measurements) } if excluded["name"] && performer.Name != nil { - value := sql.NullString{String: *performer.Name, Valid: true} - partial.Name = &value + partial.Name = models.NewOptionalString(*performer.Name) checksum := md5.FromString(*performer.Name) - partial.Checksum = &checksum + partial.Checksum = models.NewOptionalString(checksum) } if performer.Piercings != nil && !excluded["piercings"] { - value := getNullString(performer.Piercings) - partial.Piercings = &value + partial.Piercings = models.NewOptionalString(*performer.Piercings) } if performer.Tattoos != nil && !excluded["tattoos"] { - value := getNullString(performer.Tattoos) - partial.Tattoos = &value + partial.Tattoos = models.NewOptionalString(*performer.Tattoos) } if performer.Twitter != nil && !excluded["twitter"] { - value := getNullString(performer.Twitter) - partial.Twitter = &value + partial.Twitter = models.NewOptionalString(*performer.Twitter) } if performer.URL != nil && !excluded["url"] { - value := getNullString(performer.URL) - partial.URL = &value + partial.URL = models.NewOptionalString(*performer.URL) } txnErr := txn.WithTxn(ctx, instance.Repository, func(ctx context.Context) error { r := instance.Repository - _, err := r.Performer.Update(ctx, partial) + _, err := r.Performer.UpdatePartial(ctx, t.performer.ID, partial) if !t.refresh { err = r.Performer.UpdateStashIDs(ctx, t.performer.ID, []models.StashID{ @@ -203,35 +192,35 @@ func (t *StashBoxPerformerTagTask) stashBoxPerformerTag(ctx context.Context) { } else if t.name != nil && performer.Name != nil { currentTime := time.Now() newPerformer := models.Performer{ - Aliases: getNullString(performer.Aliases), + Aliases: getString(performer.Aliases), Birthdate: getDate(performer.Birthdate), - CareerLength: getNullString(performer.CareerLength), + CareerLength: getString(performer.CareerLength), Checksum: md5.FromString(*performer.Name), - Country: getNullString(performer.Country), - CreatedAt: models.SQLiteTimestamp{Timestamp: currentTime}, - Ethnicity: getNullString(performer.Ethnicity), - EyeColor: getNullString(performer.EyeColor), - FakeTits: getNullString(performer.FakeTits), - Favorite: sql.NullBool{Bool: false, Valid: true}, - Gender: getNullString(performer.Gender), - Height: getNullString(performer.Height), - Instagram: getNullString(performer.Instagram), - Measurements: getNullString(performer.Measurements), - Name: sql.NullString{String: *performer.Name, Valid: true}, - Piercings: getNullString(performer.Piercings), - Tattoos: getNullString(performer.Tattoos), - Twitter: getNullString(performer.Twitter), - URL: getNullString(performer.URL), - UpdatedAt: models.SQLiteTimestamp{Timestamp: currentTime}, + Country: getString(performer.Country), + CreatedAt: currentTime, + Ethnicity: getString(performer.Ethnicity), + EyeColor: getString(performer.EyeColor), + FakeTits: getString(performer.FakeTits), + Gender: models.GenderEnum(getString(performer.Gender)), + Height: getIntPtr(performer.Height), + Weight: getIntPtr(performer.Weight), + Instagram: getString(performer.Instagram), + Measurements: getString(performer.Measurements), + Name: *performer.Name, + Piercings: getString(performer.Piercings), + Tattoos: getString(performer.Tattoos), + Twitter: getString(performer.Twitter), + URL: getString(performer.URL), + UpdatedAt: currentTime, } err := txn.WithTxn(ctx, instance.Repository, func(ctx context.Context) error { r := instance.Repository - createdPerformer, err := r.Performer.Create(ctx, newPerformer) + err := r.Performer.Create(ctx, &newPerformer) if err != nil { return err } - err = r.Performer.UpdateStashIDs(ctx, createdPerformer.ID, []models.StashID{ + err = r.Performer.UpdateStashIDs(ctx, newPerformer.ID, []models.StashID{ { Endpoint: t.box.Endpoint, StashID: *performer.RemoteSiteID, @@ -246,7 +235,7 @@ func (t *StashBoxPerformerTagTask) stashBoxPerformerTag(ctx context.Context) { if imageErr != nil { return imageErr } - err = r.Performer.UpdateImage(ctx, createdPerformer.ID, image) + err = r.Performer.UpdateImage(ctx, newPerformer.ID, image) } return err }) @@ -261,24 +250,38 @@ func (t *StashBoxPerformerTagTask) stashBoxPerformerTag(ctx context.Context) { if t.name != nil { name = *t.name } else if t.performer != nil { - name = t.performer.Name.String + name = t.performer.Name } logger.Infof("No match found for %s", name) } } -func getDate(val *string) models.SQLiteDate { +func getDate(val *string) *models.Date { if val == nil { - return models.SQLiteDate{Valid: false} + return nil + } + + ret := models.NewDate(*val) + return &ret +} + +func getString(val *string) string { + if val == nil { + return "" } else { - return models.SQLiteDate{String: *val, Valid: true} + return *val } } -func getNullString(val *string) sql.NullString { +func getIntPtr(val *string) *int { if val == nil { - return sql.NullString{Valid: false} + return nil } else { - return sql.NullString{String: *val, Valid: true} + v, err := strconv.Atoi(*val) + if err != nil { + return nil + } + + return &v } } diff --git a/pkg/ffmpeg/ffprobe.go b/pkg/ffmpeg/ffprobe.go index fc946b6c1..d7bda62db 100644 --- a/pkg/ffmpeg/ffprobe.go +++ b/pkg/ffmpeg/ffprobe.go @@ -19,15 +19,20 @@ type VideoFile struct { AudioStream *FFProbeStream VideoStream *FFProbeStream - Path string - Title string - Comment string - Container string - Duration float64 - StartTime float64 - Bitrate int64 - Size int64 - CreationTime time.Time + Path string + Title string + Comment string + Container string + // FileDuration is the declared (meta-data) duration of the *file*. + // In most cases (sprites, previews, etc.) we actually care about the duration of the video stream specifically, + // because those two can differ slightly (e.g. audio stream longer than the video stream, making the whole file + // longer). + FileDuration float64 + VideoStreamDuration float64 + StartTime float64 + Bitrate int64 + Size int64 + CreationTime time.Time VideoCodec string VideoBitrate int64 @@ -127,7 +132,7 @@ func parse(filePath string, probeJSON *FFProbeJSON) (*VideoFile, error) { result.Container = probeJSON.Format.FormatName duration, _ := strconv.ParseFloat(probeJSON.Format.Duration, 64) - result.Duration = math.Round(duration*100) / 100 + result.FileDuration = math.Round(duration*100) / 100 fileStat, err := os.Stat(filePath) if err != nil { statErr := fmt.Errorf("error statting file <%s>: %w", filePath, err) @@ -178,6 +183,11 @@ func parse(filePath string, probeJSON *FFProbeJSON) (*VideoFile, error) { result.Width = videoStream.Width result.Height = videoStream.Height } + result.VideoStreamDuration, err = strconv.ParseFloat(videoStream.Duration, 64) + if err != nil { + // Revert to the historical behaviour, which is still correct in the vast majority of cases. + result.VideoStreamDuration = result.FileDuration + } } return result, nil diff --git a/pkg/file/clean.go b/pkg/file/clean.go index 7a1ff912a..cc8ebde6b 100644 --- a/pkg/file/clean.go +++ b/pkg/file/clean.go @@ -109,7 +109,7 @@ func (j *cleanJob) execute(ctx context.Context) error { folderCount int ) - if err := txn.WithTxn(ctx, j.Repository, func(ctx context.Context) error { + if err := txn.WithReadTxn(ctx, j.Repository, func(ctx context.Context) error { var err error fileCount, err = j.Repository.CountAllInPaths(ctx, j.options.Paths) if err != nil { @@ -169,7 +169,7 @@ func (j *cleanJob) assessFiles(ctx context.Context, toDelete *deleteSet) error { progress := j.progress more := true - if err := txn.WithTxn(ctx, j.Repository, func(ctx context.Context) error { + if err := txn.WithReadTxn(ctx, j.Repository, func(ctx context.Context) error { for more { if job.IsCancelled(ctx) { return nil @@ -253,7 +253,7 @@ func (j *cleanJob) assessFolders(ctx context.Context, toDelete *deleteSet) error progress := j.progress more := true - if err := txn.WithTxn(ctx, j.Repository, func(ctx context.Context) error { + if err := txn.WithReadTxn(ctx, j.Repository, func(ctx context.Context) error { for more { if job.IsCancelled(ctx) { return nil diff --git a/pkg/file/file.go b/pkg/file/file.go index c5de0b8a9..525c0f329 100644 --- a/pkg/file/file.go +++ b/pkg/file/file.go @@ -154,6 +154,7 @@ type Finder interface { // Getter provides methods to find Files. type Getter interface { + Finder FindByPath(ctx context.Context, path string) (File, error) FindByFingerprint(ctx context.Context, fp Fingerprint) ([]File, error) FindByZipFileID(ctx context.Context, zipFileID ID) ([]File, error) @@ -190,6 +191,8 @@ type Store interface { Creator Updater Destroyer + + IsPrimary(ctx context.Context, fileID ID) (bool, error) } // Decorator wraps the Decorate method to add additional functionality while scanning files. diff --git a/pkg/file/fs.go b/pkg/file/fs.go index 45d650fdf..0a24aaa53 100644 --- a/pkg/file/fs.go +++ b/pkg/file/fs.go @@ -4,6 +4,8 @@ import ( "io" "io/fs" "os" + + "github.com/stashapp/stash/pkg/fsutil" ) // Opener provides an interface to open a file. @@ -22,14 +24,20 @@ func (o *fsOpener) Open() (io.ReadCloser, error) { // FS represents a file system. type FS interface { + Stat(name string) (fs.FileInfo, error) Lstat(name string) (fs.FileInfo, error) Open(name string) (fs.ReadDirFile, error) OpenZip(name string) (*ZipFS, error) + IsPathCaseSensitive(path string) (bool, error) } // OsFS is a file system backed by the OS. type OsFS struct{} +func (f *OsFS) Stat(name string) (fs.FileInfo, error) { + return os.Stat(name) +} + func (f *OsFS) Lstat(name string) (fs.FileInfo, error) { return os.Lstat(name) } @@ -46,3 +54,7 @@ func (f *OsFS) OpenZip(name string) (*ZipFS, error) { return newZipFS(f, name, info) } + +func (f *OsFS) IsPathCaseSensitive(path string) (bool, error) { + return fsutil.IsFsPathCaseSensitive(path) +} diff --git a/pkg/file/scan.go b/pkg/file/scan.go index 7adcd4d9b..31cd50af6 100644 --- a/pkg/file/scan.go +++ b/pkg/file/scan.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "io/fs" + "os" "path/filepath" "strings" "sync" @@ -84,7 +85,6 @@ type scanJob struct { startTime time.Time fileQueue chan scanFile - dbQueue chan func(ctx context.Context) error retryList []scanFile retrying bool folderPathToID sync.Map @@ -129,9 +129,8 @@ func (s *Scanner) Scan(ctx context.Context, handlers []Handler, options ScanOpti type scanFile struct { *BaseFile - fs FS - info fs.FileInfo - zipFile *scanFile + fs FS + info fs.FileInfo } func (s *scanJob) withTxn(ctx context.Context, fn func(ctx context.Context) error) error { @@ -148,9 +147,11 @@ func (s *scanJob) execute(ctx context.Context) { s.startTime = time.Now() s.fileQueue = make(chan scanFile, scanQueueSize) - s.dbQueue = make(chan func(ctx context.Context) error, scanQueueSize) + var wg sync.WaitGroup + wg.Add(1) go func() { + defer wg.Done() if err := s.queueFiles(ctx, paths); err != nil { if errors.Is(err, context.Canceled) { return @@ -163,6 +164,8 @@ func (s *scanJob) execute(ctx context.Context) { logger.Infof("Finished adding files to queue. %d files queued", s.count) }() + defer wg.Wait() + if err := s.processQueue(ctx); err != nil { if errors.Is(err, context.Canceled) { return @@ -211,6 +214,19 @@ func (s *scanJob) queueFileFunc(ctx context.Context, f FS, zipFile *scanFile) fs return fmt.Errorf("reading info for %q: %w", path, err) } + var size int64 + + // #2196/#3042 - replace size with target size if file is a symlink + if info.Mode()&os.ModeSymlink == os.ModeSymlink { + targetInfo, err := f.Stat(path) + if err != nil { + return fmt.Errorf("reading info for symlink %q: %w", path, err) + } + size = targetInfo.Size() + } else { + size = info.Size() + } + if !s.acceptEntry(ctx, path, info) { if info.IsDir() { return fs.SkipDir @@ -226,13 +242,19 @@ func (s *scanJob) queueFileFunc(ctx context.Context, f FS, zipFile *scanFile) fs }, Path: path, Basename: filepath.Base(path), - Size: info.Size(), + Size: size, }, fs: f, info: info, - // there is no guarantee that the zip file has been scanned - // so we can't just plug in the id. - zipFile: zipFile, + } + + if zipFile != nil { + zipFileID, err := s.getZipFileID(ctx, zipFile) + if err != nil { + return err + } + ff.ZipFileID = zipFileID + ff.ZipFile = zipFile } if info.IsDir() { @@ -310,45 +332,57 @@ func (s *scanJob) processQueue(ctx context.Context) error { wg := sizedwaitgroup.New(parallelTasks) - for f := range s.fileQueue { - if err := ctx.Err(); err != nil { - return err + if err := func() error { + defer wg.Wait() + + for f := range s.fileQueue { + if err := ctx.Err(); err != nil { + return err + } + + wg.Add() + ff := f + go func() { + defer wg.Done() + s.processQueueItem(ctx, ff) + }() } - wg.Add() - ff := f - go func() { - defer wg.Done() - s.processQueueItem(ctx, ff) - }() + return nil + }(); err != nil { + return err } - wg.Wait() s.retrying = true - for _, f := range s.retryList { - if err := ctx.Err(); err != nil { - return err + + if err := func() error { + defer wg.Wait() + + for _, f := range s.retryList { + if err := ctx.Err(); err != nil { + return err + } + + wg.Add() + ff := f + go func() { + defer wg.Done() + s.processQueueItem(ctx, ff) + }() } - wg.Add() - ff := f - go func() { - defer wg.Done() - s.processQueueItem(ctx, ff) - }() + return nil + }(); err != nil { + return err } - wg.Wait() - - close(s.dbQueue) - return nil } func (s *scanJob) incrementProgress(f scanFile) { // don't increment for files inside zip files since these aren't // counted during the initial walking - if s.ProgressReports != nil && f.zipFile == nil { + if s.ProgressReports != nil && f.ZipFile == nil { s.ProgressReports.Increment() } } @@ -453,23 +487,12 @@ func (s *scanJob) onNewFolder(ctx context.Context, file scanFile) (*Folder, erro now := time.Now() toCreate := &Folder{ - DirEntry: DirEntry{ - ModTime: file.ModTime, - }, + DirEntry: file.DirEntry, Path: file.Path, CreatedAt: now, UpdatedAt: now, } - zipFileID, err := s.getZipFileID(ctx, file.zipFile) - if err != nil { - return nil, err - } - - if zipFileID != nil { - toCreate.ZipFileID = zipFileID - } - dir := filepath.Dir(file.Path) if dir != "." { parentFolderID, err := s.getFolderID(ctx, dir) @@ -601,15 +624,6 @@ func (s *scanJob) onNewFile(ctx context.Context, f scanFile) (File, error) { baseFile.ParentFolderID = *parentFolderID - zipFileID, err := s.getZipFileID(ctx, f.zipFile) - if err != nil { - return nil, err - } - - if zipFileID != nil { - baseFile.ZipFileID = zipFileID - } - const useExisting = false fp, err := s.calculateFingerprints(f.fs, baseFile, path, useExisting) if err != nil { @@ -741,14 +755,22 @@ func (s *scanJob) handleRename(ctx context.Context, f File, fp []Fingerprint) (F for _, other := range others { // if file does not exist, then update it to the new path - // TODO - handle #1426 scenario fs, err := s.getFileFS(other.Base()) if err != nil { - return nil, fmt.Errorf("getting FS for %q: %w", other.Base().Path, err) + missing = append(missing, other) + continue } if _, err := fs.Lstat(other.Base().Path); err != nil { missing = append(missing, other) + } else if strings.EqualFold(f.Base().Path, other.Base().Path) { + // #1426 - if file exists but is a case-insensitive match for the + // original filename, and the filesystem is case-insensitive + // then treat it as a move + if caseSensitive, _ := fs.IsPathCaseSensitive(other.Base().Path); !caseSensitive { + // treat as a move + missing = append(missing, other) + } } } diff --git a/pkg/file/video/scan.go b/pkg/file/video/scan.go index b1ddf2d83..1f3d7817f 100644 --- a/pkg/file/video/scan.go +++ b/pkg/file/video/scan.go @@ -49,7 +49,7 @@ func (d *Decorator) Decorate(ctx context.Context, fs file.FS, f file.File) (file AudioCodec: videoFile.AudioCodec, Width: videoFile.Width, Height: videoFile.Height, - Duration: videoFile.Duration, + Duration: videoFile.FileDuration, FrameRate: videoFile.FrameRate, BitRate: videoFile.Bitrate, Interactive: interactive, @@ -75,5 +75,6 @@ func (d *Decorator) IsMissingMetadata(ctx context.Context, fs file.FS, f file.Fi return vf.VideoCodec == unsetString || vf.AudioCodec == unsetString || vf.Format == unsetString || vf.Width == unsetNumber || vf.Height == unsetNumber || vf.FrameRate == unsetNumber || + vf.Duration == unsetNumber || vf.BitRate == unsetNumber || interactive != vf.Interactive } diff --git a/pkg/file/zip.go b/pkg/file/zip.go index f610b8b1c..1e61d340e 100644 --- a/pkg/file/zip.go +++ b/pkg/file/zip.go @@ -65,7 +65,7 @@ func (f *ZipFS) rel(name string) (string, error) { return relName, nil } -func (f *ZipFS) Lstat(name string) (fs.FileInfo, error) { +func (f *ZipFS) Stat(name string) (fs.FileInfo, error) { reader, err := f.Open(name) if err != nil { return nil, err @@ -75,10 +75,18 @@ func (f *ZipFS) Lstat(name string) (fs.FileInfo, error) { return reader.Stat() } +func (f *ZipFS) Lstat(name string) (fs.FileInfo, error) { + return f.Stat(name) +} + func (f *ZipFS) OpenZip(name string) (*ZipFS, error) { return nil, errZipFSOpenZip } +func (f *ZipFS) IsPathCaseSensitive(path string) (bool, error) { + return true, nil +} + type zipReadDirFile struct { fs.File } diff --git a/pkg/gallery/import.go b/pkg/gallery/import.go index c324d8d72..c3bd83527 100644 --- a/pkg/gallery/import.go +++ b/pkg/gallery/import.go @@ -136,10 +136,10 @@ func (i *Importer) populatePerformers(ctx context.Context) error { var pluckedNames []string for _, performer := range performers { - if !performer.Name.Valid { + if performer.Name == "" { continue } - pluckedNames = append(pluckedNames, performer.Name.String) + pluckedNames = append(pluckedNames, performer.Name) } missingPerformers := stringslice.StrFilter(names, func(name string) bool { @@ -176,12 +176,12 @@ func (i *Importer) createPerformers(ctx context.Context, names []string) ([]*mod for _, name := range names { newPerformer := *models.NewPerformer(name) - created, err := i.PerformerWriter.Create(ctx, newPerformer) + err := i.PerformerWriter.Create(ctx, &newPerformer) if err != nil { return nil, err } - ret = append(ret, created) + ret = append(ret, &newPerformer) } return ret, nil diff --git a/pkg/gallery/import_test.go b/pkg/gallery/import_test.go index 43634fd13..73f2aed7d 100644 --- a/pkg/gallery/import_test.go +++ b/pkg/gallery/import_test.go @@ -169,7 +169,7 @@ func TestImporterPreImportWithPerformer(t *testing.T) { performerReaderWriter.On("FindByNames", testCtx, []string{existingPerformerName}, false).Return([]*models.Performer{ { ID: existingPerformerID, - Name: models.NullString(existingPerformerName), + Name: existingPerformerName, }, }, nil).Once() performerReaderWriter.On("FindByNames", testCtx, []string{existingPerformerErr}, false).Return(nil, errors.New("FindByNames error")).Once() @@ -199,9 +199,10 @@ func TestImporterPreImportWithMissingPerformer(t *testing.T) { } performerReaderWriter.On("FindByNames", testCtx, []string{missingPerformerName}, false).Return(nil, nil).Times(3) - performerReaderWriter.On("Create", testCtx, mock.AnythingOfType("models.Performer")).Return(&models.Performer{ - ID: existingPerformerID, - }, nil) + performerReaderWriter.On("Create", testCtx, mock.AnythingOfType("*models.Performer")).Run(func(args mock.Arguments) { + performer := args.Get(1).(*models.Performer) + performer.ID = existingPerformerID + }).Return(nil) err := i.PreImport(testCtx) assert.NotNil(t, err) @@ -232,7 +233,7 @@ func TestImporterPreImportWithMissingPerformerCreateErr(t *testing.T) { } performerReaderWriter.On("FindByNames", testCtx, []string{missingPerformerName}, false).Return(nil, nil).Once() - performerReaderWriter.On("Create", testCtx, mock.AnythingOfType("models.Performer")).Return(nil, errors.New("Create error")) + performerReaderWriter.On("Create", testCtx, mock.AnythingOfType("*models.Performer")).Return(errors.New("Create error")) err := i.PreImport(testCtx) assert.NotNil(t, err) diff --git a/pkg/hash/oshash/oshash.go b/pkg/hash/oshash/oshash.go index 7d0687b99..2c42afd2a 100644 --- a/pkg/hash/oshash/oshash.go +++ b/pkg/hash/oshash/oshash.go @@ -48,8 +48,8 @@ func oshash(size int64, head []byte, tail []byte) (string, error) { // FromFilePath calculates the hash reading from src. func FromReader(src io.ReadSeeker, fileSize int64) (string, error) { - if fileSize == 0 { - return "", nil + if fileSize <= 0 { + return "", fmt.Errorf("cannot calculate oshash for empty file (size %d)", fileSize) } fileChunkSize := chunkSize diff --git a/pkg/image/image.go b/pkg/image/image.go deleted file mode 100644 index 00c8b3be2..000000000 --- a/pkg/image/image.go +++ /dev/null @@ -1,12 +0,0 @@ -package image - -import ( - "strings" - - "github.com/stashapp/stash/pkg/models" - _ "golang.org/x/image/webp" -) - -func IsCover(img *models.Image) bool { - return strings.HasSuffix(img.Path, "cover.jpg") -} diff --git a/pkg/image/image_test.go b/pkg/image/image_test.go deleted file mode 100644 index 3188a63d5..000000000 --- a/pkg/image/image_test.go +++ /dev/null @@ -1,34 +0,0 @@ -package image - -import ( - "fmt" - "path/filepath" - "testing" - - "github.com/stashapp/stash/pkg/models" - "github.com/stretchr/testify/assert" -) - -func TestIsCover(t *testing.T) { - type test struct { - fn string - isCover bool - } - - tests := []test{ - {"cover.jpg", true}, - {"covernot.jpg", false}, - {"Cover.jpg", false}, - {fmt.Sprintf("subDir%scover.jpg", string(filepath.Separator)), true}, - {"endsWithcover.jpg", true}, - {"cover.png", false}, - } - - assert := assert.New(t) - for _, tc := range tests { - img := &models.Image{ - Path: tc.fn, - } - assert.Equal(tc.isCover, IsCover(img), "expected: %t for %s", tc.isCover, tc.fn) - } -} diff --git a/pkg/image/import.go b/pkg/image/import.go index 7c19a5629..018dbf9fb 100644 --- a/pkg/image/import.go +++ b/pkg/image/import.go @@ -216,10 +216,10 @@ func (i *Importer) populatePerformers(ctx context.Context) error { var pluckedNames []string for _, performer := range performers { - if !performer.Name.Valid { + if performer.Name == "" { continue } - pluckedNames = append(pluckedNames, performer.Name.String) + pluckedNames = append(pluckedNames, performer.Name) } missingPerformers := stringslice.StrFilter(names, func(name string) bool { @@ -256,12 +256,12 @@ func (i *Importer) createPerformers(ctx context.Context, names []string) ([]*mod for _, name := range names { newPerformer := *models.NewPerformer(name) - created, err := i.PerformerWriter.Create(ctx, newPerformer) + err := i.PerformerWriter.Create(ctx, &newPerformer) if err != nil { return nil, err } - ret = append(ret, created) + ret = append(ret, &newPerformer) } return ret, nil diff --git a/pkg/image/import_test.go b/pkg/image/import_test.go index 647815127..6724b87cb 100644 --- a/pkg/image/import_test.go +++ b/pkg/image/import_test.go @@ -130,7 +130,7 @@ func TestImporterPreImportWithPerformer(t *testing.T) { performerReaderWriter.On("FindByNames", testCtx, []string{existingPerformerName}, false).Return([]*models.Performer{ { ID: existingPerformerID, - Name: models.NullString(existingPerformerName), + Name: existingPerformerName, }, }, nil).Once() performerReaderWriter.On("FindByNames", testCtx, []string{existingPerformerErr}, false).Return(nil, errors.New("FindByNames error")).Once() @@ -160,9 +160,10 @@ func TestImporterPreImportWithMissingPerformer(t *testing.T) { } performerReaderWriter.On("FindByNames", testCtx, []string{missingPerformerName}, false).Return(nil, nil).Times(3) - performerReaderWriter.On("Create", testCtx, mock.AnythingOfType("models.Performer")).Return(&models.Performer{ - ID: existingPerformerID, - }, nil) + performerReaderWriter.On("Create", testCtx, mock.AnythingOfType("*models.Performer")).Run(func(args mock.Arguments) { + performer := args.Get(1).(*models.Performer) + performer.ID = existingPerformerID + }).Return(nil) err := i.PreImport(testCtx) assert.NotNil(t, err) @@ -193,7 +194,7 @@ func TestImporterPreImportWithMissingPerformerCreateErr(t *testing.T) { } performerReaderWriter.On("FindByNames", testCtx, []string{missingPerformerName}, false).Return(nil, nil).Once() - performerReaderWriter.On("Create", testCtx, mock.AnythingOfType("models.Performer")).Return(nil, errors.New("Create error")) + performerReaderWriter.On("Create", testCtx, mock.AnythingOfType("*models.Performer")).Return(errors.New("Create error")) err := i.PreImport(testCtx) assert.NotNil(t, err) diff --git a/pkg/image/query.go b/pkg/image/query.go index 36ed3a8c3..45f1cb687 100644 --- a/pkg/image/query.go +++ b/pkg/image/query.go @@ -7,6 +7,11 @@ import ( "github.com/stashapp/stash/pkg/models" ) +const ( + coverFilename = "cover.jpg" + coverFilenameSearchString = "%" + coverFilename +) + type Queryer interface { Query(ctx context.Context, options models.ImageQueryOptions) (*models.ImageQueryResult, error) } @@ -96,3 +101,56 @@ func FindByGalleryID(ctx context.Context, r Queryer, galleryID int, sortBy strin }, }, &findFilter) } + +func FindGalleryCover(ctx context.Context, r Queryer, galleryID int) (*models.Image, error) { + const useCoverJpg = true + img, err := findGalleryCover(ctx, r, galleryID, useCoverJpg) + if err != nil { + return nil, err + } + + if img != nil { + return img, nil + } + + // return the first image in the gallery + return findGalleryCover(ctx, r, galleryID, !useCoverJpg) +} + +func findGalleryCover(ctx context.Context, r Queryer, galleryID int, useCoverJpg bool) (*models.Image, error) { + // try to find cover.jpg in the gallery + perPage := 1 + sortBy := "path" + sortDir := models.SortDirectionEnumAsc + + findFilter := models.FindFilterType{ + PerPage: &perPage, + Sort: &sortBy, + Direction: &sortDir, + } + + imageFilter := &models.ImageFilterType{ + Galleries: &models.MultiCriterionInput{ + Value: []string{strconv.Itoa(galleryID)}, + Modifier: models.CriterionModifierIncludes, + }, + } + + if useCoverJpg { + imageFilter.Path = &models.StringCriterionInput{ + Value: coverFilenameSearchString, + Modifier: models.CriterionModifierEquals, + } + } + + imgs, err := Query(ctx, r, imageFilter, &findFilter) + if err != nil { + return nil, err + } + + if len(imgs) > 0 { + return imgs[0], nil + } + + return nil, nil +} diff --git a/pkg/image/scan.go b/pkg/image/scan.go index 3b96d400e..c8d02c26f 100644 --- a/pkg/image/scan.go +++ b/pkg/image/scan.go @@ -73,26 +73,6 @@ func (h *ScanHandler) validate() error { return nil } -func (h *ScanHandler) logInfo(ctx context.Context, format string, args ...interface{}) { - // log at the end so that if anything fails above due to a locked database - // error and the transaction must be retried, then we shouldn't get multiple - // logs of the same thing. - txn.AddPostCompleteHook(ctx, func(ctx context.Context) error { - logger.Infof(format, args...) - return nil - }) -} - -func (h *ScanHandler) logError(ctx context.Context, format string, args ...interface{}) { - // log at the end so that if anything fails above due to a locked database - // error and the transaction must be retried, then we shouldn't get multiple - // logs of the same thing. - txn.AddPostCompleteHook(ctx, func(ctx context.Context) error { - logger.Errorf(format, args...) - return nil - }) -} - func (h *ScanHandler) Handle(ctx context.Context, f file.File, oldFile file.File) error { if err := h.validate(); err != nil { return err @@ -132,7 +112,7 @@ func (h *ScanHandler) Handle(ctx context.Context, f file.File, oldFile file.File GalleryIDs: models.NewRelatedIDs([]int{}), } - h.logInfo(ctx, "%s doesn't exist. Creating new image...", f.Base().Path) + logger.Infof("%s doesn't exist. Creating new image...", f.Base().Path) if _, err := h.associateGallery(ctx, newImage, imageFile); err != nil { return err @@ -162,12 +142,17 @@ func (h *ScanHandler) Handle(ctx context.Context, f file.File, oldFile file.File } if h.ScanConfig.IsGenerateThumbnails() { - for _, s := range existing { - if err := h.ThumbnailGenerator.GenerateThumbnail(ctx, s, imageFile); err != nil { - // just log if cover generation fails. We can try again on rescan - h.logError(ctx, "Error generating thumbnail for %s: %v", imageFile.Path, err) + // do this after the commit so that the transaction isn't held up + txn.AddPostCommitHook(ctx, func(ctx context.Context) error { + for _, s := range existing { + if err := h.ThumbnailGenerator.GenerateThumbnail(ctx, s, imageFile); err != nil { + // just log if cover generation fails. We can try again on rescan + logger.Errorf("Error generating thumbnail for %s: %v", imageFile.Path, err) + } } - } + + return nil + }) } return nil @@ -202,7 +187,7 @@ func (h *ScanHandler) associateExisting(ctx context.Context, existing []*models. } if !found { - h.logInfo(ctx, "Adding %s to image %s", f.Path, i.DisplayName()) + logger.Infof("Adding %s to image %s", f.Path, i.DisplayName()) if err := h.CreatorUpdater.AddFileID(ctx, i.ID, f.ID); err != nil { return fmt.Errorf("adding file to image: %w", err) @@ -249,12 +234,14 @@ func (h *ScanHandler) getOrCreateFolderBasedGallery(ctx context.Context, f file. UpdatedAt: now, } - h.logInfo(ctx, "Creating folder-based gallery for %s", filepath.Dir(f.Base().Path)) + logger.Infof("Creating folder-based gallery for %s", filepath.Dir(f.Base().Path)) if err := h.GalleryFinder.Create(ctx, newGallery, nil); err != nil { return nil, fmt.Errorf("creating folder based gallery: %w", err) } + h.PluginCache.RegisterPostHooks(ctx, newGallery.ID, plugin.GalleryCreatePost, nil, nil) + // it's possible that there are other images in the folder that // need to be added to the new gallery. Find and add them now. if err := h.associateFolderImages(ctx, newGallery); err != nil { @@ -271,7 +258,7 @@ func (h *ScanHandler) associateFolderImages(ctx context.Context, g *models.Galle } for _, ii := range i { - h.logInfo(ctx, "Adding %s to gallery %s", ii.Path, g.Path) + logger.Infof("Adding %s to gallery %s", ii.Path, g.Path) if _, err := h.CreatorUpdater.UpdatePartial(ctx, ii.ID, models.ImagePartial{ GalleryIDs: &models.UpdateIDs{ @@ -305,12 +292,14 @@ func (h *ScanHandler) getOrCreateZipBasedGallery(ctx context.Context, zipFile fi UpdatedAt: now, } - h.logInfo(ctx, "%s doesn't exist. Creating new gallery...", zipFile.Base().Path) + logger.Infof("%s doesn't exist. Creating new gallery...", zipFile.Base().Path) if err := h.GalleryFinder.Create(ctx, newGallery, []file.ID{zipFile.Base().ID}); err != nil { return nil, fmt.Errorf("creating zip-based gallery: %w", err) } + h.PluginCache.RegisterPostHooks(ctx, newGallery.ID, plugin.GalleryCreatePost, nil, nil) + return newGallery, nil } @@ -341,7 +330,7 @@ func (h *ScanHandler) associateGallery(ctx context.Context, newImage *models.Ima if g != nil && !intslice.IntInclude(newImage.GalleryIDs.List(), g.ID) { ret = true newImage.GalleryIDs.Add(g.ID) - h.logInfo(ctx, "Adding %s to gallery %s", f.Base().Path, g.Path) + logger.Infof("Adding %s to gallery %s", f.Base().Path, g.Path) } return ret, nil diff --git a/pkg/match/path.go b/pkg/match/path.go index e77fc2e59..68f0b7047 100644 --- a/pkg/match/path.go +++ b/pkg/match/path.go @@ -169,7 +169,7 @@ func PathToPerformers(ctx context.Context, path string, reader PerformerAutoTagQ var ret []*models.Performer for _, p := range performers { // TODO - commenting out alias handling until both sides work correctly - if nameMatchesPath(p.Name.String, path) != -1 { // || nameMatchesPath(p.Aliases.String, path) { + if nameMatchesPath(p.Name, path) != -1 { // || nameMatchesPath(p.Aliases.String, path) { ret = append(ret, p) } } @@ -278,7 +278,7 @@ func PathToTags(ctx context.Context, path string, reader TagAutoTagQueryer, cach return ret, nil } -func PathToScenes(ctx context.Context, name string, paths []string, sceneReader scene.Queryer) ([]*models.Scene, error) { +func PathToScenesFn(ctx context.Context, name string, paths []string, sceneReader scene.Queryer, fn func(ctx context.Context, scene *models.Scene) error) error { regex := getPathQueryRegex(name) organized := false filter := models.SceneFilterType{ @@ -291,31 +291,53 @@ func PathToScenes(ctx context.Context, name string, paths []string, sceneReader filter.And = scene.PathsFilter(paths) - pp := models.PerPageAll - scenes, err := scene.Query(ctx, sceneReader, &filter, &models.FindFilterType{ - PerPage: &pp, - }) + // do in batches + pp := 1000 + sort := "id" + sortDir := models.SortDirectionEnumAsc + lastID := 0 - if err != nil { - return nil, fmt.Errorf("error querying scenes with regex '%s': %s", regex, err.Error()) - } - - var ret []*models.Scene - - // paths may have unicode characters - const useUnicode = true - - r := nameToRegexp(name, useUnicode) - for _, p := range scenes { - if regexpMatchesPath(r, p.Path) != -1 { - ret = append(ret, p) + for { + if lastID != 0 { + filter.ID = &models.IntCriterionInput{ + Value: lastID, + Modifier: models.CriterionModifierGreaterThan, + } } + + scenes, err := scene.Query(ctx, sceneReader, &filter, &models.FindFilterType{ + PerPage: &pp, + Sort: &sort, + Direction: &sortDir, + }) + + if err != nil { + return fmt.Errorf("error querying scenes with regex '%s': %s", regex, err.Error()) + } + + // paths may have unicode characters + const useUnicode = true + + r := nameToRegexp(name, useUnicode) + for _, p := range scenes { + if regexpMatchesPath(r, p.Path) != -1 { + if err := fn(ctx, p); err != nil { + return fmt.Errorf("processing scene %s: %w", p.GetTitle(), err) + } + } + } + + if len(scenes) < pp { + break + } + + lastID = scenes[len(scenes)-1].ID } - return ret, nil + return nil } -func PathToImages(ctx context.Context, name string, paths []string, imageReader image.Queryer) ([]*models.Image, error) { +func PathToImagesFn(ctx context.Context, name string, paths []string, imageReader image.Queryer, fn func(ctx context.Context, scene *models.Image) error) error { regex := getPathQueryRegex(name) organized := false filter := models.ImageFilterType{ @@ -328,31 +350,53 @@ func PathToImages(ctx context.Context, name string, paths []string, imageReader filter.And = image.PathsFilter(paths) - pp := models.PerPageAll - images, err := image.Query(ctx, imageReader, &filter, &models.FindFilterType{ - PerPage: &pp, - }) + // do in batches + pp := 1000 + sort := "id" + sortDir := models.SortDirectionEnumAsc + lastID := 0 - if err != nil { - return nil, fmt.Errorf("error querying images with regex '%s': %s", regex, err.Error()) - } - - var ret []*models.Image - - // paths may have unicode characters - const useUnicode = true - - r := nameToRegexp(name, useUnicode) - for _, p := range images { - if regexpMatchesPath(r, p.Path) != -1 { - ret = append(ret, p) + for { + if lastID != 0 { + filter.ID = &models.IntCriterionInput{ + Value: lastID, + Modifier: models.CriterionModifierGreaterThan, + } } + + images, err := image.Query(ctx, imageReader, &filter, &models.FindFilterType{ + PerPage: &pp, + Sort: &sort, + Direction: &sortDir, + }) + + if err != nil { + return fmt.Errorf("error querying images with regex '%s': %s", regex, err.Error()) + } + + // paths may have unicode characters + const useUnicode = true + + r := nameToRegexp(name, useUnicode) + for _, p := range images { + if regexpMatchesPath(r, p.Path) != -1 { + if err := fn(ctx, p); err != nil { + return fmt.Errorf("processing image %s: %w", p.GetTitle(), err) + } + } + } + + if len(images) < pp { + break + } + + lastID = images[len(images)-1].ID } - return ret, nil + return nil } -func PathToGalleries(ctx context.Context, name string, paths []string, galleryReader gallery.Queryer) ([]*models.Gallery, error) { +func PathToGalleriesFn(ctx context.Context, name string, paths []string, galleryReader gallery.Queryer, fn func(ctx context.Context, scene *models.Gallery) error) error { regex := getPathQueryRegex(name) organized := false filter := models.GalleryFilterType{ @@ -365,27 +409,49 @@ func PathToGalleries(ctx context.Context, name string, paths []string, galleryRe filter.And = gallery.PathsFilter(paths) - pp := models.PerPageAll - gallerys, _, err := galleryReader.Query(ctx, &filter, &models.FindFilterType{ - PerPage: &pp, - }) + // do in batches + pp := 1000 + sort := "id" + sortDir := models.SortDirectionEnumAsc + lastID := 0 - if err != nil { - return nil, fmt.Errorf("error querying gallerys with regex '%s': %s", regex, err.Error()) - } - - var ret []*models.Gallery - - // paths may have unicode characters - const useUnicode = true - - r := nameToRegexp(name, useUnicode) - for _, p := range gallerys { - path := p.Path - if path != "" && regexpMatchesPath(r, path) != -1 { - ret = append(ret, p) + for { + if lastID != 0 { + filter.ID = &models.IntCriterionInput{ + Value: lastID, + Modifier: models.CriterionModifierGreaterThan, + } } + + galleries, _, err := galleryReader.Query(ctx, &filter, &models.FindFilterType{ + PerPage: &pp, + Sort: &sort, + Direction: &sortDir, + }) + + if err != nil { + return fmt.Errorf("error querying galleries with regex '%s': %s", regex, err.Error()) + } + + // paths may have unicode characters + const useUnicode = true + + r := nameToRegexp(name, useUnicode) + for _, p := range galleries { + path := p.Path + if path != "" && regexpMatchesPath(r, path) != -1 { + if err := fn(ctx, p); err != nil { + return fmt.Errorf("processing gallery %s: %w", p.GetTitle(), err) + } + } + } + + if len(galleries) < pp { + break + } + + lastID = galleries[len(galleries)-1].ID } - return ret, nil + return nil } diff --git a/pkg/models/filter.go b/pkg/models/filter.go index 57bee72df..d614f262e 100644 --- a/pkg/models/filter.go +++ b/pkg/models/filter.go @@ -85,12 +85,30 @@ type StringCriterionInput struct { Modifier CriterionModifier `json:"modifier"` } +func (i StringCriterionInput) ValidModifier() bool { + switch i.Modifier { + case CriterionModifierEquals, CriterionModifierNotEquals, CriterionModifierIncludes, CriterionModifierExcludes, CriterionModifierMatchesRegex, CriterionModifierNotMatchesRegex, + CriterionModifierIsNull, CriterionModifierNotNull: + return true + } + + return false +} + type IntCriterionInput struct { Value int `json:"value"` Value2 *int `json:"value2"` Modifier CriterionModifier `json:"modifier"` } +func (i IntCriterionInput) ValidModifier() bool { + switch i.Modifier { + case CriterionModifierEquals, CriterionModifierNotEquals, CriterionModifierGreaterThan, CriterionModifierLessThan, CriterionModifierIsNull, CriterionModifierNotNull, CriterionModifierBetween, CriterionModifierNotBetween: + return true + } + return false +} + type ResolutionCriterionInput struct { Value ResolutionEnum `json:"value"` Modifier CriterionModifier `json:"modifier"` @@ -106,3 +124,15 @@ type MultiCriterionInput struct { Value []string `json:"value"` Modifier CriterionModifier `json:"modifier"` } + +type DateCriterionInput struct { + Value string `json:"value"` + Value2 *string `json:"value2"` + Modifier CriterionModifier `json:"modifier"` +} + +type TimestampCriterionInput struct { + Value string `json:"value"` + Value2 *string `json:"value2"` + Modifier CriterionModifier `json:"modifier"` +} diff --git a/pkg/models/gallery.go b/pkg/models/gallery.go index 8ff461238..fa95cc559 100644 --- a/pkg/models/gallery.go +++ b/pkg/models/gallery.go @@ -10,6 +10,7 @@ type GalleryFilterType struct { And *GalleryFilterType `json:"AND"` Or *GalleryFilterType `json:"OR"` Not *GalleryFilterType `json:"NOT"` + ID *IntCriterionInput `json:"id"` Title *StringCriterionInput `json:"title"` Details *StringCriterionInput `json:"details"` // Filter by file checksum @@ -22,8 +23,10 @@ type GalleryFilterType struct { IsMissing *string `json:"is_missing"` // Filter to include/exclude galleries that were created from zip IsZip *bool `json:"is_zip"` - // Filter by rating + // Filter by rating expressed as 1-5 Rating *IntCriterionInput `json:"rating"` + // Filter by rating expressed as 1-100 + Rating100 *IntCriterionInput `json:"rating100"` // Filter by organized Organized *bool `json:"organized"` // Filter by average image resolution @@ -48,6 +51,12 @@ type GalleryFilterType struct { ImageCount *IntCriterionInput `json:"image_count"` // Filter by url URL *StringCriterionInput `json:"url"` + // Filter by date + Date *DateCriterionInput `json:"date"` + // Filter by created at + CreatedAt *TimestampCriterionInput `json:"created_at"` + // Filter by updated at + UpdatedAt *TimestampCriterionInput `json:"updated_at"` } type GalleryUpdateInput struct { @@ -58,6 +67,7 @@ type GalleryUpdateInput struct { Date *string `json:"date"` Details *string `json:"details"` Rating *int `json:"rating"` + Rating100 *int `json:"rating100"` Organized *bool `json:"organized"` SceneIds []string `json:"scene_ids"` StudioID *string `json:"studio_id"` diff --git a/pkg/models/image.go b/pkg/models/image.go index 9ded5939e..2b908dcb6 100644 --- a/pkg/models/image.go +++ b/pkg/models/image.go @@ -6,6 +6,7 @@ type ImageFilterType struct { And *ImageFilterType `json:"AND"` Or *ImageFilterType `json:"OR"` Not *ImageFilterType `json:"NOT"` + ID *IntCriterionInput `json:"id"` Title *StringCriterionInput `json:"title"` // Filter by file checksum Checksum *StringCriterionInput `json:"checksum"` @@ -13,8 +14,10 @@ type ImageFilterType struct { Path *StringCriterionInput `json:"path"` // Filter by file count FileCount *IntCriterionInput `json:"file_count"` - // Filter by rating + // Filter by rating expressed as 1-5 Rating *IntCriterionInput `json:"rating"` + // Filter by rating expressed as 1-100 + Rating100 *IntCriterionInput `json:"rating100"` // Filter by organized Organized *bool `json:"organized"` // Filter by o-counter @@ -39,6 +42,10 @@ type ImageFilterType struct { PerformerFavorite *bool `json:"performer_favorite"` // Filter to only include images with these galleries Galleries *MultiCriterionInput `json:"galleries"` + // Filter by created at + CreatedAt *TimestampCriterionInput `json:"created_at"` + // Filter by updated at + UpdatedAt *TimestampCriterionInput `json:"updated_at"` } type ImageDestroyInput struct { diff --git a/pkg/models/jsonschema/performer.go b/pkg/models/jsonschema/performer.go index ad33452f3..e4f5de2cb 100644 --- a/pkg/models/jsonschema/performer.go +++ b/pkg/models/jsonschema/performer.go @@ -11,15 +11,16 @@ import ( ) type Performer struct { - Name string `json:"name,omitempty"` - Gender string `json:"gender,omitempty"` - URL string `json:"url,omitempty"` - Twitter string `json:"twitter,omitempty"` - Instagram string `json:"instagram,omitempty"` - Birthdate string `json:"birthdate,omitempty"` - Ethnicity string `json:"ethnicity,omitempty"` - Country string `json:"country,omitempty"` - EyeColor string `json:"eye_color,omitempty"` + Name string `json:"name,omitempty"` + Gender string `json:"gender,omitempty"` + URL string `json:"url,omitempty"` + Twitter string `json:"twitter,omitempty"` + Instagram string `json:"instagram,omitempty"` + Birthdate string `json:"birthdate,omitempty"` + Ethnicity string `json:"ethnicity,omitempty"` + Country string `json:"country,omitempty"` + EyeColor string `json:"eye_color,omitempty"` + // this should be int, but keeping string for backwards compatibility Height string `json:"height,omitempty"` Measurements string `json:"measurements,omitempty"` FakeTits string `json:"fake_tits,omitempty"` diff --git a/pkg/models/jsonschema/scene.go b/pkg/models/jsonschema/scene.go index 425ca10e8..fbfdad010 100644 --- a/pkg/models/jsonschema/scene.go +++ b/pkg/models/jsonschema/scene.go @@ -3,6 +3,7 @@ package jsonschema import ( "fmt" "os" + "strconv" jsoniter "github.com/json-iterator/go" "github.com/stashapp/stash/pkg/fsutil" @@ -38,27 +39,33 @@ type SceneMovie struct { } type Scene struct { - Title string `json:"title,omitempty"` - Studio string `json:"studio,omitempty"` - URL string `json:"url,omitempty"` - Date string `json:"date,omitempty"` - Rating int `json:"rating,omitempty"` - Organized bool `json:"organized,omitempty"` - OCounter int `json:"o_counter,omitempty"` - Details string `json:"details,omitempty"` - Galleries []GalleryRef `json:"galleries,omitempty"` - Performers []string `json:"performers,omitempty"` - Movies []SceneMovie `json:"movies,omitempty"` - Tags []string `json:"tags,omitempty"` - Markers []SceneMarker `json:"markers,omitempty"` - Files []string `json:"files,omitempty"` - Cover string `json:"cover,omitempty"` - CreatedAt json.JSONTime `json:"created_at,omitempty"` - UpdatedAt json.JSONTime `json:"updated_at,omitempty"` - StashIDs []models.StashID `json:"stash_ids,omitempty"` + Title string `json:"title,omitempty"` + Code string `json:"code,omitempty"` + Studio string `json:"studio,omitempty"` + URL string `json:"url,omitempty"` + Date string `json:"date,omitempty"` + Rating int `json:"rating,omitempty"` + Organized bool `json:"organized,omitempty"` + OCounter int `json:"o_counter,omitempty"` + Details string `json:"details,omitempty"` + Director string `json:"director,omitempty"` + Galleries []GalleryRef `json:"galleries,omitempty"` + Performers []string `json:"performers,omitempty"` + Movies []SceneMovie `json:"movies,omitempty"` + Tags []string `json:"tags,omitempty"` + Markers []SceneMarker `json:"markers,omitempty"` + Files []string `json:"files,omitempty"` + Cover string `json:"cover,omitempty"` + CreatedAt json.JSONTime `json:"created_at,omitempty"` + UpdatedAt json.JSONTime `json:"updated_at,omitempty"` + LastPlayedAt json.JSONTime `json:"last_played_at,omitempty"` + ResumeTime float64 `json:"resume_time,omitempty"` + PlayCount int `json:"play_count,omitempty"` + PlayDuration float64 `json:"play_duration,omitempty"` + StashIDs []models.StashID `json:"stash_ids,omitempty"` } -func (s Scene) Filename(basename string, hash string) string { +func (s Scene) Filename(id int, basename string, hash string) string { ret := fsutil.SanitiseBasename(s.Title) if ret == "" { ret = basename @@ -66,6 +73,9 @@ func (s Scene) Filename(basename string, hash string) string { if hash != "" { ret += "." + hash + } else { + // scenes may have no file and therefore no hash + ret += "." + strconv.Itoa(id) } return ret + ".json" diff --git a/pkg/models/mocks/PerformerReaderWriter.go b/pkg/models/mocks/PerformerReaderWriter.go index f3fece8e6..cf1d965fa 100644 --- a/pkg/models/mocks/PerformerReaderWriter.go +++ b/pkg/models/mocks/PerformerReaderWriter.go @@ -80,26 +80,17 @@ func (_m *PerformerReaderWriter) CountByTagID(ctx context.Context, tagID int) (i } // Create provides a mock function with given fields: ctx, newPerformer -func (_m *PerformerReaderWriter) Create(ctx context.Context, newPerformer models.Performer) (*models.Performer, error) { +func (_m *PerformerReaderWriter) Create(ctx context.Context, newPerformer *models.Performer) error { ret := _m.Called(ctx, newPerformer) - var r0 *models.Performer - if rf, ok := ret.Get(0).(func(context.Context, models.Performer) *models.Performer); ok { + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, *models.Performer) error); ok { r0 = rf(ctx, newPerformer) } else { - if ret.Get(0) != nil { - r0 = ret.Get(0).(*models.Performer) - } + r0 = ret.Error(0) } - var r1 error - if rf, ok := ret.Get(1).(func(context.Context, models.Performer) error); ok { - r1 = rf(ctx, newPerformer) - } else { - r1 = ret.Error(1) - } - - return r0, r1 + return r0 } // Destroy provides a mock function with given fields: ctx, id @@ -314,29 +305,6 @@ func (_m *PerformerReaderWriter) FindMany(ctx context.Context, ids []int) ([]*mo return r0, r1 } -// FindNamesBySceneID provides a mock function with given fields: ctx, sceneID -func (_m *PerformerReaderWriter) FindNamesBySceneID(ctx context.Context, sceneID int) ([]*models.Performer, error) { - ret := _m.Called(ctx, sceneID) - - var r0 []*models.Performer - if rf, ok := ret.Get(0).(func(context.Context, int) []*models.Performer); ok { - r0 = rf(ctx, sceneID) - } else { - if ret.Get(0) != nil { - r0 = ret.Get(0).([]*models.Performer) - } - } - - var r1 error - if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { - r1 = rf(ctx, sceneID) - } else { - r1 = ret.Error(1) - } - - return r0, r1 -} - // GetImage provides a mock function with given fields: ctx, performerID func (_m *PerformerReaderWriter) GetImage(ctx context.Context, performerID int) ([]byte, error) { ret := _m.Called(ctx, performerID) @@ -460,49 +428,17 @@ func (_m *PerformerReaderWriter) QueryForAutoTag(ctx context.Context, words []st } // Update provides a mock function with given fields: ctx, updatedPerformer -func (_m *PerformerReaderWriter) Update(ctx context.Context, updatedPerformer models.PerformerPartial) (*models.Performer, error) { +func (_m *PerformerReaderWriter) Update(ctx context.Context, updatedPerformer *models.Performer) error { ret := _m.Called(ctx, updatedPerformer) - var r0 *models.Performer - if rf, ok := ret.Get(0).(func(context.Context, models.PerformerPartial) *models.Performer); ok { + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, *models.Performer) error); ok { r0 = rf(ctx, updatedPerformer) } else { - if ret.Get(0) != nil { - r0 = ret.Get(0).(*models.Performer) - } + r0 = ret.Error(0) } - var r1 error - if rf, ok := ret.Get(1).(func(context.Context, models.PerformerPartial) error); ok { - r1 = rf(ctx, updatedPerformer) - } else { - r1 = ret.Error(1) - } - - return r0, r1 -} - -// UpdateFull provides a mock function with given fields: ctx, updatedPerformer -func (_m *PerformerReaderWriter) UpdateFull(ctx context.Context, updatedPerformer models.Performer) (*models.Performer, error) { - ret := _m.Called(ctx, updatedPerformer) - - var r0 *models.Performer - if rf, ok := ret.Get(0).(func(context.Context, models.Performer) *models.Performer); ok { - r0 = rf(ctx, updatedPerformer) - } else { - if ret.Get(0) != nil { - r0 = ret.Get(0).(*models.Performer) - } - } - - var r1 error - if rf, ok := ret.Get(1).(func(context.Context, models.Performer) error); ok { - r1 = rf(ctx, updatedPerformer) - } else { - r1 = ret.Error(1) - } - - return r0, r1 + return r0 } // UpdateImage provides a mock function with given fields: ctx, performerID, image @@ -519,6 +455,29 @@ func (_m *PerformerReaderWriter) UpdateImage(ctx context.Context, performerID in return r0 } +// UpdatePartial provides a mock function with given fields: ctx, id, updatedPerformer +func (_m *PerformerReaderWriter) UpdatePartial(ctx context.Context, id int, updatedPerformer models.PerformerPartial) (*models.Performer, error) { + ret := _m.Called(ctx, id, updatedPerformer) + + var r0 *models.Performer + if rf, ok := ret.Get(0).(func(context.Context, int, models.PerformerPartial) *models.Performer); ok { + r0 = rf(ctx, id, updatedPerformer) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*models.Performer) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, int, models.PerformerPartial) error); ok { + r1 = rf(ctx, id, updatedPerformer) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // UpdateStashIDs provides a mock function with given fields: ctx, performerID, stashIDs func (_m *PerformerReaderWriter) UpdateStashIDs(ctx context.Context, performerID int, stashIDs []models.StashID) error { ret := _m.Called(ctx, performerID, stashIDs) diff --git a/pkg/models/mocks/SceneReaderWriter.go b/pkg/models/mocks/SceneReaderWriter.go index 87b253686..74ad7dc4b 100644 --- a/pkg/models/mocks/SceneReaderWriter.go +++ b/pkg/models/mocks/SceneReaderWriter.go @@ -638,6 +638,48 @@ func (_m *SceneReaderWriter) GetTagIDs(ctx context.Context, relatedID int) ([]in return r0, r1 } +// SaveActivity provides a mock function with given fields: ctx, id, resumeTime, playDuration +func (_m *SceneReaderWriter) SaveActivity(ctx context.Context, id int, resumeTime *float64, playDuration *float64) (bool, error) { + ret := _m.Called(ctx, id, resumeTime, playDuration) + + var r0 bool + if rf, ok := ret.Get(0).(func(context.Context, int, *float64, *float64) bool); ok { + r0 = rf(ctx, id, resumeTime, playDuration) + } else { + r0 = ret.Get(0).(bool) + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, int, *float64, *float64) error); ok { + r1 = rf(ctx, id, resumeTime, playDuration) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// IncrementWatchCount provides a mock function with given fields: ctx, id +func (_m *SceneReaderWriter) IncrementWatchCount(ctx context.Context, id int) (int, error) { + ret := _m.Called(ctx, id) + + var r0 int + if rf, ok := ret.Get(0).(func(context.Context, int) int); ok { + r0 = rf(ctx, id) + } else { + r0 = ret.Get(0).(int) + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { + r1 = rf(ctx, id) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // IncrementOCounter provides a mock function with given fields: ctx, id func (_m *SceneReaderWriter) IncrementOCounter(ctx context.Context, id int) (int, error) { ret := _m.Called(ctx, id) diff --git a/pkg/models/mocks/transaction.go b/pkg/models/mocks/transaction.go index 0690ae419..f2c4c9c49 100644 --- a/pkg/models/mocks/transaction.go +++ b/pkg/models/mocks/transaction.go @@ -9,7 +9,7 @@ import ( type TxnManager struct{} -func (*TxnManager) Begin(ctx context.Context) (context.Context, error) { +func (*TxnManager) Begin(ctx context.Context, exclusive bool) (context.Context, error) { return ctx, nil } @@ -25,6 +25,9 @@ func (*TxnManager) Rollback(ctx context.Context) error { return nil } +func (*TxnManager) Complete(ctx context.Context) { +} + func (*TxnManager) AddPostCommitHook(ctx context.Context, hook txn.TxnFunc) { } diff --git a/pkg/models/model_gallery.go b/pkg/models/model_gallery.go index c9cc35a75..932d5cd17 100644 --- a/pkg/models/model_gallery.go +++ b/pkg/models/model_gallery.go @@ -2,6 +2,7 @@ package models import ( "context" + "path/filepath" "strconv" "time" @@ -11,13 +12,14 @@ import ( type Gallery struct { ID int `json:"id"` - Title string `json:"title"` - URL string `json:"url"` - Date *Date `json:"date"` - Details string `json:"details"` - Rating *int `json:"rating"` - Organized bool `json:"organized"` - StudioID *int `json:"studio_id"` + Title string `json:"title"` + URL string `json:"url"` + Date *Date `json:"date"` + Details string `json:"details"` + // Rating expressed in 1-100 scale + Rating *int `json:"rating"` + Organized bool `json:"organized"` + StudioID *int `json:"studio_id"` // transient - not persisted Files RelatedFiles @@ -36,6 +38,12 @@ type Gallery struct { PerformerIDs RelatedIDs `json:"performer_ids"` } +// IsUserCreated returns true if the gallery was created by the user. +// This is determined by whether the gallery has a primary file or folder. +func (g *Gallery) IsUserCreated() bool { + return g.PrimaryFileID == nil && g.FolderID == nil +} + func (g *Gallery) LoadFiles(ctx context.Context, l FileLoader) error { return g.Files.load(func() ([]file.File, error) { return l.GetFiles(ctx, g.ID) @@ -97,10 +105,11 @@ type GalleryPartial struct { // Path OptionalString // Checksum OptionalString // Zip OptionalBool - Title OptionalString - URL OptionalString - Date OptionalDate - Details OptionalString + Title OptionalString + URL OptionalString + Date OptionalDate + Details OptionalString + // Rating expressed in 1-100 scale Rating OptionalInt Organized OptionalBool StudioID OptionalInt @@ -128,7 +137,7 @@ func (g Gallery) GetTitle() string { return g.Title } - return g.Path + return filepath.Base(g.Path) } // DisplayName returns a display name for the scene for logging purposes. diff --git a/pkg/models/model_image.go b/pkg/models/model_image.go index 377e0cc5a..dcece55bb 100644 --- a/pkg/models/model_image.go +++ b/pkg/models/model_image.go @@ -14,11 +14,12 @@ import ( type Image struct { ID int `json:"id"` - Title string `json:"title"` - Rating *int `json:"rating"` - Organized bool `json:"organized"` - OCounter int `json:"o_counter"` - StudioID *int `json:"studio_id"` + Title string `json:"title"` + // Rating expressed in 1-100 scale + Rating *int `json:"rating"` + Organized bool `json:"organized"` + OCounter int `json:"o_counter"` + StudioID *int `json:"studio_id"` // transient - not persisted Files RelatedImageFiles @@ -113,7 +114,8 @@ type ImageCreateInput struct { } type ImagePartial struct { - Title OptionalString + Title OptionalString + // Rating expressed in 1-100 scale Rating OptionalInt Organized OptionalBool OCounter OptionalInt diff --git a/pkg/models/model_joins.go b/pkg/models/model_joins.go index bcd47c9a9..5fe8b7fa5 100644 --- a/pkg/models/model_joins.go +++ b/pkg/models/model_joins.go @@ -41,21 +41,43 @@ func (u *UpdateMovieIDs) SceneMovieInputs() []*SceneMovieInput { return ret } +func (u *UpdateMovieIDs) AddUnique(v MoviesScenes) { + for _, vv := range u.Movies { + if vv.MovieID == v.MovieID { + return + } + } + + u.Movies = append(u.Movies, v) +} + func UpdateMovieIDsFromInput(i []*SceneMovieInput) (*UpdateMovieIDs, error) { ret := &UpdateMovieIDs{ Mode: RelationshipUpdateModeSet, } - for _, v := range i { + var err error + ret.Movies, err = MoviesScenesFromInput(i) + if err != nil { + return nil, err + } + + return ret, nil +} + +func MoviesScenesFromInput(input []*SceneMovieInput) ([]MoviesScenes, error) { + ret := make([]MoviesScenes, len(input)) + + for i, v := range input { mID, err := strconv.Atoi(v.MovieID) if err != nil { return nil, fmt.Errorf("invalid movie ID: %s", v.MovieID) } - ret.Movies = append(ret.Movies, MoviesScenes{ + ret[i] = MoviesScenes{ MovieID: mID, SceneIndex: v.SceneIndex, - }) + } } return ret, nil diff --git a/pkg/models/model_movie.go b/pkg/models/model_movie.go index b2e2631e3..756a6c936 100644 --- a/pkg/models/model_movie.go +++ b/pkg/models/model_movie.go @@ -8,12 +8,13 @@ import ( ) type Movie struct { - ID int `db:"id" json:"id"` - Checksum string `db:"checksum" json:"checksum"` - Name sql.NullString `db:"name" json:"name"` - Aliases sql.NullString `db:"aliases" json:"aliases"` - Duration sql.NullInt64 `db:"duration" json:"duration"` - Date SQLiteDate `db:"date" json:"date"` + ID int `db:"id" json:"id"` + Checksum string `db:"checksum" json:"checksum"` + Name sql.NullString `db:"name" json:"name"` + Aliases sql.NullString `db:"aliases" json:"aliases"` + Duration sql.NullInt64 `db:"duration" json:"duration"` + Date SQLiteDate `db:"date" json:"date"` + // Rating expressed in 1-100 scale Rating sql.NullInt64 `db:"rating" json:"rating"` StudioID sql.NullInt64 `db:"studio_id,omitempty" json:"studio_id"` Director sql.NullString `db:"director" json:"director"` @@ -24,12 +25,13 @@ type Movie struct { } type MoviePartial struct { - ID int `db:"id" json:"id"` - Checksum *string `db:"checksum" json:"checksum"` - Name *sql.NullString `db:"name" json:"name"` - Aliases *sql.NullString `db:"aliases" json:"aliases"` - Duration *sql.NullInt64 `db:"duration" json:"duration"` - Date *SQLiteDate `db:"date" json:"date"` + ID int `db:"id" json:"id"` + Checksum *string `db:"checksum" json:"checksum"` + Name *sql.NullString `db:"name" json:"name"` + Aliases *sql.NullString `db:"aliases" json:"aliases"` + Duration *sql.NullInt64 `db:"duration" json:"duration"` + Date *SQLiteDate `db:"date" json:"date"` + // Rating expressed in 1-100 scale Rating *sql.NullInt64 `db:"rating" json:"rating"` StudioID *sql.NullInt64 `db:"studio_id,omitempty" json:"studio_id"` Director *sql.NullString `db:"director" json:"director"` diff --git a/pkg/models/model_performer.go b/pkg/models/model_performer.go index 0f10344f7..18c864fc4 100644 --- a/pkg/models/model_performer.go +++ b/pkg/models/model_performer.go @@ -1,80 +1,89 @@ package models import ( - "database/sql" "time" "github.com/stashapp/stash/pkg/hash/md5" ) type Performer struct { - ID int `db:"id" json:"id"` - Checksum string `db:"checksum" json:"checksum"` - Name sql.NullString `db:"name" json:"name"` - Gender sql.NullString `db:"gender" json:"gender"` - URL sql.NullString `db:"url" json:"url"` - Twitter sql.NullString `db:"twitter" json:"twitter"` - Instagram sql.NullString `db:"instagram" json:"instagram"` - Birthdate SQLiteDate `db:"birthdate" json:"birthdate"` - Ethnicity sql.NullString `db:"ethnicity" json:"ethnicity"` - Country sql.NullString `db:"country" json:"country"` - EyeColor sql.NullString `db:"eye_color" json:"eye_color"` - Height sql.NullString `db:"height" json:"height"` - Measurements sql.NullString `db:"measurements" json:"measurements"` - FakeTits sql.NullString `db:"fake_tits" json:"fake_tits"` - CareerLength sql.NullString `db:"career_length" json:"career_length"` - Tattoos sql.NullString `db:"tattoos" json:"tattoos"` - Piercings sql.NullString `db:"piercings" json:"piercings"` - Aliases sql.NullString `db:"aliases" json:"aliases"` - Favorite sql.NullBool `db:"favorite" json:"favorite"` - CreatedAt SQLiteTimestamp `db:"created_at" json:"created_at"` - UpdatedAt SQLiteTimestamp `db:"updated_at" json:"updated_at"` - Rating sql.NullInt64 `db:"rating" json:"rating"` - Details sql.NullString `db:"details" json:"details"` - DeathDate SQLiteDate `db:"death_date" json:"death_date"` - HairColor sql.NullString `db:"hair_color" json:"hair_color"` - Weight sql.NullInt64 `db:"weight" json:"weight"` - IgnoreAutoTag bool `db:"ignore_auto_tag" json:"ignore_auto_tag"` + ID int `json:"id"` + Checksum string `json:"checksum"` + Name string `json:"name"` + Gender GenderEnum `json:"gender"` + URL string `json:"url"` + Twitter string `json:"twitter"` + Instagram string `json:"instagram"` + Birthdate *Date `json:"birthdate"` + Ethnicity string `json:"ethnicity"` + Country string `json:"country"` + EyeColor string `json:"eye_color"` + Height *int `json:"height"` + Measurements string `json:"measurements"` + FakeTits string `json:"fake_tits"` + CareerLength string `json:"career_length"` + Tattoos string `json:"tattoos"` + Piercings string `json:"piercings"` + Aliases string `json:"aliases"` + Favorite bool `json:"favorite"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + // Rating expressed in 1-100 scale + Rating *int `json:"rating"` + Details string `json:"details"` + DeathDate *Date `json:"death_date"` + HairColor string `json:"hair_color"` + Weight *int `json:"weight"` + IgnoreAutoTag bool `json:"ignore_auto_tag"` } +// PerformerPartial represents part of a Performer object. It is used to update +// the database entry. type PerformerPartial struct { - ID int `db:"id" json:"id"` - Checksum *string `db:"checksum" json:"checksum"` - Name *sql.NullString `db:"name" json:"name"` - Gender *sql.NullString `db:"gender" json:"gender"` - URL *sql.NullString `db:"url" json:"url"` - Twitter *sql.NullString `db:"twitter" json:"twitter"` - Instagram *sql.NullString `db:"instagram" json:"instagram"` - Birthdate *SQLiteDate `db:"birthdate" json:"birthdate"` - Ethnicity *sql.NullString `db:"ethnicity" json:"ethnicity"` - Country *sql.NullString `db:"country" json:"country"` - EyeColor *sql.NullString `db:"eye_color" json:"eye_color"` - Height *sql.NullString `db:"height" json:"height"` - Measurements *sql.NullString `db:"measurements" json:"measurements"` - FakeTits *sql.NullString `db:"fake_tits" json:"fake_tits"` - CareerLength *sql.NullString `db:"career_length" json:"career_length"` - Tattoos *sql.NullString `db:"tattoos" json:"tattoos"` - Piercings *sql.NullString `db:"piercings" json:"piercings"` - Aliases *sql.NullString `db:"aliases" json:"aliases"` - Favorite *sql.NullBool `db:"favorite" json:"favorite"` - CreatedAt *SQLiteTimestamp `db:"created_at" json:"created_at"` - UpdatedAt *SQLiteTimestamp `db:"updated_at" json:"updated_at"` - Rating *sql.NullInt64 `db:"rating" json:"rating"` - Details *sql.NullString `db:"details" json:"details"` - DeathDate *SQLiteDate `db:"death_date" json:"death_date"` - HairColor *sql.NullString `db:"hair_color" json:"hair_color"` - Weight *sql.NullInt64 `db:"weight" json:"weight"` - IgnoreAutoTag *bool `db:"ignore_auto_tag" json:"ignore_auto_tag"` + ID int + Checksum OptionalString + Name OptionalString + Gender OptionalString + URL OptionalString + Twitter OptionalString + Instagram OptionalString + Birthdate OptionalDate + Ethnicity OptionalString + Country OptionalString + EyeColor OptionalString + Height OptionalInt + Measurements OptionalString + FakeTits OptionalString + CareerLength OptionalString + Tattoos OptionalString + Piercings OptionalString + Aliases OptionalString + Favorite OptionalBool + CreatedAt OptionalTime + UpdatedAt OptionalTime + // Rating expressed in 1-100 scale + Rating OptionalInt + Details OptionalString + DeathDate OptionalDate + HairColor OptionalString + Weight OptionalInt + IgnoreAutoTag OptionalBool } func NewPerformer(name string) *Performer { currentTime := time.Now() return &Performer{ Checksum: md5.FromString(name), - Name: sql.NullString{String: name, Valid: true}, - Favorite: sql.NullBool{Bool: false, Valid: true}, - CreatedAt: SQLiteTimestamp{Timestamp: currentTime}, - UpdatedAt: SQLiteTimestamp{Timestamp: currentTime}, + Name: name, + CreatedAt: currentTime, + UpdatedAt: currentTime, + } +} + +func NewPerformerPartial() PerformerPartial { + updatedTime := time.Now() + return PerformerPartial{ + UpdatedAt: NewOptionalTime(updatedTime), } } diff --git a/pkg/models/model_scene.go b/pkg/models/model_scene.go index 3249f1785..79c865ed2 100644 --- a/pkg/models/model_scene.go +++ b/pkg/models/model_scene.go @@ -12,15 +12,18 @@ import ( // Scene stores the metadata for a single video scene. type Scene struct { - ID int `json:"id"` - Title string `json:"title"` - Details string `json:"details"` - URL string `json:"url"` - Date *Date `json:"date"` - Rating *int `json:"rating"` - Organized bool `json:"organized"` - OCounter int `json:"o_counter"` - StudioID *int `json:"studio_id"` + ID int `json:"id"` + Title string `json:"title"` + Code string `json:"code"` + Details string `json:"details"` + Director string `json:"director"` + URL string `json:"url"` + Date *Date `json:"date"` + // Rating expressed in 1-100 scale + Rating *int `json:"rating"` + Organized bool `json:"organized"` + OCounter int `json:"o_counter"` + StudioID *int `json:"studio_id"` // transient - not persisted Files RelatedVideoFiles @@ -35,6 +38,11 @@ type Scene struct { CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` + LastPlayedAt *time.Time `json:"last_played_at"` + ResumeTime float64 `json:"resume_time"` + PlayDuration float64 `json:"play_duration"` + PlayCount int `json:"play_count"` + GalleryIDs RelatedIDs `json:"gallery_ids"` TagIDs RelatedIDs `json:"tag_ids"` PerformerIDs RelatedIDs `json:"performer_ids"` @@ -132,16 +140,23 @@ func (s *Scene) LoadRelationships(ctx context.Context, l SceneReader) error { // ScenePartial represents part of a Scene object. It is used to update // the database entry. type ScenePartial struct { - Title OptionalString - Details OptionalString - URL OptionalString - Date OptionalDate - Rating OptionalInt - Organized OptionalBool - OCounter OptionalInt - StudioID OptionalInt - CreatedAt OptionalTime - UpdatedAt OptionalTime + Title OptionalString + Code OptionalString + Details OptionalString + Director OptionalString + URL OptionalString + Date OptionalDate + // Rating expressed in 1-100 scale + Rating OptionalInt + Organized OptionalBool + OCounter OptionalInt + StudioID OptionalInt + CreatedAt OptionalTime + UpdatedAt OptionalTime + ResumeTime OptionalFloat64 + PlayDuration OptionalFloat64 + PlayCount OptionalInt + LastPlayedAt OptionalTime GalleryIDs *UpdateIDs TagIDs *UpdateIDs @@ -164,22 +179,31 @@ type SceneMovieInput struct { } type SceneUpdateInput struct { - ClientMutationID *string `json:"clientMutationId"` - ID string `json:"id"` - Title *string `json:"title"` - Details *string `json:"details"` - URL *string `json:"url"` - Date *string `json:"date"` - Rating *int `json:"rating"` - Organized *bool `json:"organized"` - StudioID *string `json:"studio_id"` - GalleryIds []string `json:"gallery_ids"` - PerformerIds []string `json:"performer_ids"` - Movies []*SceneMovieInput `json:"movies"` - TagIds []string `json:"tag_ids"` + ClientMutationID *string `json:"clientMutationId"` + ID string `json:"id"` + Title *string `json:"title"` + Code *string `json:"code"` + Details *string `json:"details"` + Director *string `json:"director"` + URL *string `json:"url"` + Date *string `json:"date"` + // Rating expressed in 1-5 scale + Rating *int `json:"rating"` + // Rating expressed in 1-100 scale + Rating100 *int `json:"rating100"` + OCounter *int `json:"o_counter"` + Organized *bool `json:"organized"` + StudioID *string `json:"studio_id"` + GalleryIds []string `json:"gallery_ids"` + PerformerIds []string `json:"performer_ids"` + Movies []*SceneMovieInput `json:"movies"` + TagIds []string `json:"tag_ids"` // This should be a URL or a base64 encoded data URL CoverImage *string `json:"cover_image"` StashIds []StashID `json:"stash_ids"` + ResumeTime *float64 `json:"resume_time"` + PlayDuration *float64 `json:"play_duration"` + PlayCount *int `json:"play_count"` PrimaryFileID *string `json:"primary_file_id"` } @@ -197,13 +221,15 @@ func (s ScenePartial) UpdateInput(id int) SceneUpdateInput { stashIDs = s.StashIDs.StashIDs } - return SceneUpdateInput{ + ret := SceneUpdateInput{ ID: strconv.Itoa(id), Title: s.Title.Ptr(), + Code: s.Code.Ptr(), Details: s.Details.Ptr(), + Director: s.Director.Ptr(), URL: s.URL.Ptr(), Date: dateStr, - Rating: s.Rating.Ptr(), + Rating100: s.Rating.Ptr(), Organized: s.Organized.Ptr(), StudioID: s.StudioID.StringPtr(), GalleryIds: s.GalleryIDs.IDStrings(), @@ -212,6 +238,14 @@ func (s ScenePartial) UpdateInput(id int) SceneUpdateInput { TagIds: s.TagIDs.IDStrings(), StashIds: stashIDs, } + + if s.Rating.Set && !s.Rating.Null { + // convert to 1-100 scale + rating := Rating100To5(s.Rating.Value) + ret.Rating = &rating + } + + return ret } // GetTitle returns the title of the scene. If the Title field is empty, diff --git a/pkg/models/model_scene_test.go b/pkg/models/model_scene_test.go index e4f1e37ac..910991971 100644 --- a/pkg/models/model_scene_test.go +++ b/pkg/models/model_scene_test.go @@ -12,14 +12,17 @@ func TestScenePartial_UpdateInput(t *testing.T) { ) var ( - title = "title" - details = "details" - url = "url" - date = "2001-02-03" - rating = 4 - organized = true - studioID = 2 - studioIDStr = "2" + title = "title" + code = "1337" + details = "details" + director = "director" + url = "url" + date = "2001-02-03" + ratingLegacy = 4 + rating100 = 80 + organized = true + studioID = 2 + studioIDStr = "2" ) dateObj := NewDate(date) @@ -35,20 +38,25 @@ func TestScenePartial_UpdateInput(t *testing.T) { id, ScenePartial{ Title: NewOptionalString(title), + Code: NewOptionalString(code), Details: NewOptionalString(details), + Director: NewOptionalString(director), URL: NewOptionalString(url), Date: NewOptionalDate(dateObj), - Rating: NewOptionalInt(rating), + Rating: NewOptionalInt(rating100), Organized: NewOptionalBool(organized), StudioID: NewOptionalInt(studioID), }, SceneUpdateInput{ ID: idStr, Title: &title, + Code: &code, Details: &details, + Director: &director, URL: &url, Date: &date, - Rating: &rating, + Rating: &ratingLegacy, + Rating100: &rating100, Organized: &organized, StudioID: &studioIDStr, }, diff --git a/pkg/models/model_scraped_item.go b/pkg/models/model_scraped_item.go index 9563fbfd2..8475b0b35 100644 --- a/pkg/models/model_scraped_item.go +++ b/pkg/models/model_scraped_item.go @@ -79,7 +79,9 @@ func (ScrapedMovie) IsScrapedContent() {} type ScrapedItem struct { ID int `db:"id" json:"id"` Title sql.NullString `db:"title" json:"title"` + Code sql.NullString `db:"code" json:"code"` Description sql.NullString `db:"description" json:"description"` + Director sql.NullString `db:"director" json:"director"` URL sql.NullString `db:"url" json:"url"` Date SQLiteDate `db:"date" json:"date"` Rating sql.NullString `db:"rating" json:"rating"` diff --git a/pkg/models/model_studio.go b/pkg/models/model_studio.go index 55b0e03aa..51a8d332c 100644 --- a/pkg/models/model_studio.go +++ b/pkg/models/model_studio.go @@ -8,29 +8,31 @@ import ( ) type Studio struct { - ID int `db:"id" json:"id"` - Checksum string `db:"checksum" json:"checksum"` - Name sql.NullString `db:"name" json:"name"` - URL sql.NullString `db:"url" json:"url"` - ParentID sql.NullInt64 `db:"parent_id,omitempty" json:"parent_id"` - CreatedAt SQLiteTimestamp `db:"created_at" json:"created_at"` - UpdatedAt SQLiteTimestamp `db:"updated_at" json:"updated_at"` - Rating sql.NullInt64 `db:"rating" json:"rating"` - Details sql.NullString `db:"details" json:"details"` - IgnoreAutoTag bool `db:"ignore_auto_tag" json:"ignore_auto_tag"` + ID int `db:"id" json:"id"` + Checksum string `db:"checksum" json:"checksum"` + Name sql.NullString `db:"name" json:"name"` + URL sql.NullString `db:"url" json:"url"` + ParentID sql.NullInt64 `db:"parent_id,omitempty" json:"parent_id"` + CreatedAt SQLiteTimestamp `db:"created_at" json:"created_at"` + UpdatedAt SQLiteTimestamp `db:"updated_at" json:"updated_at"` + // Rating expressed in 1-100 scale + Rating sql.NullInt64 `db:"rating" json:"rating"` + Details sql.NullString `db:"details" json:"details"` + IgnoreAutoTag bool `db:"ignore_auto_tag" json:"ignore_auto_tag"` } type StudioPartial struct { - ID int `db:"id" json:"id"` - Checksum *string `db:"checksum" json:"checksum"` - Name *sql.NullString `db:"name" json:"name"` - URL *sql.NullString `db:"url" json:"url"` - ParentID *sql.NullInt64 `db:"parent_id,omitempty" json:"parent_id"` - CreatedAt *SQLiteTimestamp `db:"created_at" json:"created_at"` - UpdatedAt *SQLiteTimestamp `db:"updated_at" json:"updated_at"` - Rating *sql.NullInt64 `db:"rating" json:"rating"` - Details *sql.NullString `db:"details" json:"details"` - IgnoreAutoTag *bool `db:"ignore_auto_tag" json:"ignore_auto_tag"` + ID int `db:"id" json:"id"` + Checksum *string `db:"checksum" json:"checksum"` + Name *sql.NullString `db:"name" json:"name"` + URL *sql.NullString `db:"url" json:"url"` + ParentID *sql.NullInt64 `db:"parent_id,omitempty" json:"parent_id"` + CreatedAt *SQLiteTimestamp `db:"created_at" json:"created_at"` + UpdatedAt *SQLiteTimestamp `db:"updated_at" json:"updated_at"` + // Rating expressed in 1-100 scale + Rating *sql.NullInt64 `db:"rating" json:"rating"` + Details *sql.NullString `db:"details" json:"details"` + IgnoreAutoTag *bool `db:"ignore_auto_tag" json:"ignore_auto_tag"` } var DefaultStudioImage = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGQAAABkCAYAAABw4pVUAAAABmJLR0QA/wD/AP+gvaeTAAAACXBIWXMAAA3XAAAN1wFCKJt4AAAAB3RJTUUH4wgVBQsJl1CMZAAAASJJREFUeNrt3N0JwyAYhlEj3cj9R3Cm5rbkqtAP+qrnGaCYHPwJpLlaa++mmLpbAERAgAgIEAEBIiBABERAgAgIEAEBIiBABERAgAgIEAHZuVflj40x4i94zhk9vqsVvEq6AsQqMP1EjORx20OACAgQRRx7T+zzcFBxcjNDfoB4ntQqTm5Awo7MlqywZxcgYQ+RlqywJ3ozJAQCSBiEJSsQA0gYBpDAgAARECACAkRAgAgIEAERECACAmSjUv6eAOSB8m8YIGGzBUjYbAESBgMkbBkDEjZbgITBAClcxiqQvEoatreYIWEBASIgJ4Gkf11ntXH3nS9uxfGWfJ5J9hAgAgJEQAQEiIAAERAgAgJEQAQEiIAAERAgAgJEQAQEiL7qBuc6RKLHxr0CAAAAAElFTkSuQmCC" diff --git a/pkg/models/movie.go b/pkg/models/movie.go index 3fc1890a6..8d58d70dd 100644 --- a/pkg/models/movie.go +++ b/pkg/models/movie.go @@ -8,8 +8,10 @@ type MovieFilterType struct { Synopsis *StringCriterionInput `json:"synopsis"` // Filter by duration (in seconds) Duration *IntCriterionInput `json:"duration"` - // Filter by rating + // Filter by rating expressed as 1-5 Rating *IntCriterionInput `json:"rating"` + // Filter by rating expressed as 1-100 + Rating100 *IntCriterionInput `json:"rating100"` // Filter to only include movies with this studio Studios *HierarchicalMultiCriterionInput `json:"studios"` // Filter to only include movies missing this property @@ -18,6 +20,12 @@ type MovieFilterType struct { URL *StringCriterionInput `json:"url"` // Filter to only include movies where performer appears in a scene Performers *MultiCriterionInput `json:"performers"` + // Filter by date + Date *DateCriterionInput `json:"date"` + // Filter by created at + CreatedAt *TimestampCriterionInput `json:"created_at"` + // Filter by updated at + UpdatedAt *TimestampCriterionInput `json:"updated_at"` } type MovieReader interface { diff --git a/pkg/models/paths/paths.go b/pkg/models/paths/paths.go index fcd029e65..612d5e2b7 100644 --- a/pkg/models/paths/paths.go +++ b/pkg/models/paths/paths.go @@ -13,13 +13,13 @@ type Paths struct { SceneMarkers *sceneMarkerPaths } -func NewPaths(generatedPath string) *Paths { +func NewPaths(generatedPath string) Paths { p := Paths{} p.Generated = newGeneratedPaths(generatedPath) p.Scene = newScenePaths(p) p.SceneMarkers = newSceneMarkerPaths(p) - return &p + return p } func GetStashHomeDirectory() string { diff --git a/pkg/models/performer.go b/pkg/models/performer.go index a00eea7fc..d5b6ea55c 100644 --- a/pkg/models/performer.go +++ b/pkg/models/performer.go @@ -79,8 +79,10 @@ type PerformerFilterType struct { Country *StringCriterionInput `json:"country"` // Filter by eye color EyeColor *StringCriterionInput `json:"eye_color"` - // Filter by height + // Filter by height - deprecated: use height_cm instead Height *StringCriterionInput `json:"height"` + // Filter by height in centimeters + HeightCm *IntCriterionInput `json:"height_cm"` // Filter by measurements Measurements *StringCriterionInput `json:"measurements"` // Filter by fake tits value @@ -109,8 +111,12 @@ type PerformerFilterType struct { GalleryCount *IntCriterionInput `json:"gallery_count"` // Filter by StashID StashID *StringCriterionInput `json:"stash_id"` - // Filter by rating + // Filter by StashID Endpoint + StashIDEndpoint *StashIDCriterionInput `json:"stash_id_endpoint"` + // Filter by rating expressed as 1-5 Rating *IntCriterionInput `json:"rating"` + // Filter by rating expressed as 1-100 + Rating100 *IntCriterionInput `json:"rating100"` // Filter by url URL *StringCriterionInput `json:"url"` // Filter by hair color @@ -123,6 +129,14 @@ type PerformerFilterType struct { Studios *HierarchicalMultiCriterionInput `json:"studios"` // Filter by autotag ignore value IgnoreAutoTag *bool `json:"ignore_auto_tag"` + // Filter by birthdate + Birthdate *DateCriterionInput `json:"birth_date"` + // Filter by death date + DeathDate *DateCriterionInput `json:"death_date"` + // Filter by created at + CreatedAt *TimestampCriterionInput `json:"created_at"` + // Filter by updated at + UpdatedAt *TimestampCriterionInput `json:"updated_at"` } type PerformerFinder interface { @@ -133,7 +147,6 @@ type PerformerReader interface { Find(ctx context.Context, id int) (*Performer, error) PerformerFinder FindBySceneID(ctx context.Context, sceneID int) ([]*Performer, error) - FindNamesBySceneID(ctx context.Context, sceneID int) ([]*Performer, error) FindByImageID(ctx context.Context, imageID int) ([]*Performer, error) FindByGalleryID(ctx context.Context, galleryID int) ([]*Performer, error) FindByNames(ctx context.Context, names []string, nocase bool) ([]*Performer, error) @@ -152,9 +165,9 @@ type PerformerReader interface { } type PerformerWriter interface { - Create(ctx context.Context, newPerformer Performer) (*Performer, error) - Update(ctx context.Context, updatedPerformer PerformerPartial) (*Performer, error) - UpdateFull(ctx context.Context, updatedPerformer Performer) (*Performer, error) + Create(ctx context.Context, newPerformer *Performer) error + UpdatePartial(ctx context.Context, id int, updatedPerformer PerformerPartial) (*Performer, error) + Update(ctx context.Context, updatedPerformer *Performer) error Destroy(ctx context.Context, id int) error UpdateImage(ctx context.Context, performerID int, image []byte) error DestroyImage(ctx context.Context, performerID int) error diff --git a/pkg/models/rating.go b/pkg/models/rating.go new file mode 100644 index 000000000..66219b50a --- /dev/null +++ b/pkg/models/rating.go @@ -0,0 +1,69 @@ +package models + +import ( + "fmt" + "io" + "math" + "strconv" +) + +type RatingSystem string + +const ( + FiveStar = "FiveStar" + FivePointFiveStar = "FivePointFiveStar" + FivePointTwoFiveStar = "FivePointTwoFiveStar" + // TenStar = "TenStar" + // TenPointFiveStar = "TenPointFiveStar" + // TenPointTwoFiveStar = "TenPointTwoFiveStar" + TenPointDecimal = "TenPointDecimal" +) + +func (e RatingSystem) IsValid() bool { + switch e { + // case FiveStar, FivePointFiveStar, FivePointTwoFiveStar, TenStar, TenPointFiveStar, TenPointTwoFiveStar, TenPointDecimal: + case FiveStar, FivePointFiveStar, FivePointTwoFiveStar, TenPointDecimal: + return true + } + return false +} + +func (e RatingSystem) String() string { + return string(e) +} + +func (e *RatingSystem) UnmarshalGQL(v interface{}) error { + str, ok := v.(string) + if !ok { + return fmt.Errorf("enums must be strings") + } + + *e = RatingSystem(str) + if !e.IsValid() { + return fmt.Errorf("%s is not a valid RatingSystem", str) + } + return nil +} + +func (e RatingSystem) MarshalGQL(w io.Writer) { + fmt.Fprint(w, strconv.Quote(e.String())) +} + +const ( + maxRating100 = 100 + maxRating5 = 5 + minRating5 = 1 + minRating100 = 20 +) + +// Rating100To5 converts a 1-100 rating to a 1-5 rating. +// Values <= 30 are converted to 1. Otherwise, rating is divided by 20 and rounded to the nearest integer. +func Rating100To5(rating100 int) int { + val := math.Round((float64(rating100) / 20)) + return int(math.Max(minRating5, math.Min(maxRating5, val))) +} + +// Rating5To100 converts a 1-5 rating to a 1-100 rating +func Rating5To100(rating5 int) int { + return int(math.Max(minRating100, math.Min(maxRating100, float64(rating5*20)))) +} diff --git a/pkg/models/rating_test.go b/pkg/models/rating_test.go new file mode 100644 index 000000000..ad04ca11d --- /dev/null +++ b/pkg/models/rating_test.go @@ -0,0 +1,55 @@ +package models + +import ( + "testing" +) + +func TestRating100To5(t *testing.T) { + tests := []struct { + name string + rating100 int + want int + }{ + {"20", 20, 1}, + {"100", 100, 5}, + {"1", 1, 1}, + {"10", 10, 1}, + {"11", 11, 1}, + {"21", 21, 1}, + {"31", 31, 2}, + {"0", 0, 1}, + {"-100", -100, 1}, + {"120", 120, 5}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := Rating100To5(tt.rating100); got != tt.want { + t.Errorf("Rating100To5() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestRating5To100(t *testing.T) { + tests := []struct { + name string + rating5 int + want int + }{ + {"1", 1, 20}, + {"5", 5, 100}, + {"2", 2, 40}, + {"3", 3, 60}, + {"4", 4, 80}, + {"6", 6, 100}, + {"0", 0, 20}, + {"-1", -1, 20}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := Rating5To100(tt.rating5); got != tt.want { + t.Errorf("Rating5To100() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/pkg/models/relationships.go b/pkg/models/relationships.go index 41bd0a69c..91453c629 100644 --- a/pkg/models/relationships.go +++ b/pkg/models/relationships.go @@ -138,6 +138,19 @@ func (r *RelatedMovies) Add(movies ...MoviesScenes) { r.list = append(r.list, movies...) } +// ForID returns the MoviesScenes object for the given movie ID. Returns nil if not found. +func (r *RelatedMovies) ForID(id int) *MoviesScenes { + r.mustLoaded() + + for _, v := range r.list { + if v.MovieID == id { + return &v + } + } + + return nil +} + func (r *RelatedMovies) load(fn func() ([]MoviesScenes, error)) error { if r.Loaded() { return nil diff --git a/pkg/models/repository.go b/pkg/models/repository.go index 45d6c0357..7a9e14af5 100644 --- a/pkg/models/repository.go +++ b/pkg/models/repository.go @@ -1,8 +1,6 @@ package models import ( - "context" - "github.com/stashapp/stash/pkg/file" "github.com/stashapp/stash/pkg/txn" ) @@ -29,7 +27,3 @@ type Repository struct { Tag TagReaderWriter SavedFilter SavedFilterReaderWriter } - -func (r *Repository) WithTxn(ctx context.Context, fn txn.TxnFunc) error { - return txn.WithTxn(ctx, r, fn) -} diff --git a/pkg/models/scene.go b/pkg/models/scene.go index e9f7a554b..3d6842943 100644 --- a/pkg/models/scene.go +++ b/pkg/models/scene.go @@ -13,11 +13,14 @@ type PHashDuplicationCriterionInput struct { } type SceneFilterType struct { - And *SceneFilterType `json:"AND"` - Or *SceneFilterType `json:"OR"` - Not *SceneFilterType `json:"NOT"` - Title *StringCriterionInput `json:"title"` - Details *StringCriterionInput `json:"details"` + And *SceneFilterType `json:"AND"` + Or *SceneFilterType `json:"OR"` + Not *SceneFilterType `json:"NOT"` + ID *IntCriterionInput `json:"id"` + Title *StringCriterionInput `json:"title"` + Code *StringCriterionInput `json:"code"` + Details *StringCriterionInput `json:"details"` + Director *StringCriterionInput `json:"director"` // Filter by file oshash Oshash *StringCriterionInput `json:"oshash"` // Filter by file checksum @@ -28,8 +31,10 @@ type SceneFilterType struct { Path *StringCriterionInput `json:"path"` // Filter by file count FileCount *IntCriterionInput `json:"file_count"` - // Filter by rating + // Filter by rating expressed as 1-5 Rating *IntCriterionInput `json:"rating"` + // Filter by rating expressed as 1-100 + Rating100 *IntCriterionInput `json:"rating100"` // Filter by organized Organized *bool `json:"organized"` // Filter by o-counter @@ -64,14 +69,28 @@ type SceneFilterType struct { PerformerCount *IntCriterionInput `json:"performer_count"` // Filter by StashID StashID *StringCriterionInput `json:"stash_id"` + // Filter by StashID Endpoint + StashIDEndpoint *StashIDCriterionInput `json:"stash_id_endpoint"` // Filter by url URL *StringCriterionInput `json:"url"` // Filter by interactive Interactive *bool `json:"interactive"` // Filter by InteractiveSpeed InteractiveSpeed *IntCriterionInput `json:"interactive_speed"` - + // Filter by captions Captions *StringCriterionInput `json:"captions"` + // Filter by resume time + ResumeTime *IntCriterionInput `json:"resume_time"` + // Filter by play count + PlayCount *IntCriterionInput `json:"play_count"` + // Filter by play duration (in seconds) + PlayDuration *IntCriterionInput `json:"play_duration"` + // Filter by date + Date *DateCriterionInput `json:"date"` + // Filter by created at + CreatedAt *TimestampCriterionInput `json:"created_at"` + // Filter by updated at + UpdatedAt *TimestampCriterionInput `json:"updated_at"` } type SceneQueryOptions struct { @@ -166,6 +185,8 @@ type SceneWriter interface { IncrementOCounter(ctx context.Context, id int) (int, error) DecrementOCounter(ctx context.Context, id int) (int, error) ResetOCounter(ctx context.Context, id int) (int, error) + SaveActivity(ctx context.Context, id int, resumeTime *float64, playDuration *float64) (bool, error) + IncrementWatchCount(ctx context.Context, id int) (int, error) Destroy(ctx context.Context, id int) error UpdateCover(ctx context.Context, sceneID int, cover []byte) error DestroyCover(ctx context.Context, sceneID int) error diff --git a/pkg/models/scene_marker.go b/pkg/models/scene_marker.go index dd0b786f6..3251f6a00 100644 --- a/pkg/models/scene_marker.go +++ b/pkg/models/scene_marker.go @@ -11,6 +11,16 @@ type SceneMarkerFilterType struct { SceneTags *HierarchicalMultiCriterionInput `json:"scene_tags"` // Filter to only include scene markers with these performers Performers *MultiCriterionInput `json:"performers"` + // Filter by created at + CreatedAt *TimestampCriterionInput `json:"created_at"` + // Filter by updated at + UpdatedAt *TimestampCriterionInput `json:"updated_at"` + // Filter by scenes date + SceneDate *DateCriterionInput `json:"scene_date"` + // Filter by scenes created at + SceneCreatedAt *TimestampCriterionInput `json:"scene_created_at"` + // Filter by scenes updated at + SceneUpdatedAt *TimestampCriterionInput `json:"scene_updated_at"` } type MarkerStringsResultType struct { diff --git a/pkg/models/stash_ids.go b/pkg/models/stash_ids.go index 448491e18..9c9cfc56a 100644 --- a/pkg/models/stash_ids.go +++ b/pkg/models/stash_ids.go @@ -9,3 +9,22 @@ type UpdateStashIDs struct { StashIDs []StashID `json:"stash_ids"` Mode RelationshipUpdateMode `json:"mode"` } + +// AddUnique adds the stash id to the list, only if the endpoint/stashid pair does not already exist in the list. +func (u *UpdateStashIDs) AddUnique(v StashID) { + for _, vv := range u.StashIDs { + if vv.StashID == v.StashID && vv.Endpoint == v.Endpoint { + return + } + } + + u.StashIDs = append(u.StashIDs, v) +} + +type StashIDCriterionInput struct { + // If present, this value is treated as a predicate. + // That is, it will filter based on stash_ids with the matching endpoint + Endpoint *string `json:"endpoint"` + StashID *string `json:"stash_id"` + Modifier CriterionModifier `json:"modifier"` +} diff --git a/pkg/models/studio.go b/pkg/models/studio.go index 50f8c12b4..26443edf7 100644 --- a/pkg/models/studio.go +++ b/pkg/models/studio.go @@ -12,10 +12,14 @@ type StudioFilterType struct { Parents *MultiCriterionInput `json:"parents"` // Filter by StashID StashID *StringCriterionInput `json:"stash_id"` + // Filter by StashID Endpoint + StashIDEndpoint *StashIDCriterionInput `json:"stash_id_endpoint"` // Filter to only include studios missing this property IsMissing *string `json:"is_missing"` - // Filter by rating + // Filter by rating expressed as 1-5 Rating *IntCriterionInput `json:"rating"` + // Filter by rating expressed as 1-100 + Rating100 *IntCriterionInput `json:"rating100"` // Filter by scene count SceneCount *IntCriterionInput `json:"scene_count"` // Filter by image count @@ -28,6 +32,10 @@ type StudioFilterType struct { Aliases *StringCriterionInput `json:"aliases"` // Filter by autotag ignore value IgnoreAutoTag *bool `json:"ignore_auto_tag"` + // Filter by created at + CreatedAt *TimestampCriterionInput `json:"created_at"` + // Filter by updated at + UpdatedAt *TimestampCriterionInput `json:"updated_at"` } type StudioFinder interface { diff --git a/pkg/models/tag.go b/pkg/models/tag.go index 57b9f55d5..440d147d3 100644 --- a/pkg/models/tag.go +++ b/pkg/models/tag.go @@ -10,6 +10,8 @@ type TagFilterType struct { Name *StringCriterionInput `json:"name"` // Filter by tag aliases Aliases *StringCriterionInput `json:"aliases"` + // Filter by tag description + Description *StringCriterionInput `json:"description"` // Filter to only include tags missing this property IsMissing *string `json:"is_missing"` // Filter by number of scenes with this tag @@ -32,6 +34,10 @@ type TagFilterType struct { ChildCount *IntCriterionInput `json:"child_count"` // Filter by autotag ignore value IgnoreAutoTag *bool `json:"ignore_auto_tag"` + // Filter by created at + CreatedAt *TimestampCriterionInput `json:"created_at"` + // Filter by updated at + UpdatedAt *TimestampCriterionInput `json:"updated_at"` } type TagFinder interface { diff --git a/pkg/models/value.go b/pkg/models/value.go index 0adff1f83..356d65293 100644 --- a/pkg/models/value.go +++ b/pkg/models/value.go @@ -23,6 +23,13 @@ func (o *OptionalString) Ptr() *string { return &v } +// Merge sets the OptionalString if it is not already set, the destination value is empty and the source value is not empty. +func (o *OptionalString) Merge(destVal string, srcVal string) { + if destVal == "" && srcVal != "" && !o.Set { + *o = NewOptionalString(srcVal) + } +} + // NewOptionalString returns a new OptionalString with the given value. func NewOptionalString(v string) OptionalString { return OptionalString{v, false, true} @@ -58,6 +65,13 @@ func (o *OptionalInt) Ptr() *int { return &v } +// MergePtr sets the OptionalInt if it is not already set, the destination value is nil and the source value is not nil. +func (o *OptionalInt) MergePtr(destVal *int, srcVal *int) { + if destVal == nil && srcVal != nil && !o.Set { + *o = NewOptionalInt(*srcVal) + } +} + // NewOptionalInt returns a new OptionalInt with the given value. func NewOptionalInt(v int) OptionalInt { return OptionalInt{v, false, true} @@ -138,6 +152,13 @@ func (o *OptionalBool) Ptr() *bool { return &v } +// Merge sets the OptionalBool to true if it is not already set, the destination value is false and the source value is true. +func (o *OptionalBool) Merge(destVal bool, srcVal bool) { + if !destVal && srcVal && !o.Set { + *o = NewOptionalBool(true) + } +} + // NewOptionalBool returns a new OptionalBool with the given value. func NewOptionalBool(v bool) OptionalBool { return OptionalBool{v, false, true} @@ -178,6 +199,18 @@ func NewOptionalFloat64(v float64) OptionalFloat64 { return OptionalFloat64{v, false, true} } +// NewOptionalFloat64 returns a new OptionalFloat64 with the given value. +func NewOptionalFloat64Ptr(v *float64) OptionalFloat64 { + if v == nil { + return OptionalFloat64{ + Null: true, + Set: true, + } + } + + return OptionalFloat64{*v, false, true} +} + // OptionalDate represents an optional date argument that may be null. See OptionalString. type OptionalDate struct { Value Date @@ -200,6 +233,13 @@ func NewOptionalDate(v Date) OptionalDate { return OptionalDate{v, false, true} } +// Merge sets the OptionalDate if it is not already set, the destination value is nil and the source value is nil. +func (o *OptionalDate) MergePtr(destVal *Date, srcVal *Date) { + if destVal == nil && srcVal != nil && !o.Set { + *o = NewOptionalDate(*srcVal) + } +} + // NewOptionalBoolPtr returns a new OptionalDate with the given value. // If the value is nil, the returned OptionalDate will be set and null. func NewOptionalDatePtr(v *Date) OptionalDate { diff --git a/pkg/performer/export.go b/pkg/performer/export.go index 9a1a9c701..90e50cb69 100644 --- a/pkg/performer/export.go +++ b/pkg/performer/export.go @@ -3,6 +3,7 @@ package performer import ( "context" "fmt" + "strconv" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models/json" @@ -18,76 +19,44 @@ type ImageStashIDGetter interface { // ToJSON converts a Performer object into its JSON equivalent. func ToJSON(ctx context.Context, reader ImageStashIDGetter, performer *models.Performer) (*jsonschema.Performer, error) { newPerformerJSON := jsonschema.Performer{ + Name: performer.Name, + Gender: performer.Gender.String(), + URL: performer.URL, + Ethnicity: performer.Ethnicity, + Country: performer.Country, + EyeColor: performer.EyeColor, + Measurements: performer.Measurements, + FakeTits: performer.FakeTits, + CareerLength: performer.CareerLength, + Tattoos: performer.Tattoos, + Piercings: performer.Piercings, + Aliases: performer.Aliases, + Twitter: performer.Twitter, + Instagram: performer.Instagram, + Favorite: performer.Favorite, + Details: performer.Details, + HairColor: performer.HairColor, IgnoreAutoTag: performer.IgnoreAutoTag, - CreatedAt: json.JSONTime{Time: performer.CreatedAt.Timestamp}, - UpdatedAt: json.JSONTime{Time: performer.UpdatedAt.Timestamp}, + CreatedAt: json.JSONTime{Time: performer.CreatedAt}, + UpdatedAt: json.JSONTime{Time: performer.UpdatedAt}, } - if performer.Name.Valid { - newPerformerJSON.Name = performer.Name.String + if performer.Birthdate != nil { + newPerformerJSON.Birthdate = performer.Birthdate.String() } - if performer.Gender.Valid { - newPerformerJSON.Gender = performer.Gender.String + if performer.Rating != nil { + newPerformerJSON.Rating = *performer.Rating } - if performer.URL.Valid { - newPerformerJSON.URL = performer.URL.String + if performer.DeathDate != nil { + newPerformerJSON.DeathDate = performer.DeathDate.String() } - if performer.Birthdate.Valid { - newPerformerJSON.Birthdate = utils.GetYMDFromDatabaseDate(performer.Birthdate.String) + + if performer.Height != nil { + newPerformerJSON.Height = strconv.Itoa(*performer.Height) } - if performer.Ethnicity.Valid { - newPerformerJSON.Ethnicity = performer.Ethnicity.String - } - if performer.Country.Valid { - newPerformerJSON.Country = performer.Country.String - } - if performer.EyeColor.Valid { - newPerformerJSON.EyeColor = performer.EyeColor.String - } - if performer.Height.Valid { - newPerformerJSON.Height = performer.Height.String - } - if performer.Measurements.Valid { - newPerformerJSON.Measurements = performer.Measurements.String - } - if performer.FakeTits.Valid { - newPerformerJSON.FakeTits = performer.FakeTits.String - } - if performer.CareerLength.Valid { - newPerformerJSON.CareerLength = performer.CareerLength.String - } - if performer.Tattoos.Valid { - newPerformerJSON.Tattoos = performer.Tattoos.String - } - if performer.Piercings.Valid { - newPerformerJSON.Piercings = performer.Piercings.String - } - if performer.Aliases.Valid { - newPerformerJSON.Aliases = performer.Aliases.String - } - if performer.Twitter.Valid { - newPerformerJSON.Twitter = performer.Twitter.String - } - if performer.Instagram.Valid { - newPerformerJSON.Instagram = performer.Instagram.String - } - if performer.Favorite.Valid { - newPerformerJSON.Favorite = performer.Favorite.Bool - } - if performer.Rating.Valid { - newPerformerJSON.Rating = int(performer.Rating.Int64) - } - if performer.Details.Valid { - newPerformerJSON.Details = performer.Details.String - } - if performer.DeathDate.Valid { - newPerformerJSON.DeathDate = utils.GetYMDFromDatabaseDate(performer.DeathDate.String) - } - if performer.HairColor.Valid { - newPerformerJSON.HairColor = performer.HairColor.String - } - if performer.Weight.Valid { - newPerformerJSON.Weight = int(performer.Weight.Int64) + + if performer.Weight != nil { + newPerformerJSON.Weight = *performer.Weight } image, err := reader.GetImage(ctx, performer.ID) @@ -126,8 +95,8 @@ func GetIDs(performers []*models.Performer) []int { func GetNames(performers []*models.Performer) []string { var results []string for _, performer := range performers { - if performer.Name.Valid { - results = append(results, performer.Name.String) + if performer.Name != "" { + results = append(results, performer.Name) } } diff --git a/pkg/performer/export_test.go b/pkg/performer/export_test.go index f328c0d8c..d3ee15d46 100644 --- a/pkg/performer/export_test.go +++ b/pkg/performer/export_test.go @@ -1,8 +1,8 @@ package performer import ( - "database/sql" "errors" + "strconv" "github.com/stashapp/stash/pkg/hash/md5" "github.com/stashapp/stash/pkg/models" @@ -22,28 +22,32 @@ const ( ) const ( - performerName = "testPerformer" - url = "url" - aliases = "aliases" - careerLength = "careerLength" - country = "country" - ethnicity = "ethnicity" - eyeColor = "eyeColor" - fakeTits = "fakeTits" - gender = "gender" - height = "height" - instagram = "instagram" - measurements = "measurements" - piercings = "piercings" - tattoos = "tattoos" - twitter = "twitter" - rating = 5 - details = "details" - hairColor = "hairColor" - weight = 60 + performerName = "testPerformer" + url = "url" + aliases = "aliases" + careerLength = "careerLength" + country = "country" + ethnicity = "ethnicity" + eyeColor = "eyeColor" + fakeTits = "fakeTits" + gender = "gender" + instagram = "instagram" + measurements = "measurements" + piercings = "piercings" + tattoos = "tattoos" + twitter = "twitter" + details = "details" + hairColor = "hairColor" + autoTagIgnored = true ) +var ( + rating = 5 + height = 123 + weight = 60 +) + var imageBytes = []byte("imageBytes") var stashID = models.StashID{ @@ -56,14 +60,8 @@ var stashIDs = []models.StashID{ const image = "aW1hZ2VCeXRlcw==" -var birthDate = models.SQLiteDate{ - String: "2001-01-01", - Valid: true, -} -var deathDate = models.SQLiteDate{ - String: "2021-02-02", - Valid: true, -} +var birthDate = models.NewDate("2001-01-01") +var deathDate = models.NewDate("2021-02-02") var ( createTime = time.Date(2001, 01, 01, 0, 0, 0, 0, time.Local) @@ -72,55 +70,41 @@ var ( func createFullPerformer(id int, name string) *models.Performer { return &models.Performer{ - ID: id, - Name: models.NullString(name), - Checksum: md5.FromString(name), - URL: models.NullString(url), - Aliases: models.NullString(aliases), - Birthdate: birthDate, - CareerLength: models.NullString(careerLength), - Country: models.NullString(country), - Ethnicity: models.NullString(ethnicity), - EyeColor: models.NullString(eyeColor), - FakeTits: models.NullString(fakeTits), - Favorite: sql.NullBool{ - Bool: true, - Valid: true, - }, - Gender: models.NullString(gender), - Height: models.NullString(height), - Instagram: models.NullString(instagram), - Measurements: models.NullString(measurements), - Piercings: models.NullString(piercings), - Tattoos: models.NullString(tattoos), - Twitter: models.NullString(twitter), - CreatedAt: models.SQLiteTimestamp{ - Timestamp: createTime, - }, - UpdatedAt: models.SQLiteTimestamp{ - Timestamp: updateTime, - }, - Rating: models.NullInt64(rating), - Details: models.NullString(details), - DeathDate: deathDate, - HairColor: models.NullString(hairColor), - Weight: sql.NullInt64{ - Int64: weight, - Valid: true, - }, + ID: id, + Name: name, + Checksum: md5.FromString(name), + URL: url, + Aliases: aliases, + Birthdate: &birthDate, + CareerLength: careerLength, + Country: country, + Ethnicity: ethnicity, + EyeColor: eyeColor, + FakeTits: fakeTits, + Favorite: true, + Gender: gender, + Height: &height, + Instagram: instagram, + Measurements: measurements, + Piercings: piercings, + Tattoos: tattoos, + Twitter: twitter, + CreatedAt: createTime, + UpdatedAt: updateTime, + Rating: &rating, + Details: details, + DeathDate: &deathDate, + HairColor: hairColor, + Weight: &weight, IgnoreAutoTag: autoTagIgnored, } } func createEmptyPerformer(id int) models.Performer { return models.Performer{ - ID: id, - CreatedAt: models.SQLiteTimestamp{ - Timestamp: createTime, - }, - UpdatedAt: models.SQLiteTimestamp{ - Timestamp: updateTime, - }, + ID: id, + CreatedAt: createTime, + UpdatedAt: updateTime, } } @@ -129,7 +113,7 @@ func createFullJSONPerformer(name string, image string) *jsonschema.Performer { Name: name, URL: url, Aliases: aliases, - Birthdate: birthDate.String, + Birthdate: birthDate.String(), CareerLength: careerLength, Country: country, Ethnicity: ethnicity, @@ -137,7 +121,7 @@ func createFullJSONPerformer(name string, image string) *jsonschema.Performer { FakeTits: fakeTits, Favorite: true, Gender: gender, - Height: height, + Height: strconv.Itoa(height), Instagram: instagram, Measurements: measurements, Piercings: piercings, @@ -152,7 +136,7 @@ func createFullJSONPerformer(name string, image string) *jsonschema.Performer { Rating: rating, Image: image, Details: details, - DeathDate: deathDate.String, + DeathDate: deathDate.String(), HairColor: hairColor, Weight: weight, StashIDs: []models.StashID{ diff --git a/pkg/performer/import.go b/pkg/performer/import.go index 7c673fb34..62c1d1b95 100644 --- a/pkg/performer/import.go +++ b/pkg/performer/import.go @@ -2,11 +2,12 @@ package performer import ( "context" - "database/sql" "fmt" + "strconv" "strings" "github.com/stashapp/stash/pkg/hash/md5" + "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models/jsonschema" "github.com/stashapp/stash/pkg/sliceutil/stringslice" @@ -16,7 +17,7 @@ import ( type NameFinderCreatorUpdater interface { NameFinderCreator - UpdateFull(ctx context.Context, updatedPerformer models.Performer) (*models.Performer, error) + Update(ctx context.Context, updatedPerformer *models.Performer) error UpdateTags(ctx context.Context, performerID int, tagIDs []int) error UpdateImage(ctx context.Context, performerID int, image []byte) error UpdateStashIDs(ctx context.Context, performerID int, stashIDs []models.StashID) error @@ -164,19 +165,19 @@ func (i *Importer) FindExistingID(ctx context.Context) (*int, error) { } func (i *Importer) Create(ctx context.Context) (*int, error) { - created, err := i.ReaderWriter.Create(ctx, i.performer) + err := i.ReaderWriter.Create(ctx, &i.performer) if err != nil { return nil, fmt.Errorf("error creating performer: %v", err) } - id := created.ID + id := i.performer.ID return &id, nil } func (i *Importer) Update(ctx context.Context, id int) error { performer := i.performer performer.ID = id - _, err := i.ReaderWriter.UpdateFull(ctx, performer) + err := i.ReaderWriter.Update(ctx, &performer) if err != nil { return fmt.Errorf("error updating existing performer: %v", err) } @@ -188,75 +189,60 @@ func performerJSONToPerformer(performerJSON jsonschema.Performer) models.Perform checksum := md5.FromString(performerJSON.Name) newPerformer := models.Performer{ + Name: performerJSON.Name, Checksum: checksum, - Favorite: sql.NullBool{Bool: performerJSON.Favorite, Valid: true}, + Gender: models.GenderEnum(performerJSON.Gender), + URL: performerJSON.URL, + Ethnicity: performerJSON.Ethnicity, + Country: performerJSON.Country, + EyeColor: performerJSON.EyeColor, + Measurements: performerJSON.Measurements, + FakeTits: performerJSON.FakeTits, + CareerLength: performerJSON.CareerLength, + Tattoos: performerJSON.Tattoos, + Piercings: performerJSON.Piercings, + Aliases: performerJSON.Aliases, + Twitter: performerJSON.Twitter, + Instagram: performerJSON.Instagram, + Details: performerJSON.Details, + HairColor: performerJSON.HairColor, + Favorite: performerJSON.Favorite, IgnoreAutoTag: performerJSON.IgnoreAutoTag, - CreatedAt: models.SQLiteTimestamp{Timestamp: performerJSON.CreatedAt.GetTime()}, - UpdatedAt: models.SQLiteTimestamp{Timestamp: performerJSON.UpdatedAt.GetTime()}, + CreatedAt: performerJSON.CreatedAt.GetTime(), + UpdatedAt: performerJSON.UpdatedAt.GetTime(), } - if performerJSON.Name != "" { - newPerformer.Name = sql.NullString{String: performerJSON.Name, Valid: true} - } - if performerJSON.Gender != "" { - newPerformer.Gender = sql.NullString{String: performerJSON.Gender, Valid: true} - } - if performerJSON.URL != "" { - newPerformer.URL = sql.NullString{String: performerJSON.URL, Valid: true} - } if performerJSON.Birthdate != "" { - newPerformer.Birthdate = models.SQLiteDate{String: performerJSON.Birthdate, Valid: true} - } - if performerJSON.Ethnicity != "" { - newPerformer.Ethnicity = sql.NullString{String: performerJSON.Ethnicity, Valid: true} - } - if performerJSON.Country != "" { - newPerformer.Country = sql.NullString{String: performerJSON.Country, Valid: true} - } - if performerJSON.EyeColor != "" { - newPerformer.EyeColor = sql.NullString{String: performerJSON.EyeColor, Valid: true} - } - if performerJSON.Height != "" { - newPerformer.Height = sql.NullString{String: performerJSON.Height, Valid: true} - } - if performerJSON.Measurements != "" { - newPerformer.Measurements = sql.NullString{String: performerJSON.Measurements, Valid: true} - } - if performerJSON.FakeTits != "" { - newPerformer.FakeTits = sql.NullString{String: performerJSON.FakeTits, Valid: true} - } - if performerJSON.CareerLength != "" { - newPerformer.CareerLength = sql.NullString{String: performerJSON.CareerLength, Valid: true} - } - if performerJSON.Tattoos != "" { - newPerformer.Tattoos = sql.NullString{String: performerJSON.Tattoos, Valid: true} - } - if performerJSON.Piercings != "" { - newPerformer.Piercings = sql.NullString{String: performerJSON.Piercings, Valid: true} - } - if performerJSON.Aliases != "" { - newPerformer.Aliases = sql.NullString{String: performerJSON.Aliases, Valid: true} - } - if performerJSON.Twitter != "" { - newPerformer.Twitter = sql.NullString{String: performerJSON.Twitter, Valid: true} - } - if performerJSON.Instagram != "" { - newPerformer.Instagram = sql.NullString{String: performerJSON.Instagram, Valid: true} + d, err := utils.ParseDateStringAsTime(performerJSON.Birthdate) + if err == nil { + newPerformer.Birthdate = &models.Date{ + Time: d, + } + } } if performerJSON.Rating != 0 { - newPerformer.Rating = sql.NullInt64{Int64: int64(performerJSON.Rating), Valid: true} - } - if performerJSON.Details != "" { - newPerformer.Details = sql.NullString{String: performerJSON.Details, Valid: true} + newPerformer.Rating = &performerJSON.Rating } if performerJSON.DeathDate != "" { - newPerformer.DeathDate = models.SQLiteDate{String: performerJSON.DeathDate, Valid: true} - } - if performerJSON.HairColor != "" { - newPerformer.HairColor = sql.NullString{String: performerJSON.HairColor, Valid: true} + d, err := utils.ParseDateStringAsTime(performerJSON.DeathDate) + if err == nil { + newPerformer.DeathDate = &models.Date{ + Time: d, + } + } } + if performerJSON.Weight != 0 { - newPerformer.Weight = sql.NullInt64{Int64: int64(performerJSON.Weight), Valid: true} + newPerformer.Weight = &performerJSON.Weight + } + + if performerJSON.Height != "" { + h, err := strconv.Atoi(performerJSON.Height) + if err == nil { + newPerformer.Height = &h + } else { + logger.Warnf("error parsing height %q: %v", performerJSON.Height, err) + } } return newPerformer diff --git a/pkg/performer/import_test.go b/pkg/performer/import_test.go index 4f80a67c0..08f2c5b0c 100644 --- a/pkg/performer/import_test.go +++ b/pkg/performer/import_test.go @@ -237,11 +237,11 @@ func TestCreate(t *testing.T) { readerWriter := &mocks.PerformerReaderWriter{} performer := models.Performer{ - Name: models.NullString(performerName), + Name: performerName, } performerErr := models.Performer{ - Name: models.NullString(performerNameErr), + Name: performerNameErr, } i := Importer{ @@ -250,10 +250,11 @@ func TestCreate(t *testing.T) { } errCreate := errors.New("Create error") - readerWriter.On("Create", testCtx, performer).Return(&models.Performer{ - ID: performerID, - }, nil).Once() - readerWriter.On("Create", testCtx, performerErr).Return(nil, errCreate).Once() + readerWriter.On("Create", testCtx, &performer).Run(func(args mock.Arguments) { + arg := args.Get(1).(*models.Performer) + arg.ID = performerID + }).Return(nil).Once() + readerWriter.On("Create", testCtx, &performerErr).Return(errCreate).Once() id, err := i.Create(testCtx) assert.Equal(t, performerID, *id) @@ -271,11 +272,11 @@ func TestUpdate(t *testing.T) { readerWriter := &mocks.PerformerReaderWriter{} performer := models.Performer{ - Name: models.NullString(performerName), + Name: performerName, } performerErr := models.Performer{ - Name: models.NullString(performerNameErr), + Name: performerNameErr, } i := Importer{ @@ -287,7 +288,7 @@ func TestUpdate(t *testing.T) { // id needs to be set for the mock input performer.ID = performerID - readerWriter.On("UpdateFull", testCtx, performer).Return(nil, nil).Once() + readerWriter.On("Update", testCtx, &performer).Return(nil).Once() err := i.Update(testCtx, performerID) assert.Nil(t, err) @@ -296,7 +297,7 @@ func TestUpdate(t *testing.T) { // need to set id separately performerErr.ID = errImageID - readerWriter.On("UpdateFull", testCtx, performerErr).Return(nil, errUpdate).Once() + readerWriter.On("Update", testCtx, &performerErr).Return(errUpdate).Once() err = i.Update(testCtx, errImageID) assert.NotNil(t, err) diff --git a/pkg/performer/update.go b/pkg/performer/update.go index 5974a5eab..ed10246fa 100644 --- a/pkg/performer/update.go +++ b/pkg/performer/update.go @@ -8,5 +8,5 @@ import ( type NameFinderCreator interface { FindByNames(ctx context.Context, names []string, nocase bool) ([]*models.Performer, error) - Create(ctx context.Context, newPerformer models.Performer) (*models.Performer, error) + Create(ctx context.Context, newPerformer *models.Performer) error } diff --git a/pkg/performer/validate.go b/pkg/performer/validate.go index 374262590..4c0b5a919 100644 --- a/pkg/performer/validate.go +++ b/pkg/performer/validate.go @@ -14,11 +14,13 @@ func ValidateDeathDate(performer *models.Performer, birthdate *string, deathDate } if performer != nil { - if birthdate == nil && performer.Birthdate.Valid { - birthdate = &performer.Birthdate.String + if birthdate == nil && performer.Birthdate != nil { + s := performer.Birthdate.String() + birthdate = &s } - if deathDate == nil && performer.DeathDate.Valid { - deathDate = &performer.DeathDate.String + if deathDate == nil && performer.DeathDate != nil { + s := performer.DeathDate.String() + deathDate = &s } } diff --git a/pkg/performer/validate_test.go b/pkg/performer/validate_test.go index 33616e184..dbbb7fc98 100644 --- a/pkg/performer/validate_test.go +++ b/pkg/performer/validate_test.go @@ -16,26 +16,17 @@ func TestValidateDeathDate(t *testing.T) { date4 := "2004-01-01" empty := "" + md2 := models.NewDate(date2) + md3 := models.NewDate(date3) + emptyPerformer := models.Performer{} invalidPerformer := models.Performer{ - Birthdate: models.SQLiteDate{ - String: date3, - Valid: true, - }, - DeathDate: models.SQLiteDate{ - String: date2, - Valid: true, - }, + Birthdate: &md3, + DeathDate: &md2, } validPerformer := models.Performer{ - Birthdate: models.SQLiteDate{ - String: date2, - Valid: true, - }, - DeathDate: models.SQLiteDate{ - String: date3, - Valid: true, - }, + Birthdate: &md2, + DeathDate: &md3, } // nil values should always return nil diff --git a/pkg/scene/create.go b/pkg/scene/create.go new file mode 100644 index 000000000..83fd5e56c --- /dev/null +++ b/pkg/scene/create.go @@ -0,0 +1,76 @@ +package scene + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/stashapp/stash/pkg/file" + "github.com/stashapp/stash/pkg/logger" + "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/plugin" + "github.com/stashapp/stash/pkg/txn" +) + +func (s *Service) Create(ctx context.Context, input *models.Scene, fileIDs []file.ID, coverImage []byte) (*models.Scene, error) { + // title must be set if no files are provided + if input.Title == "" && len(fileIDs) == 0 { + return nil, errors.New("title must be set if scene has no files") + } + + now := time.Now() + newScene := *input + newScene.CreatedAt = now + newScene.UpdatedAt = now + + // don't pass the file ids since they may be already assigned + // assign them afterwards + if err := s.Repository.Create(ctx, &newScene, nil); err != nil { + return nil, fmt.Errorf("creating new scene: %w", err) + } + + for _, f := range fileIDs { + if err := s.AssignFile(ctx, newScene.ID, f); err != nil { + return nil, fmt.Errorf("assigning file %d to new scene: %w", f, err) + } + } + + if len(fileIDs) > 0 { + // assign the primary to the first + if _, err := s.Repository.UpdatePartial(ctx, newScene.ID, models.ScenePartial{ + PrimaryFileID: &fileIDs[0], + }); err != nil { + return nil, fmt.Errorf("setting primary file on new scene: %w", err) + } + } + + // re-find the scene so that it correctly returns file-related fields + ret, err := s.Repository.Find(ctx, newScene.ID) + if err != nil { + return nil, err + } + + if len(coverImage) > 0 { + if err := s.Repository.UpdateCover(ctx, ret.ID, coverImage); err != nil { + return nil, fmt.Errorf("setting cover on new scene: %w", err) + } + + // only update the cover image if provided and everything else was successful + // only do this if there is a file associated + if len(fileIDs) > 0 { + txn.AddPostCommitHook(ctx, func(ctx context.Context) error { + if err := SetScreenshot(s.Paths, ret.GetHash(s.Config.GetVideoFileNamingAlgorithm()), coverImage); err != nil { + logger.Errorf("Error setting screenshot: %v", err) + } + + return nil + }) + } + } + + s.PluginCache.RegisterPostHooks(ctx, ret.ID, plugin.SceneCreatePost, nil, nil) + + // re-find the scene so that it correctly returns file-related fields + return ret, nil +} diff --git a/pkg/scene/delete.go b/pkg/scene/delete.go index 47449f1e3..622b54377 100644 --- a/pkg/scene/delete.go +++ b/pkg/scene/delete.go @@ -128,7 +128,7 @@ type MarkerDestroyer interface { // Destroy deletes a scene and its associated relationships from the // database. func (s *Service) Destroy(ctx context.Context, scene *models.Scene, fileDeleter *FileDeleter, deleteGenerated, deleteFile bool) error { - mqb := s.MarkerDestroyer + mqb := s.MarkerRepository markers, err := mqb.FindBySceneID(ctx, scene.ID) if err != nil { return err diff --git a/pkg/scene/export.go b/pkg/scene/export.go index 343210fe6..f7426c8bd 100644 --- a/pkg/scene/export.go +++ b/pkg/scene/export.go @@ -39,8 +39,10 @@ type TagFinder interface { func ToBasicJSON(ctx context.Context, reader CoverGetter, scene *models.Scene) (*jsonschema.Scene, error) { newSceneJSON := jsonschema.Scene{ Title: scene.Title, + Code: scene.Code, URL: scene.URL, Details: scene.Details, + Director: scene.Director, CreatedAt: json.JSONTime{Time: scene.CreatedAt}, UpdatedAt: json.JSONTime{Time: scene.UpdatedAt}, } diff --git a/pkg/scene/generate/generator.go b/pkg/scene/generate/generator.go index 1caaf6799..a76c7ce84 100644 --- a/pkg/scene/generate/generator.go +++ b/pkg/scene/generate/generator.go @@ -83,6 +83,16 @@ func (g Generator) generateFile(lockCtx *fsutil.LockContext, p Paths, pattern st return err } + // check if generated empty file + stat, err := os.Stat(tmpFn) + if err != nil { + return fmt.Errorf("error getting file stat: %w", err) + } + + if stat.Size() == 0 { + return fmt.Errorf("ffmpeg command produced no output") + } + if err := fsutil.SafeMove(tmpFn, output); err != nil { return fmt.Errorf("moving %s to %s", tmpFn, output) } @@ -142,5 +152,9 @@ func (g Generator) generateOutput(lockCtx *fsutil.LockContext, args []string) ([ return nil, fmt.Errorf("error running ffmpeg command <%s>: %w", strings.Join(args, " "), err) } + if stdout.Len() == 0 { + return nil, fmt.Errorf("ffmpeg command produced no output: <%s>", strings.Join(args, " ")) + } + return stdout.Bytes(), nil } diff --git a/pkg/scene/import.go b/pkg/scene/import.go index 79d95aa04..05575a848 100644 --- a/pkg/scene/import.go +++ b/pkg/scene/import.go @@ -82,7 +82,9 @@ func (i *Importer) sceneJSONToScene(sceneJSON jsonschema.Scene) models.Scene { newScene := models.Scene{ // Path: i.Path, Title: sceneJSON.Title, + Code: sceneJSON.Code, Details: sceneJSON.Details, + Director: sceneJSON.Director, URL: sceneJSON.URL, PerformerIDs: models.NewRelatedIDs([]int{}), TagIDs: models.NewRelatedIDs([]int{}), @@ -103,6 +105,13 @@ func (i *Importer) sceneJSONToScene(sceneJSON jsonschema.Scene) models.Scene { newScene.OCounter = sceneJSON.OCounter newScene.CreatedAt = sceneJSON.CreatedAt.GetTime() newScene.UpdatedAt = sceneJSON.UpdatedAt.GetTime() + if !sceneJSON.LastPlayedAt.IsZero() { + t := sceneJSON.LastPlayedAt.GetTime() + newScene.LastPlayedAt = &t + } + newScene.ResumeTime = sceneJSON.ResumeTime + newScene.PlayDuration = sceneJSON.PlayDuration + newScene.PlayCount = sceneJSON.PlayCount return newScene } @@ -231,10 +240,10 @@ func (i *Importer) populatePerformers(ctx context.Context) error { var pluckedNames []string for _, performer := range performers { - if !performer.Name.Valid { + if performer.Name == "" { continue } - pluckedNames = append(pluckedNames, performer.Name.String) + pluckedNames = append(pluckedNames, performer.Name) } missingPerformers := stringslice.StrFilter(names, func(name string) bool { @@ -271,12 +280,12 @@ func (i *Importer) createPerformers(ctx context.Context, names []string) ([]*mod for _, name := range names { newPerformer := *models.NewPerformer(name) - created, err := i.PerformerWriter.Create(ctx, newPerformer) + err := i.PerformerWriter.Create(ctx, &newPerformer) if err != nil { return nil, err } - ret = append(ret, created) + ret = append(ret, &newPerformer) } return ret, nil diff --git a/pkg/scene/import_test.go b/pkg/scene/import_test.go index 5a5fd5026..2e4d65f05 100644 --- a/pkg/scene/import_test.go +++ b/pkg/scene/import_test.go @@ -147,7 +147,7 @@ func TestImporterPreImportWithPerformer(t *testing.T) { performerReaderWriter.On("FindByNames", testCtx, []string{existingPerformerName}, false).Return([]*models.Performer{ { ID: existingPerformerID, - Name: models.NullString(existingPerformerName), + Name: existingPerformerName, }, }, nil).Once() performerReaderWriter.On("FindByNames", testCtx, []string{existingPerformerErr}, false).Return(nil, errors.New("FindByNames error")).Once() @@ -177,9 +177,10 @@ func TestImporterPreImportWithMissingPerformer(t *testing.T) { } performerReaderWriter.On("FindByNames", testCtx, []string{missingPerformerName}, false).Return(nil, nil).Times(3) - performerReaderWriter.On("Create", testCtx, mock.AnythingOfType("models.Performer")).Return(&models.Performer{ - ID: existingPerformerID, - }, nil) + performerReaderWriter.On("Create", testCtx, mock.AnythingOfType("*models.Performer")).Run(func(args mock.Arguments) { + p := args.Get(1).(*models.Performer) + p.ID = existingPerformerID + }).Return(nil) err := i.PreImport(testCtx) assert.NotNil(t, err) @@ -210,7 +211,7 @@ func TestImporterPreImportWithMissingPerformerCreateErr(t *testing.T) { } performerReaderWriter.On("FindByNames", testCtx, []string{missingPerformerName}, false).Return(nil, nil).Once() - performerReaderWriter.On("Create", testCtx, mock.AnythingOfType("models.Performer")).Return(nil, errors.New("Create error")) + performerReaderWriter.On("Create", testCtx, mock.AnythingOfType("*models.Performer")).Return(errors.New("Create error")) err := i.PreImport(testCtx) assert.NotNil(t, err) diff --git a/pkg/scene/merge.go b/pkg/scene/merge.go new file mode 100644 index 000000000..d14f9621f --- /dev/null +++ b/pkg/scene/merge.go @@ -0,0 +1,144 @@ +package scene + +import ( + "context" + "errors" + "fmt" + "os" + + "github.com/stashapp/stash/pkg/file" + "github.com/stashapp/stash/pkg/fsutil" + "github.com/stashapp/stash/pkg/logger" + "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/sliceutil/intslice" + "github.com/stashapp/stash/pkg/txn" +) + +func (s *Service) Merge(ctx context.Context, sourceIDs []int, destinationID int, scenePartial models.ScenePartial) error { + // ensure source ids are unique + sourceIDs = intslice.IntAppendUniques(nil, sourceIDs) + + // ensure destination is not in source list + if intslice.IntInclude(sourceIDs, destinationID) { + return errors.New("destination scene cannot be in source list") + } + + dest, err := s.Repository.Find(ctx, destinationID) + if err != nil { + return fmt.Errorf("finding destination scene ID %d: %w", destinationID, err) + } + + sources, err := s.Repository.FindMany(ctx, sourceIDs) + if err != nil { + return fmt.Errorf("finding source scenes: %w", err) + } + + var fileIDs []file.ID + + for _, src := range sources { + // TODO - delete generated files as needed + + if err := src.LoadRelationships(ctx, s.Repository); err != nil { + return fmt.Errorf("loading scene relationships from %d: %w", src.ID, err) + } + + for _, f := range src.Files.List() { + fileIDs = append(fileIDs, f.Base().ID) + } + + if err := s.mergeSceneMarkers(ctx, dest, src); err != nil { + return err + } + } + + // move files to destination scene + if len(fileIDs) > 0 { + if err := s.Repository.AssignFiles(ctx, destinationID, fileIDs); err != nil { + return fmt.Errorf("moving files to destination scene: %w", err) + } + + // if scene didn't already have a primary file, then set it now + if dest.PrimaryFileID == nil { + scenePartial.PrimaryFileID = &fileIDs[0] + } else { + // don't allow changing primary file ID from the input values + scenePartial.PrimaryFileID = nil + } + } + + if _, err := s.Repository.UpdatePartial(ctx, destinationID, scenePartial); err != nil { + return fmt.Errorf("updating scene: %w", err) + } + + // delete old scenes + for _, srcID := range sourceIDs { + if err := s.Repository.Destroy(ctx, srcID); err != nil { + return fmt.Errorf("deleting scene %d: %w", srcID, err) + } + } + + return nil +} + +func (s *Service) mergeSceneMarkers(ctx context.Context, dest *models.Scene, src *models.Scene) error { + markers, err := s.MarkerRepository.FindBySceneID(ctx, src.ID) + if err != nil { + return fmt.Errorf("finding scene markers: %w", err) + } + + type rename struct { + src string + dest string + } + + var toRename []rename + + destHash := dest.GetHash(s.Config.GetVideoFileNamingAlgorithm()) + + for _, m := range markers { + srcHash := src.GetHash(s.Config.GetVideoFileNamingAlgorithm()) + + // updated the scene id + m.SceneID.Int64 = int64(dest.ID) + + if _, err := s.MarkerRepository.Update(ctx, *m); err != nil { + return fmt.Errorf("updating scene marker %d: %w", m.ID, err) + } + + // move generated files to new location + toRename = append(toRename, []rename{ + { + src: s.Paths.SceneMarkers.GetScreenshotPath(srcHash, int(m.Seconds)), + dest: s.Paths.SceneMarkers.GetScreenshotPath(destHash, int(m.Seconds)), + }, + { + src: s.Paths.SceneMarkers.GetThumbnailPath(srcHash, int(m.Seconds)), + dest: s.Paths.SceneMarkers.GetThumbnailPath(destHash, int(m.Seconds)), + }, + { + src: s.Paths.SceneMarkers.GetWebpPreviewPath(srcHash, int(m.Seconds)), + dest: s.Paths.SceneMarkers.GetWebpPreviewPath(destHash, int(m.Seconds)), + }, + }...) + } + + if len(toRename) > 0 { + txn.AddPostCommitHook(ctx, func(ctx context.Context) error { + // rename the files if they exist + for _, e := range toRename { + srcExists, _ := fsutil.FileExists(e.src) + destExists, _ := fsutil.FileExists(e.dest) + + if srcExists && !destExists { + if err := os.Rename(e.src, e.dest); err != nil { + logger.Errorf("Error renaming generated marker file from %s to %s: %v", e.src, e.dest, err) + } + } + } + + return nil + }) + } + + return nil +} diff --git a/pkg/scene/query.go b/pkg/scene/query.go index 928270f38..e910f42f0 100644 --- a/pkg/scene/query.go +++ b/pkg/scene/query.go @@ -16,6 +16,7 @@ type Queryer interface { type IDFinder interface { Find(ctx context.Context, id int) (*models.Scene, error) + FindMany(ctx context.Context, ids []int) ([]*models.Scene, error) } // QueryOptions returns a SceneQueryOptions populated with the provided filters. diff --git a/pkg/scene/scan.go b/pkg/scene/scan.go index 9404a0b88..b0f9ef3d4 100644 --- a/pkg/scene/scan.go +++ b/pkg/scene/scan.go @@ -11,6 +11,7 @@ import ( "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models/paths" "github.com/stashapp/stash/pkg/plugin" + "github.com/stashapp/stash/pkg/txn" ) var ( @@ -20,7 +21,7 @@ var ( type CreatorUpdater interface { FindByFileID(ctx context.Context, fileID file.ID) ([]*models.Scene, error) FindByFingerprints(ctx context.Context, fp []file.Fingerprint) ([]*models.Scene, error) - Create(ctx context.Context, newScene *models.Scene, fileIDs []file.ID) error + Creator UpdatePartial(ctx context.Context, id int, updatedScene models.ScenePartial) (*models.Scene, error) AddFileID(ctx context.Context, id int, fileID file.ID) error models.VideoFileLoader @@ -119,17 +120,22 @@ func (h *ScanHandler) Handle(ctx context.Context, f file.File, oldFile file.File } } - for _, s := range existing { - if err := h.CoverGenerator.GenerateCover(ctx, s, videoFile); err != nil { - // just log if cover generation fails. We can try again on rescan - logger.Errorf("Error generating cover for %s: %v", videoFile.Path, err) + // do this after the commit so that cover generation doesn't hold up the transaction + txn.AddPostCommitHook(ctx, func(ctx context.Context) error { + for _, s := range existing { + if err := h.CoverGenerator.GenerateCover(ctx, s, videoFile); err != nil { + // just log if cover generation fails. We can try again on rescan + logger.Errorf("Error generating cover for %s: %v", videoFile.Path, err) + } + + if err := h.ScanGenerator.Generate(ctx, s, videoFile); err != nil { + // just log if cover generation fails. We can try again on rescan + logger.Errorf("Error generating content for %s: %v", videoFile.Path, err) + } } - if err := h.ScanGenerator.Generate(ctx, s, videoFile); err != nil { - // just log if cover generation fails. We can try again on rescan - logger.Errorf("Error generating content for %s: %v", videoFile.Path, err) - } - } + return nil + }) return nil } diff --git a/pkg/scene/screenshot.go b/pkg/scene/screenshot.go index 13464e16e..8335c53d6 100644 --- a/pkg/scene/screenshot.go +++ b/pkg/scene/screenshot.go @@ -32,6 +32,10 @@ type PathsCoverSetter struct { } func (ss *PathsCoverSetter) SetScreenshot(scene *models.Scene, imageData []byte) error { + // don't set where scene has no file + if scene.Path == "" { + return nil + } checksum := scene.GetHash(ss.FileNamingAlgorithm) return SetScreenshot(ss.Paths, checksum, imageData) } diff --git a/pkg/scene/service.go b/pkg/scene/service.go index 8d2e5dc0c..c162858af 100644 --- a/pkg/scene/service.go +++ b/pkg/scene/service.go @@ -5,20 +5,55 @@ import ( "github.com/stashapp/stash/pkg/file" "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/models/paths" + "github.com/stashapp/stash/pkg/plugin" ) type FinderByFile interface { FindByFileID(ctx context.Context, fileID file.ID) ([]*models.Scene, error) } +type FileAssigner interface { + AssignFiles(ctx context.Context, sceneID int, fileID []file.ID) error +} + +type Creator interface { + Create(ctx context.Context, newScene *models.Scene, fileIDs []file.ID) error +} + +type CoverUpdater interface { + UpdateCover(ctx context.Context, sceneID int, cover []byte) error +} + +type Config interface { + GetVideoFileNamingAlgorithm() models.HashAlgorithm +} + type Repository interface { + IDFinder FinderByFile + Creator + PartialUpdater Destroyer models.VideoFileLoader + FileAssigner + CoverUpdater + models.SceneReader +} + +type MarkerRepository interface { + MarkerFinder + MarkerDestroyer + + Update(ctx context.Context, updatedObject models.SceneMarker) (*models.SceneMarker, error) } type Service struct { - File file.Store - Repository Repository - MarkerDestroyer MarkerDestroyer + File file.Store + Repository Repository + MarkerRepository MarkerRepository + PluginCache *plugin.Cache + + Paths *paths.Paths + Config Config } diff --git a/pkg/scene/update.go b/pkg/scene/update.go index 420736020..c38597da7 100644 --- a/pkg/scene/update.go +++ b/pkg/scene/update.go @@ -6,6 +6,7 @@ import ( "fmt" "time" + "github.com/stashapp/stash/pkg/file" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/utils" ) @@ -115,3 +116,27 @@ func AddGallery(ctx context.Context, qb PartialUpdater, o *models.Scene, gallery }) return err } + +func (s *Service) AssignFile(ctx context.Context, sceneID int, fileID file.ID) error { + // ensure file isn't a primary file and that it is a video file + f, err := s.File.Find(ctx, fileID) + if err != nil { + return err + } + + ff := f[0] + if _, ok := ff.(*file.VideoFile); !ok { + return fmt.Errorf("%s is not a video file", ff.Base().Path) + } + + isPrimary, err := s.File.IsPrimary(ctx, fileID) + if err != nil { + return err + } + + if isPrimary { + return errors.New("cannot reassign primary file") + } + + return s.Repository.AssignFiles(ctx, sceneID, []file.ID{fileID}) +} diff --git a/pkg/scraper/autotag.go b/pkg/scraper/autotag.go index cbcd38cfa..53aedc749 100644 --- a/pkg/scraper/autotag.go +++ b/pkg/scraper/autotag.go @@ -38,11 +38,12 @@ func autotagMatchPerformers(ctx context.Context, path string, performerReader ma id := strconv.Itoa(pp.ID) sp := &models.ScrapedPerformer{ - Name: &pp.Name.String, + Name: &pp.Name, StoredID: &id, } - if pp.Gender.Valid { - sp.Gender = &pp.Gender.String + if pp.Gender.IsValid() { + v := pp.Gender.String() + sp.Gender = &v } ret = append(ret, sp) @@ -94,8 +95,12 @@ func (s autotagScraper) viaScene(ctx context.Context, _client *http.Client, scen const trimExt = false // populate performers, studio and tags based on scene path - if err := txn.WithTxn(ctx, s.txnManager, func(ctx context.Context) error { + if err := txn.WithReadTxn(ctx, s.txnManager, func(ctx context.Context) error { path := scene.Path + if path == "" { + return nil + } + performers, err := autotagMatchPerformers(ctx, path, s.performerReader, trimExt) if err != nil { return fmt.Errorf("autotag scraper viaScene: %w", err) @@ -139,7 +144,7 @@ func (s autotagScraper) viaGallery(ctx context.Context, _client *http.Client, ga var ret *ScrapedGallery // populate performers, studio and tags based on scene path - if err := txn.WithTxn(ctx, s.txnManager, func(ctx context.Context) error { + if err := txn.WithReadTxn(ctx, s.txnManager, func(ctx context.Context) error { path := gallery.Path performers, err := autotagMatchPerformers(ctx, path, s.performerReader, trimExt) if err != nil { diff --git a/pkg/scraper/cache.go b/pkg/scraper/cache.go index 64cd63629..894286c3c 100644 --- a/pkg/scraper/cache.go +++ b/pkg/scraper/cache.go @@ -350,7 +350,7 @@ func (c Cache) ScrapeID(ctx context.Context, scraperID string, id int, ty Scrape func (c Cache) getScene(ctx context.Context, sceneID int) (*models.Scene, error) { var ret *models.Scene - if err := txn.WithTxn(ctx, c.txnManager, func(ctx context.Context) error { + if err := txn.WithReadTxn(ctx, c.txnManager, func(ctx context.Context) error { var err error ret, err = c.repository.SceneFinder.Find(ctx, sceneID) return err @@ -362,7 +362,7 @@ func (c Cache) getScene(ctx context.Context, sceneID int) (*models.Scene, error) func (c Cache) getGallery(ctx context.Context, galleryID int) (*models.Gallery, error) { var ret *models.Gallery - if err := txn.WithTxn(ctx, c.txnManager, func(ctx context.Context) error { + if err := txn.WithReadTxn(ctx, c.txnManager, func(ctx context.Context) error { var err error ret, err = c.repository.GalleryFinder.Find(ctx, galleryID) diff --git a/pkg/scraper/country.go b/pkg/scraper/country.go new file mode 100644 index 000000000..6ef75b116 --- /dev/null +++ b/pkg/scraper/country.go @@ -0,0 +1,296 @@ +package scraper + +import ( + "strings" + + "github.com/stashapp/stash/pkg/logger" +) + +var countryNameMapping = map[string]string{ + "afghanistan": "AF", + "albania": "AL", + "algeria": "DZ", + "america": "US", + "american": "US", + "american samoa": "AS", + "andorra": "AD", + "angola": "AO", + "anguilla": "AI", + "antarctica": "AQ", + "antigua and barbuda": "AG", + "argentina": "AR", + "armenia": "AM", + "aruba": "AW", + "australia": "AU", + "austria": "AT", + "azerbaijan": "AZ", + "bahamas": "BS", + "bahrain": "BH", + "bangladesh": "BD", + "barbados": "BB", + "belarus": "BY", + "belgium": "BE", + "belize": "BZ", + "benin": "BJ", + "bermuda": "BM", + "bhutan": "BT", + "bolivia": "BO", + "bosnia and herzegovina": "BA", + "botswana": "BW", + "bouvet island": "BV", + "brazil": "BR", + "british indian ocean territory": "IO", + "brunei darussalam": "BN", + "bulgaria": "BG", + "burkina faso": "BF", + "burundi": "BI", + "cambodia": "KH", + "cameroon": "CM", + "canada": "CA", + "cape verde": "CV", + "cayman islands": "KY", + "central african republic": "CF", + "chad": "TD", + "chile": "CL", + "china": "CN", + "christmas island": "CX", + "cocos (keeling) islands": "CC", + "colombia": "CO", + "comoros": "KM", + "congo": "CG", + "congo the democratic republic of the": "CD", + "cook islands": "CK", + "costa rica": "CR", + "cote d'ivoire": "CI", + "croatia": "HR", + "cuba": "CU", + "cyprus": "CY", + "czech republic": "CZ", + "czechia": "CZ", + "denmark": "DK", + "djibouti": "DJ", + "dominica": "DM", + "dominican republic": "DO", + "ecuador": "EC", + "egypt": "EG", + "el salvador": "SV", + "equatorial guinea": "GQ", + "eritrea": "ER", + "estonia": "EE", + "ethiopia": "ET", + "falkland islands (malvinas)": "FK", + "faroe islands": "FO", + "fiji": "FJ", + "finland": "FI", + "france": "FR", + "french guiana": "GF", + "french polynesia": "PF", + "french southern territories": "TF", + "gabon": "GA", + "gambia": "GM", + "georgia": "GE", + "germany": "DE", + "ghana": "GH", + "gibraltar": "GI", + "greece": "GR", + "greenland": "GL", + "grenada": "GD", + "guadeloupe": "GP", + "guam": "GU", + "guatemala": "GT", + "guinea": "GN", + "guinea-bissau": "GW", + "guyana": "GY", + "haiti": "HT", + "heard island and mcdonald islands": "HM", + "holy see (vatican city state)": "VA", + "honduras": "HN", + "hong kong": "HK", + "hungary": "HU", + "iceland": "IS", + "india": "IN", + "indonesia": "ID", + "iran": "IR", + "iran islamic republic of": "IR", + "iraq": "IQ", + "ireland": "IE", + "israel": "IL", + "italy": "IT", + "jamaica": "JM", + "japan": "JP", + "jordan": "JO", + "kazakhstan": "KZ", + "kenya": "KE", + "kiribati": "KI", + "north korea": "KP", + "south korea": "KR", + "kuwait": "KW", + "kyrgyzstan": "KG", + "lao people's democratic republic": "LA", + "latvia": "LV", + "lebanon": "LB", + "lesotho": "LS", + "liberia": "LR", + "libya": "LY", + "liechtenstein": "LI", + "lithuania": "LT", + "luxembourg": "LU", + "macao": "MO", + "madagascar": "MG", + "malawi": "MW", + "malaysia": "MY", + "maldives": "MV", + "mali": "ML", + "malta": "MT", + "marshall islands": "MH", + "martinique": "MQ", + "mauritania": "MR", + "mauritius": "MU", + "mayotte": "YT", + "mexico": "MX", + "micronesia federated states of": "FM", + "moldova": "MD", + "moldova republic of": "MD", + "moldova, republic of": "MD", + "monaco": "MC", + "mongolia": "MN", + "montserrat": "MS", + "morocco": "MA", + "mozambique": "MZ", + "myanmar": "MM", + "namibia": "NA", + "nauru": "NR", + "nepal": "NP", + "netherlands": "NL", + "new caledonia": "NC", + "new zealand": "NZ", + "nicaragua": "NI", + "niger": "NE", + "nigeria": "NG", + "niue": "NU", + "norfolk island": "NF", + "north macedonia republic of": "MK", + "northern mariana islands": "MP", + "norway": "NO", + "oman": "OM", + "pakistan": "PK", + "palau": "PW", + "palestinian territory occupied": "PS", + "panama": "PA", + "papua new guinea": "PG", + "paraguay": "PY", + "peru": "PE", + "philippines": "PH", + "pitcairn": "PN", + "poland": "PL", + "portugal": "PT", + "puerto rico": "PR", + "qatar": "QA", + "reunion": "RE", + "romania": "RO", + "russia": "RU", + "russian federation": "RU", + "rwanda": "RW", + "saint helena": "SH", + "saint kitts and nevis": "KN", + "saint lucia": "LC", + "saint pierre and miquelon": "PM", + "saint vincent and the grenadines": "VC", + "samoa": "WS", + "san marino": "SM", + "sao tome and principe": "ST", + "saudi arabia": "SA", + "senegal": "SN", + "seychelles": "SC", + "sierra leone": "SL", + "singapore": "SG", + "slovakia": "SK", + "slovak republic": "SK", + "slovenia": "SI", + "solomon islands": "SB", + "somalia": "SO", + "south africa": "ZA", + "south georgia and the south sandwich islands": "GS", + "spain": "ES", + "sri lanka": "LK", + "sudan": "SD", + "suriname": "SR", + "svalbard and jan mayen": "SJ", + "eswatini": "SZ", + "sweden": "SE", + "switzerland": "CH", + "syrian arab republic": "SY", + "taiwan": "TW", + "tajikistan": "TJ", + "tanzania united republic of": "TZ", + "thailand": "TH", + "timor-leste": "TL", + "togo": "TG", + "tokelau": "TK", + "tonga": "TO", + "trinidad and tobago": "TT", + "tunisia": "TN", + "turkey": "TR", + "turkmenistan": "TM", + "turks and caicos islands": "TC", + "tuvalu": "TV", + "uganda": "UG", + "ukraine": "UA", + "united arab emirates": "AE", + "england": "GB", + "great britain": "GB", + "united kingdom": "GB", + "usa": "US", + "united states": "US", + "united states of america": "US", + "united states minor outlying islands": "UM", + "uruguay": "UY", + "uzbekistan": "UZ", + "vanuatu": "VU", + "venezuela": "VE", + "vietnam": "VN", + "virgin islands british": "VG", + "virgin islands u.s.": "VI", + "wallis and futuna": "WF", + "western sahara": "EH", + "yemen": "YE", + "zambia": "ZM", + "zimbabwe": "ZW", + "åland islands": "AX", + "bonaire sint eustatius and saba": "BQ", + "curaçao": "CW", + "guernsey": "GG", + "isle of man": "IM", + "jersey": "JE", + "montenegro": "ME", + "saint barthélemy": "BL", + "saint martin (french part)": "MF", + "serbia": "RS", + "sint maarten (dutch part)": "SX", + "south sudan": "SS", + "kosovo": "XK", +} + +func resolveCountryName(name *string) *string { + if name == nil { + return nil + } + + trimmedName := strings.TrimSpace(*name) + if len(trimmedName) == 2 { + // If name is two characters it's likely already an ISO value + return &trimmedName + } else if len(trimmedName) == 0 { + return nil + } + + v, exists := countryNameMapping[strings.ToLower(trimmedName)] + if exists { + return &v + } + + logger.Debugf("Scraped country was not recognized: %s", trimmedName) + + // return original name + return &trimmedName +} diff --git a/pkg/scraper/postprocessing.go b/pkg/scraper/postprocessing.go index 4151602d2..cf8cac1eb 100644 --- a/pkg/scraper/postprocessing.go +++ b/pkg/scraper/postprocessing.go @@ -47,7 +47,7 @@ func (c Cache) postScrape(ctx context.Context, content ScrapedContent) (ScrapedC } func (c Cache) postScrapePerformer(ctx context.Context, p models.ScrapedPerformer) (ScrapedContent, error) { - if err := txn.WithTxn(ctx, c.txnManager, func(ctx context.Context) error { + if err := txn.WithReadTxn(ctx, c.txnManager, func(ctx context.Context) error { tqb := c.repository.TagFinder tags, err := postProcessTags(ctx, tqb, p.Tags) @@ -66,12 +66,14 @@ func (c Cache) postScrapePerformer(ctx context.Context, p models.ScrapedPerforme logger.Warnf("Could not set image using URL %s: %s", *p.Image, err.Error()) } + p.Country = resolveCountryName(p.Country) + return p, nil } func (c Cache) postScrapeMovie(ctx context.Context, m models.ScrapedMovie) (ScrapedContent, error) { if m.Studio != nil { - if err := txn.WithTxn(ctx, c.txnManager, func(ctx context.Context) error { + if err := txn.WithReadTxn(ctx, c.txnManager, func(ctx context.Context) error { return match.ScrapedStudio(ctx, c.repository.StudioFinder, m.Studio, nil) }); err != nil { return nil, err @@ -98,11 +100,13 @@ func (c Cache) postScrapeScenePerformer(ctx context.Context, p models.ScrapedPer } p.Tags = tags + p.Country = resolveCountryName(p.Country) + return nil } func (c Cache) postScrapeScene(ctx context.Context, scene ScrapedScene) (ScrapedContent, error) { - if err := txn.WithTxn(ctx, c.txnManager, func(ctx context.Context) error { + if err := txn.WithReadTxn(ctx, c.txnManager, func(ctx context.Context) error { pqb := c.repository.PerformerFinder mqb := c.repository.MovieFinder tqb := c.repository.TagFinder @@ -156,7 +160,7 @@ func (c Cache) postScrapeScene(ctx context.Context, scene ScrapedScene) (Scraped } func (c Cache) postScrapeGallery(ctx context.Context, g ScrapedGallery) (ScrapedContent, error) { - if err := txn.WithTxn(ctx, c.txnManager, func(ctx context.Context) error { + if err := txn.WithReadTxn(ctx, c.txnManager, func(ctx context.Context) error { pqb := c.repository.PerformerFinder tqb := c.repository.TagFinder sqb := c.repository.StudioFinder diff --git a/pkg/scraper/query_url.go b/pkg/scraper/query_url.go index a65bd6c7f..0ad4aa7e9 100644 --- a/pkg/scraper/query_url.go +++ b/pkg/scraper/query_url.go @@ -36,9 +36,11 @@ func queryURLParametersFromScrapedScene(scene ScrapedSceneInput) queryURLParamet } setField("title", scene.Title) + setField("code", scene.Code) setField("url", scene.URL) setField("date", scene.Date) setField("details", scene.Details) + setField("director", scene.Director) setField("remote_site_id", scene.RemoteSiteID) return ret } diff --git a/pkg/scraper/scene.go b/pkg/scraper/scene.go index 9b5a60191..517f2a318 100644 --- a/pkg/scraper/scene.go +++ b/pkg/scraper/scene.go @@ -5,10 +5,12 @@ import ( ) type ScrapedScene struct { - Title *string `json:"title"` - Details *string `json:"details"` - URL *string `json:"url"` - Date *string `json:"date"` + Title *string `json:"title"` + Code *string `json:"code"` + Details *string `json:"details"` + Director *string `json:"director"` + URL *string `json:"url"` + Date *string `json:"date"` // This should be a base64 encoded data URL Image *string `json:"image"` File *models.SceneFileType `json:"file"` @@ -25,7 +27,9 @@ func (ScrapedScene) IsScrapedContent() {} type ScrapedSceneInput struct { Title *string `json:"title"` + Code *string `json:"code"` Details *string `json:"details"` + Director *string `json:"director"` URL *string `json:"url"` Date *string `json:"date"` RemoteSiteID *string `json:"remote_site_id"` diff --git a/pkg/scraper/stash.go b/pkg/scraper/stash.go index 73e14e7ec..9267bad0c 100644 --- a/pkg/scraper/stash.go +++ b/pkg/scraper/stash.go @@ -319,9 +319,12 @@ func sceneToUpdateInput(scene *models.Scene) models.SceneUpdateInput { return nil } + // fallback to file basename if title is empty + title := scene.GetTitle() + return models.SceneUpdateInput{ ID: strconv.Itoa(scene.ID), - Title: &scene.Title, + Title: &title, Details: &scene.Details, URL: &scene.URL, Date: dateToStringPtr(scene.Date), @@ -338,9 +341,12 @@ func galleryToUpdateInput(gallery *models.Gallery) models.GalleryUpdateInput { return nil } + // fallback to file basename if title is empty + title := gallery.GetTitle() + return models.GalleryUpdateInput{ ID: strconv.Itoa(gallery.ID), - Title: &gallery.Title, + Title: &title, Details: &gallery.Details, URL: &gallery.URL, Date: dateToStringPtr(gallery.Date), diff --git a/pkg/scraper/stashbox/graphql/generated_client.go b/pkg/scraper/stashbox/graphql/generated_client.go index 43b68c95c..cc30ab136 100644 --- a/pkg/scraper/stashbox/graphql/generated_client.go +++ b/pkg/scraper/stashbox/graphql/generated_client.go @@ -182,7 +182,9 @@ type FingerprintFragment struct { type SceneFragment struct { ID string "json:\"id\" graphql:\"id\"" Title *string "json:\"title\" graphql:\"title\"" + Code *string "json:\"code\" graphql:\"code\"" Details *string "json:\"details\" graphql:\"details\"" + Director *string "json:\"director\" graphql:\"director\"" Duration *int "json:\"duration\" graphql:\"duration\"" Date *string "json:\"date\" graphql:\"date\"" Urls []*URLFragment "json:\"urls\" graphql:\"urls\"" @@ -237,6 +239,49 @@ const FindSceneByFingerprintDocument = `query FindSceneByFingerprint ($fingerpri ... SceneFragment } } +fragment MeasurementsFragment on Measurements { + band_size + cup_size + waist + hip +} +fragment SceneFragment on Scene { + id + title + code + details + director + duration + date + urls { + ... URLFragment + } + images { + ... ImageFragment + } + studio { + ... StudioFragment + } + tags { + ... TagFragment + } + performers { + ... PerformerAppearanceFragment + } + fingerprints { + ... FingerprintFragment + } +} +fragment StudioFragment on Studio { + name + id + urls { + ... URLFragment + } + images { + ... ImageFragment + } +} fragment PerformerFragment on Performer { id name @@ -271,73 +316,32 @@ fragment PerformerFragment on Performer { ... BodyModificationFragment } } +fragment FuzzyDateFragment on FuzzyDate { + date + accuracy +} +fragment BodyModificationFragment on BodyModification { + location + description +} fragment FingerprintFragment on Fingerprint { algorithm hash duration } -fragment SceneFragment on Scene { - id - title - details - duration - date - urls { - ... URLFragment - } - images { - ... ImageFragment - } - studio { - ... StudioFragment - } - tags { - ... TagFragment - } - performers { - ... PerformerAppearanceFragment - } - fingerprints { - ... FingerprintFragment - } -} fragment URLFragment on URL { url type } -fragment TagFragment on Tag { - name - id -} -fragment FuzzyDateFragment on FuzzyDate { - date - accuracy -} -fragment MeasurementsFragment on Measurements { - band_size - cup_size - waist - hip -} -fragment BodyModificationFragment on BodyModification { - location - description -} fragment ImageFragment on Image { id url width height } -fragment StudioFragment on Studio { +fragment TagFragment on Tag { name id - urls { - ... URLFragment - } - images { - ... ImageFragment - } } fragment PerformerAppearanceFragment on PerformerAppearance { as @@ -369,6 +373,22 @@ fragment URLFragment on URL { url type } +fragment ImageFragment on Image { + id + url + width + height +} +fragment StudioFragment on Studio { + name + id + urls { + ... URLFragment + } + images { + ... ImageFragment + } +} fragment PerformerFragment on Performer { id name @@ -403,15 +423,16 @@ fragment PerformerFragment on Performer { ... BodyModificationFragment } } -fragment FingerprintFragment on Fingerprint { - algorithm - hash - duration +fragment BodyModificationFragment on BodyModification { + location + description } fragment SceneFragment on Scene { id title + code details + director duration date urls { @@ -433,20 +454,6 @@ fragment SceneFragment on Scene { ... FingerprintFragment } } -fragment StudioFragment on Studio { - name - id - urls { - ... URLFragment - } - images { - ... ImageFragment - } -} -fragment TagFragment on Tag { - name - id -} fragment PerformerAppearanceFragment on PerformerAppearance { as performer { @@ -463,15 +470,14 @@ fragment MeasurementsFragment on Measurements { waist hip } -fragment BodyModificationFragment on BodyModification { - location - description +fragment FingerprintFragment on Fingerprint { + algorithm + hash + duration } -fragment ImageFragment on Image { +fragment TagFragment on Tag { + name id - url - width - height } ` @@ -493,31 +499,6 @@ const FindScenesBySceneFingerprintsDocument = `query FindScenesBySceneFingerprin ... SceneFragment } } -fragment SceneFragment on Scene { - id - title - details - duration - date - urls { - ... URLFragment - } - images { - ... ImageFragment - } - studio { - ... StudioFragment - } - tags { - ... TagFragment - } - performers { - ... PerformerAppearanceFragment - } - fingerprints { - ... FingerprintFragment - } -} fragment ImageFragment on Image { id url @@ -534,6 +515,29 @@ fragment StudioFragment on Studio { ... ImageFragment } } +fragment PerformerAppearanceFragment on PerformerAppearance { + as + performer { + ... PerformerFragment + } +} +fragment FuzzyDateFragment on FuzzyDate { + date + accuracy +} +fragment FingerprintFragment on Fingerprint { + algorithm + hash + duration +} +fragment URLFragment on URL { + url + type +} +fragment TagFragment on Tag { + name + id +} fragment PerformerFragment on Performer { id name @@ -568,29 +572,6 @@ fragment PerformerFragment on Performer { ... BodyModificationFragment } } -fragment FingerprintFragment on Fingerprint { - algorithm - hash - duration -} -fragment URLFragment on URL { - url - type -} -fragment TagFragment on Tag { - name - id -} -fragment PerformerAppearanceFragment on PerformerAppearance { - as - performer { - ... PerformerFragment - } -} -fragment FuzzyDateFragment on FuzzyDate { - date - accuracy -} fragment MeasurementsFragment on Measurements { band_size cup_size @@ -601,6 +582,33 @@ fragment BodyModificationFragment on BodyModification { location description } +fragment SceneFragment on Scene { + id + title + code + details + director + duration + date + urls { + ... URLFragment + } + images { + ... ImageFragment + } + studio { + ... StudioFragment + } + tags { + ... TagFragment + } + performers { + ... PerformerAppearanceFragment + } + fingerprints { + ... FingerprintFragment + } +} ` func (c *Client) FindScenesBySceneFingerprints(ctx context.Context, fingerprints [][]*FingerprintQueryInput, httpRequestOptions ...client.HTTPRequestOption) (*FindScenesBySceneFingerprints, error) { @@ -621,14 +629,27 @@ const SearchSceneDocument = `query SearchScene ($term: String!) { ... SceneFragment } } -fragment FuzzyDateFragment on FuzzyDate { - date - accuracy +fragment MeasurementsFragment on Measurements { + band_size + cup_size + waist + hip +} +fragment BodyModificationFragment on BodyModification { + location + description +} +fragment FingerprintFragment on Fingerprint { + algorithm + hash + duration } fragment SceneFragment on Scene { id title + code details + director duration date urls { @@ -650,45 +671,16 @@ fragment SceneFragment on Scene { ... FingerprintFragment } } +fragment URLFragment on URL { + url + type +} fragment ImageFragment on Image { id url width height } -fragment TagFragment on Tag { - name - id -} -fragment PerformerAppearanceFragment on PerformerAppearance { - as - performer { - ... PerformerFragment - } -} -fragment BodyModificationFragment on BodyModification { - location - description -} -fragment FingerprintFragment on Fingerprint { - algorithm - hash - duration -} -fragment URLFragment on URL { - url - type -} -fragment StudioFragment on Studio { - name - id - urls { - ... URLFragment - } - images { - ... ImageFragment - } -} fragment PerformerFragment on Performer { id name @@ -723,11 +715,29 @@ fragment PerformerFragment on Performer { ... BodyModificationFragment } } -fragment MeasurementsFragment on Measurements { - band_size - cup_size - waist - hip +fragment FuzzyDateFragment on FuzzyDate { + date + accuracy +} +fragment StudioFragment on Studio { + name + id + urls { + ... URLFragment + } + images { + ... ImageFragment + } +} +fragment TagFragment on Tag { + name + id +} +fragment PerformerAppearanceFragment on PerformerAppearance { + as + performer { + ... PerformerFragment + } } ` @@ -749,6 +759,26 @@ const SearchPerformerDocument = `query SearchPerformer ($term: String!) { ... PerformerFragment } } +fragment ImageFragment on Image { + id + url + width + height +} +fragment FuzzyDateFragment on FuzzyDate { + date + accuracy +} +fragment MeasurementsFragment on Measurements { + band_size + cup_size + waist + hip +} +fragment BodyModificationFragment on BodyModification { + location + description +} fragment PerformerFragment on Performer { id name @@ -787,26 +817,6 @@ fragment URLFragment on URL { url type } -fragment ImageFragment on Image { - id - url - width - height -} -fragment FuzzyDateFragment on FuzzyDate { - date - accuracy -} -fragment MeasurementsFragment on Measurements { - band_size - cup_size - waist - hip -} -fragment BodyModificationFragment on BodyModification { - location - description -} ` func (c *Client) SearchPerformer(ctx context.Context, term string, httpRequestOptions ...client.HTTPRequestOption) (*SearchPerformer, error) { @@ -827,16 +837,6 @@ const FindPerformerByIDDocument = `query FindPerformerByID ($id: ID!) { ... PerformerFragment } } -fragment MeasurementsFragment on Measurements { - band_size - cup_size - waist - hip -} -fragment BodyModificationFragment on BodyModification { - location - description -} fragment PerformerFragment on Performer { id name @@ -885,6 +885,16 @@ fragment FuzzyDateFragment on FuzzyDate { date accuracy } +fragment MeasurementsFragment on Measurements { + band_size + cup_size + waist + hip +} +fragment BodyModificationFragment on BodyModification { + location + description +} ` func (c *Client) FindPerformerByID(ctx context.Context, id string, httpRequestOptions ...client.HTTPRequestOption) (*FindPerformerByID, error) { @@ -909,12 +919,49 @@ fragment BodyModificationFragment on BodyModification { location description } +fragment SceneFragment on Scene { + id + title + code + details + director + duration + date + urls { + ... URLFragment + } + images { + ... ImageFragment + } + studio { + ... StudioFragment + } + tags { + ... TagFragment + } + performers { + ... PerformerAppearanceFragment + } + fingerprints { + ... FingerprintFragment + } +} fragment ImageFragment on Image { id url width height } +fragment StudioFragment on Studio { + name + id + urls { + ... URLFragment + } + images { + ... ImageFragment + } +} fragment FuzzyDateFragment on FuzzyDate { date accuracy @@ -925,6 +972,10 @@ fragment MeasurementsFragment on Measurements { waist hip } +fragment URLFragment on URL { + url + type +} fragment TagFragment on Tag { name id @@ -974,45 +1025,6 @@ fragment FingerprintFragment on Fingerprint { hash duration } -fragment SceneFragment on Scene { - id - title - details - duration - date - urls { - ... URLFragment - } - images { - ... ImageFragment - } - studio { - ... StudioFragment - } - tags { - ... TagFragment - } - performers { - ... PerformerAppearanceFragment - } - fingerprints { - ... FingerprintFragment - } -} -fragment URLFragment on URL { - url - type -} -fragment StudioFragment on Studio { - name - id - urls { - ... URLFragment - } - images { - ... ImageFragment - } -} ` func (c *Client) FindSceneByID(ctx context.Context, id string, httpRequestOptions ...client.HTTPRequestOption) (*FindSceneByID, error) { diff --git a/pkg/scraper/stashbox/graphql/generated_models.go b/pkg/scraper/stashbox/graphql/generated_models.go index eaee65838..6bb572edb 100644 --- a/pkg/scraper/stashbox/graphql/generated_models.go +++ b/pkg/scraper/stashbox/graphql/generated_models.go @@ -88,8 +88,8 @@ type DraftEntity struct { ID *string `json:"id,omitempty"` } -func (DraftEntity) IsSceneDraftStudio() {} func (DraftEntity) IsSceneDraftTag() {} +func (DraftEntity) IsSceneDraftStudio() {} func (DraftEntity) IsSceneDraftPerformer() {} type DraftEntityInput struct { @@ -339,8 +339,8 @@ type Performer struct { Updated time.Time `json:"updated"` } -func (Performer) IsSceneDraftPerformer() {} func (Performer) IsEditTarget() {} +func (Performer) IsSceneDraftPerformer() {} type PerformerAppearance struct { Performer *Performer `json:"performer,omitempty"` diff --git a/pkg/scraper/stashbox/stash_box.go b/pkg/scraper/stashbox/stash_box.go index f87e4a9fe..a9e3cf54f 100644 --- a/pkg/scraper/stashbox/stash_box.go +++ b/pkg/scraper/stashbox/stash_box.go @@ -3,7 +3,9 @@ package stashbox import ( "bytes" "context" + "database/sql" "encoding/json" + "errors" "fmt" "io" "mime/multipart" @@ -129,7 +131,7 @@ func (c Client) FindStashBoxSceneByFingerprints(ctx context.Context, sceneID int func (c Client) FindStashBoxScenesByFingerprints(ctx context.Context, ids []int) ([][]*scraper.ScrapedScene, error) { var fingerprints [][]*graphql.FingerprintQueryInput - if err := txn.WithTxn(ctx, c.txnManager, func(ctx context.Context) error { + if err := txn.WithReadTxn(ctx, c.txnManager, func(ctx context.Context) error { qb := c.repository.Scene for _, sceneID := range ids { @@ -187,13 +189,22 @@ func (c Client) FindStashBoxScenesByFingerprints(ctx context.Context, ids []int) } func (c Client) findStashBoxScenesByFingerprints(ctx context.Context, scenes [][]*graphql.FingerprintQueryInput) ([][]*scraper.ScrapedScene, error) { - var ret [][]*scraper.ScrapedScene - for i := 0; i < len(scenes); i += 40 { - end := i + 40 - if end > len(scenes) { - end = len(scenes) + var results [][]*scraper.ScrapedScene + + // filter out nils + var validScenes [][]*graphql.FingerprintQueryInput + for _, s := range scenes { + if len(s) > 0 { + validScenes = append(validScenes, s) } - scenes, err := c.client.FindScenesBySceneFingerprints(ctx, scenes[i:end]) + } + + for i := 0; i < len(validScenes); i += 40 { + end := i + 40 + if end > len(validScenes) { + end = len(validScenes) + } + scenes, err := c.client.FindScenesBySceneFingerprints(ctx, validScenes[i:end]) if err != nil { return nil, err @@ -208,11 +219,22 @@ func (c Client) findStashBoxScenesByFingerprints(ctx context.Context, scenes [][ } sceneResults = append(sceneResults, ss) } - ret = append(ret, sceneResults) + results = append(results, sceneResults) } } - return ret, nil + // repopulate the results to be the same order as the input + ret := make([][]*scraper.ScrapedScene, len(scenes)) + upTo := 0 + + for i, v := range scenes { + if len(v) > 0 { + ret[i] = results[upTo] + upTo++ + } + } + + return results, nil } func (c Client) SubmitStashBoxFingerprints(ctx context.Context, sceneIDs []string, endpoint string) (bool, error) { @@ -223,12 +245,13 @@ func (c Client) SubmitStashBoxFingerprints(ctx context.Context, sceneIDs []strin var fingerprints []graphql.FingerprintSubmission - if err := txn.WithTxn(ctx, c.txnManager, func(ctx context.Context) error { + if err := txn.WithReadTxn(ctx, c.txnManager, func(ctx context.Context) error { qb := c.repository.Scene for _, sceneID := range ids { + // TODO - Find should return an appropriate not found error scene, err := qb.Find(ctx, sceneID) - if err != nil { + if err != nil && !errors.Is(err, sql.ErrNoRows) { return err } @@ -363,7 +386,7 @@ func (c Client) FindStashBoxPerformersByNames(ctx context.Context, performerIDs var performers []*models.Performer - if err := txn.WithTxn(ctx, c.txnManager, func(ctx context.Context) error { + if err := txn.WithReadTxn(ctx, c.txnManager, func(ctx context.Context) error { qb := c.repository.Performer for _, performerID := range ids { @@ -376,7 +399,7 @@ func (c Client) FindStashBoxPerformersByNames(ctx context.Context, performerIDs return fmt.Errorf("performer with id %d not found", performerID) } - if performer.Name.Valid { + if performer.Name != "" { performers = append(performers, performer) } } @@ -397,7 +420,7 @@ func (c Client) FindStashBoxPerformersByPerformerNames(ctx context.Context, perf var performers []*models.Performer - if err := txn.WithTxn(ctx, c.txnManager, func(ctx context.Context) error { + if err := txn.WithReadTxn(ctx, c.txnManager, func(ctx context.Context) error { qb := c.repository.Performer for _, performerID := range ids { @@ -410,7 +433,7 @@ func (c Client) FindStashBoxPerformersByPerformerNames(ctx context.Context, perf return fmt.Errorf("performer with id %d not found", performerID) } - if performer.Name.Valid { + if performer.Name != "" { performers = append(performers, performer) } } @@ -436,8 +459,8 @@ func (c Client) FindStashBoxPerformersByPerformerNames(ctx context.Context, perf func (c Client) findStashBoxPerformersByNames(ctx context.Context, performers []*models.Performer) ([]*StashBoxPerformerQueryResult, error) { var ret []*StashBoxPerformerQueryResult for _, performer := range performers { - if performer.Name.Valid { - performerResults, err := c.queryStashBoxPerformer(ctx, performer.Name.String) + if performer.Name != "" { + performerResults, err := c.queryStashBoxPerformer(ctx, performer.Name) if err != nil { return nil, err } @@ -664,8 +687,10 @@ func (c Client) sceneFragmentToScrapedScene(ctx context.Context, s *graphql.Scen stashID := s.ID ss := &scraper.ScrapedScene{ Title: s.Title, + Code: s.Code, Date: s.Date, Details: s.Details, + Director: s.Director, URL: findURL(s.Urls, "STUDIO"), Duration: s.Duration, RemoteSiteID: &stashID, @@ -680,7 +705,7 @@ func (c Client) sceneFragmentToScrapedScene(ctx context.Context, s *graphql.Scen ss.Image = getFirstImage(ctx, c.getHTTPClient(), s.Images) } - if err := txn.WithTxn(ctx, c.txnManager, func(ctx context.Context) error { + if err := txn.WithReadTxn(ctx, c.txnManager, func(ctx context.Context) error { pqb := c.repository.Performer tqb := c.repository.Tag @@ -864,7 +889,7 @@ func (c Client) SubmitSceneDraft(ctx context.Context, scene *models.Scene, endpo performers := []*graphql.DraftEntityInput{} for _, p := range scenePerformers { performerDraft := graphql.DraftEntityInput{ - Name: p.Name.String, + Name: p.Name, } stashIDs, err := pqb.GetStashIDs(ctx, p.ID) @@ -944,55 +969,58 @@ func (c Client) SubmitPerformerDraft(ctx context.Context, performer *models.Perf image = bytes.NewReader(img) } - if performer.Name.Valid { - draft.Name = performer.Name.String + if performer.Name != "" { + draft.Name = performer.Name } - if performer.Birthdate.Valid { - draft.Birthdate = &performer.Birthdate.String + if performer.Birthdate != nil { + d := performer.Birthdate.String() + draft.Birthdate = &d } - if performer.Country.Valid { - draft.Country = &performer.Country.String + if performer.Country != "" { + draft.Country = &performer.Country } - if performer.Ethnicity.Valid { - draft.Ethnicity = &performer.Ethnicity.String + if performer.Ethnicity != "" { + draft.Ethnicity = &performer.Ethnicity } - if performer.EyeColor.Valid { - draft.EyeColor = &performer.EyeColor.String + if performer.EyeColor != "" { + draft.EyeColor = &performer.EyeColor } - if performer.FakeTits.Valid { - draft.BreastType = &performer.FakeTits.String + if performer.FakeTits != "" { + draft.BreastType = &performer.FakeTits } - if performer.Gender.Valid { - draft.Gender = &performer.Gender.String + if performer.Gender.IsValid() { + v := performer.Gender.String() + draft.Gender = &v } - if performer.HairColor.Valid { - draft.HairColor = &performer.HairColor.String + if performer.HairColor != "" { + draft.HairColor = &performer.HairColor } - if performer.Height.Valid { - draft.Height = &performer.Height.String + if performer.Height != nil { + v := strconv.Itoa(*performer.Height) + draft.Height = &v } - if performer.Measurements.Valid { - draft.Measurements = &performer.Measurements.String + if performer.Measurements != "" { + draft.Measurements = &performer.Measurements } - if performer.Piercings.Valid { - draft.Piercings = &performer.Piercings.String + if performer.Piercings != "" { + draft.Piercings = &performer.Piercings } - if performer.Tattoos.Valid { - draft.Tattoos = &performer.Tattoos.String + if performer.Tattoos != "" { + draft.Tattoos = &performer.Tattoos } - if performer.Aliases.Valid { - draft.Aliases = &performer.Aliases.String + if performer.Aliases != "" { + draft.Aliases = &performer.Aliases } var urls []string - if len(strings.TrimSpace(performer.Twitter.String)) > 0 { - urls = append(urls, "https://twitter.com/"+strings.TrimSpace(performer.Twitter.String)) + if len(strings.TrimSpace(performer.Twitter)) > 0 { + urls = append(urls, "https://twitter.com/"+strings.TrimSpace(performer.Twitter)) } - if len(strings.TrimSpace(performer.Instagram.String)) > 0 { - urls = append(urls, "https://instagram.com/"+strings.TrimSpace(performer.Instagram.String)) + if len(strings.TrimSpace(performer.Instagram)) > 0 { + urls = append(urls, "https://instagram.com/"+strings.TrimSpace(performer.Instagram)) } - if len(strings.TrimSpace(performer.URL.String)) > 0 { - urls = append(urls, strings.TrimSpace(performer.URL.String)) + if len(strings.TrimSpace(performer.URL)) > 0 { + urls = append(urls, strings.TrimSpace(performer.URL)) } if len(urls) > 0 { draft.Urls = urls diff --git a/pkg/sqlite/database.go b/pkg/sqlite/database.go index 0c913737a..b2c333024 100644 --- a/pkg/sqlite/database.go +++ b/pkg/sqlite/database.go @@ -8,7 +8,6 @@ import ( "fmt" "os" "path/filepath" - "sync" "time" "github.com/fvbommel/sortorder" @@ -22,7 +21,7 @@ import ( "github.com/stashapp/stash/pkg/logger" ) -var appSchemaVersion uint = 36 +var appSchemaVersion uint = 41 //go:embed migrations/*.sql var migrationsBox embed.FS @@ -61,18 +60,19 @@ func init() { } type Database struct { - File *FileStore - Folder *FolderStore - Image *ImageStore - Gallery *GalleryStore - Scene *SceneStore + File *FileStore + Folder *FolderStore + Image *ImageStore + Gallery *GalleryStore + Scene *SceneStore + Performer *PerformerStore db *sqlx.DB dbPath string schemaVersion uint - writeMu sync.Mutex + lockChan chan struct{} } func NewDatabase() *Database { @@ -80,11 +80,13 @@ func NewDatabase() *Database { folderStore := NewFolderStore() ret := &Database{ - File: fileStore, - Folder: folderStore, - Scene: NewSceneStore(fileStore), - Image: NewImageStore(fileStore), - Gallery: NewGalleryStore(fileStore, folderStore), + File: fileStore, + Folder: folderStore, + Scene: NewSceneStore(fileStore), + Image: NewImageStore(fileStore), + Gallery: NewGalleryStore(fileStore, folderStore), + Performer: NewPerformerStore(), + lockChan: make(chan struct{}, 1), } return ret @@ -104,8 +106,8 @@ func (db *Database) Ready() error { // necessary migrations must be run separately using RunMigrations. // Returns true if the database is new. func (db *Database) Open(dbPath string) error { - db.writeMu.Lock() - defer db.writeMu.Unlock() + db.lockNoCtx() + defer db.unlock() db.dbPath = dbPath @@ -150,9 +152,36 @@ func (db *Database) Open(dbPath string) error { return nil } +// lock locks the database for writing. +// This method will block until the lock is acquired of the context is cancelled. +func (db *Database) lock(ctx context.Context) error { + select { + case <-ctx.Done(): + return ctx.Err() + case db.lockChan <- struct{}{}: + return nil + } +} + +// lock locks the database for writing. This method will block until the lock is acquired. +func (db *Database) lockNoCtx() { + db.lockChan <- struct{}{} +} + +// unlock unlocks the database +func (db *Database) unlock() { + // will block the caller if the lock is not held, so check first + select { + case <-db.lockChan: + return + default: + panic("database is not locked") + } +} + func (db *Database) Close() error { - db.writeMu.Lock() - defer db.writeMu.Unlock() + db.lockNoCtx() + defer db.unlock() if db.db != nil { if err := db.db.Close(); err != nil { diff --git a/pkg/sqlite/file.go b/pkg/sqlite/file.go index 4c72d299e..0cd21ee4b 100644 --- a/pkg/sqlite/file.go +++ b/pkg/sqlite/file.go @@ -586,10 +586,20 @@ func (qb *FileStore) FindByPath(ctx context.Context, p string) (file.File, error table := qb.table() folderTable := folderTableMgr.table - q := qb.selectDataset().Prepared(true).Where( - folderTable.Col("path").Like(dirName), - table.Col("basename").Like(basename), - ) + // like uses case-insensitive matching. Only use like if wildcards are used + q := qb.selectDataset().Prepared(true) + + if strings.Contains(basename, "%") || strings.Contains(dirName, "%") { + q = q.Where( + folderTable.Col("path").Like(dirName), + table.Col("basename").Like(basename), + ) + } else { + q = q.Where( + folderTable.Col("path").Eq(dirName), + table.Col("basename").Eq(basename), + ) + } ret, err := qb.get(ctx, q) if err != nil && !errors.Is(err, sql.ErrNoRows) { @@ -786,7 +796,8 @@ func (qb *FileStore) Query(ctx context.Context, options models.FileQueryOptions) distinctIDs(&query, fileTable) if q := findFilter.Q; q != nil && *q != "" { - searchColumns := []string{"folders.path", "files.basename"} + filepathColumn := "folders.path || '" + string(filepath.Separator) + "' || files.basename" + searchColumns := []string{filepathColumn} query.parseQueryString(searchColumns, *q) } diff --git a/pkg/sqlite/filter.go b/pkg/sqlite/filter.go index e6211a91e..d75012b4e 100644 --- a/pkg/sqlite/filter.go +++ b/pkg/sqlite/filter.go @@ -454,17 +454,19 @@ func pathCriterionHandler(c *models.StringCriterionInput, pathColumn string, bas f.setError(err) return } - f.addWhere(fmt.Sprintf("%s IS NOT NULL AND %s IS NOT NULL AND %[1]s || '%[3]s' || %[2]s regexp ?", pathColumn, basenameColumn, string(filepath.Separator)), c.Value) + filepathColumn := fmt.Sprintf("%s || '%s' || %s", pathColumn, string(filepath.Separator), basenameColumn) + f.addWhere(fmt.Sprintf("%s IS NOT NULL AND %s IS NOT NULL AND %s regexp ?", pathColumn, basenameColumn, filepathColumn), c.Value) case models.CriterionModifierNotMatchesRegex: if _, err := regexp.Compile(c.Value); err != nil { f.setError(err) return } - f.addWhere(fmt.Sprintf("%s IS NULL OR %s IS NULL OR %[1]s || '%[3]s' || %[2]s NOT regexp ?", pathColumn, basenameColumn, string(filepath.Separator)), c.Value) + filepathColumn := fmt.Sprintf("%s || '%s' || %s", pathColumn, string(filepath.Separator), basenameColumn) + f.addWhere(fmt.Sprintf("%s IS NULL OR %s IS NULL OR %s NOT regexp ?", pathColumn, basenameColumn, filepathColumn), c.Value) case models.CriterionModifierIsNull: - f.addWhere(fmt.Sprintf("(%s IS NULL OR TRIM(%[1]s) = '' OR %s IS NULL OR TRIM(%[2]s) = '')", pathColumn, basenameColumn)) + f.addWhere(fmt.Sprintf("%s IS NULL OR TRIM(%[1]s) = '' OR %s IS NULL OR TRIM(%[2]s) = ''", pathColumn, basenameColumn)) case models.CriterionModifierNotNull: - f.addWhere(fmt.Sprintf("(%s IS NOT NULL AND TRIM(%[1]s) != '' AND %s IS NOT NULL AND TRIM(%[2]s) != '')", pathColumn, basenameColumn)) + f.addWhere(fmt.Sprintf("%s IS NOT NULL AND TRIM(%[1]s) != '' AND %s IS NOT NULL AND TRIM(%[2]s) != ''", pathColumn, basenameColumn)) default: panic("unsupported string filter modifier") } @@ -474,46 +476,12 @@ func pathCriterionHandler(c *models.StringCriterionInput, pathColumn string, bas } func getPathSearchClause(pathColumn, basenameColumn, p string, addWildcards, not bool) sqlClause { - // if path value has slashes, then we're potentially searching directory only or - // directory plus basename - hasSlashes := strings.Contains(p, string(filepath.Separator)) - trailingSlash := hasSlashes && p[len(p)-1] == filepath.Separator - const emptyDir = string(filepath.Separator) - - // possible values: - // dir/basename - // dir1/subdir - // dir/ - // /basename - // dirOrBasename - - basename := filepath.Base(p) - dir := filepath.Dir(p) - if addWildcards { p = "%" + p + "%" - basename += "%" - dir = "%" + dir } - var ret sqlClause - - switch { - case !hasSlashes: - // dir or basename - ret = makeClause(fmt.Sprintf("%s LIKE ? OR %s LIKE ?", pathColumn, basenameColumn), p, p) - case dir != emptyDir && !trailingSlash: - // (path like %dir AND basename like basename%) OR path like %p% - c1 := makeClause(fmt.Sprintf("%s LIKE ? AND %s LIKE ?", pathColumn, basenameColumn), dir, basename) - c2 := makeClause(fmt.Sprintf("%s LIKE ?", pathColumn), p) - ret = orClauses(c1, c2) - case dir == emptyDir && !trailingSlash: - // path like %p% OR basename like basename% - ret = makeClause(fmt.Sprintf("%s LIKE ? OR %s LIKE ?", pathColumn, basenameColumn), p, basename) - case dir != emptyDir && trailingSlash: - // path like %p% OR path like %dir - ret = makeClause(fmt.Sprintf("%s LIKE ? OR %[1]s LIKE ?", pathColumn), p, dir) - } + filepathColumn := fmt.Sprintf("%s || '%s' || %s", pathColumn, string(filepath.Separator), basenameColumn) + ret := makeClause(fmt.Sprintf("%s LIKE ?", filepathColumn), p) if not { ret = ret.not() @@ -575,6 +543,43 @@ func boolCriterionHandler(c *bool, column string, addJoinFn func(f *filterBuilde } } +func rating5CriterionHandler(c *models.IntCriterionInput, column string, addJoinFn func(f *filterBuilder)) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if c != nil { + // make a copy so we can adjust it + cc := *c + if cc.Value != 0 { + cc.Value = models.Rating5To100(cc.Value) + } + if cc.Value2 != nil { + val := models.Rating5To100(*cc.Value2) + cc.Value2 = &val + } + + clause, args := getIntCriterionWhereClause(column, cc) + f.addWhere(clause, args...) + } + } +} + +func dateCriterionHandler(c *models.DateCriterionInput, column string) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if c != nil { + clause, args := getDateCriterionWhereClause(column, *c) + f.addWhere(clause, args...) + } + } +} + +func timestampCriterionHandler(c *models.TimestampCriterionInput, column string) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if c != nil { + clause, args := getTimestampCriterionWhereClause(column, *c) + f.addWhere(clause, args...) + } + } +} + // handle for MultiCriterion where there is a join table between the new // objects type joinedMultiCriterionHandlerBuilder struct { @@ -937,3 +942,39 @@ func (m *joinedHierarchicalMultiCriterionHandlerBuilder) handler(criterion *mode } } } + +type stashIDCriterionHandler struct { + c *models.StashIDCriterionInput + stashIDRepository *stashIDRepository + stashIDTableAs string + parentIDCol string +} + +func (h *stashIDCriterionHandler) handle(ctx context.Context, f *filterBuilder) { + if h.c == nil { + return + } + + stashIDRepo := h.stashIDRepository + t := stashIDRepo.tableName + if h.stashIDTableAs != "" { + t = h.stashIDTableAs + } + + joinClause := fmt.Sprintf("%s.%s = %s", t, stashIDRepo.idColumn, h.parentIDCol) + if h.c.Endpoint != nil && *h.c.Endpoint != "" { + joinClause += fmt.Sprintf(" AND %s.endpoint = '%s'", t, *h.c.Endpoint) + } + + f.addLeftJoin(stashIDRepo.tableName, h.stashIDTableAs, joinClause) + + v := "" + if h.c.StashID != nil { + v = *h.c.StashID + } + + stringCriterionHandler(&models.StringCriterionInput{ + Value: v, + Modifier: h.c.Modifier, + }, t+".stash_id")(ctx, f) +} diff --git a/pkg/sqlite/gallery.go b/pkg/sqlite/gallery.go index f2fbea9f5..e45d7cb9f 100644 --- a/pkg/sqlite/gallery.go +++ b/pkg/sqlite/gallery.go @@ -30,11 +30,12 @@ const ( ) type galleryRow struct { - ID int `db:"id" goqu:"skipinsert"` - Title zero.String `db:"title"` - URL zero.String `db:"url"` - Date models.SQLiteDate `db:"date"` - Details zero.String `db:"details"` + ID int `db:"id" goqu:"skipinsert"` + Title zero.String `db:"title"` + URL zero.String `db:"url"` + Date models.SQLiteDate `db:"date"` + Details zero.String `db:"details"` + // expressed as 1-100 Rating null.Int `db:"rating"` Organized bool `db:"organized"` StudioID null.Int `db:"studio_id,omitempty"` @@ -624,6 +625,7 @@ func (qb *GalleryStore) makeFilter(ctx context.Context, galleryFilter *models.Ga query.not(qb.makeFilter(ctx, galleryFilter.Not)) } + query.handleCriterion(ctx, intCriterionHandler(galleryFilter.ID, "galleries.id", nil)) query.handleCriterion(ctx, stringCriterionHandler(galleryFilter.Title, "galleries.title")) query.handleCriterion(ctx, stringCriterionHandler(galleryFilter.Details, "galleries.details")) @@ -650,7 +652,9 @@ func (qb *GalleryStore) makeFilter(ctx context.Context, galleryFilter *models.Ga query.handleCriterion(ctx, qb.galleryPathCriterionHandler(galleryFilter.Path)) query.handleCriterion(ctx, galleryFileCountCriterionHandler(qb, galleryFilter.FileCount)) - query.handleCriterion(ctx, intCriterionHandler(galleryFilter.Rating, "galleries.rating", nil)) + query.handleCriterion(ctx, intCriterionHandler(galleryFilter.Rating100, "galleries.rating", nil)) + // legacy rating handler + query.handleCriterion(ctx, rating5CriterionHandler(galleryFilter.Rating, "galleries.rating", nil)) query.handleCriterion(ctx, stringCriterionHandler(galleryFilter.URL, "galleries.url")) query.handleCriterion(ctx, boolCriterionHandler(galleryFilter.Organized, "galleries.organized", nil)) query.handleCriterion(ctx, galleryIsMissingCriterionHandler(qb, galleryFilter.IsMissing)) @@ -664,6 +668,9 @@ func (qb *GalleryStore) makeFilter(ctx context.Context, galleryFilter *models.Ga query.handleCriterion(ctx, galleryImageCountCriterionHandler(qb, galleryFilter.ImageCount)) query.handleCriterion(ctx, galleryPerformerFavoriteCriterionHandler(galleryFilter.PerformerFavorite)) query.handleCriterion(ctx, galleryPerformerAgeCriterionHandler(galleryFilter.PerformerAge)) + query.handleCriterion(ctx, dateCriterionHandler(galleryFilter.Date, "galleries.date")) + query.handleCriterion(ctx, timestampCriterionHandler(galleryFilter.CreatedAt, "galleries.created_at")) + query.handleCriterion(ctx, timestampCriterionHandler(galleryFilter.UpdatedAt, "galleries.updated_at")) return query } @@ -719,7 +726,8 @@ func (qb *GalleryStore) makeQuery(ctx context.Context, galleryFilter *models.Gal ) // add joins for files and checksum - searchColumns := []string{"galleries.title", "gallery_folder.path", "folders.path", "files.basename", "files_fingerprints.fingerprint"} + filepathColumn := "folders.path || '" + string(filepath.Separator) + "' || files.basename" + searchColumns := []string{"galleries.title", "gallery_folder.path", filepathColumn, "files_fingerprints.fingerprint"} query.parseQueryString(searchColumns, *q) } @@ -785,12 +793,12 @@ func (qb *GalleryStore) galleryPathCriterionHandler(c *models.StringCriterionInp if modifier := c.Modifier; c.Modifier.IsValid() { switch modifier { case models.CriterionModifierIncludes: - clause := getPathSearchClause(pathColumn, basenameColumn, c.Value, addWildcards, not) + clause := getPathSearchClauseMany(pathColumn, basenameColumn, c.Value, addWildcards, not) clause2 := getStringSearchClause([]string{folderPathColumn}, c.Value, false) f.whereClauses = append(f.whereClauses, orClauses(clause, clause2)) case models.CriterionModifierExcludes: not = true - clause := getPathSearchClause(pathColumn, basenameColumn, c.Value, addWildcards, not) + clause := getPathSearchClauseMany(pathColumn, basenameColumn, c.Value, addWildcards, not) clause2 := getStringSearchClause([]string{folderPathColumn}, c.Value, true) f.whereClauses = append(f.whereClauses, orClauses(clause, clause2)) case models.CriterionModifierEquals: @@ -809,22 +817,24 @@ func (qb *GalleryStore) galleryPathCriterionHandler(c *models.StringCriterionInp f.setError(err) return } - clause := makeClause(fmt.Sprintf("(%s IS NOT NULL AND %[1]s regexp ?) OR (%s IS NOT NULL AND %[2]s regexp ?)", pathColumn, basenameColumn), c.Value, c.Value) - clause2 := makeClause(fmt.Sprintf("(%s IS NOT NULL AND %[1]s regexp ?)", folderPathColumn), c.Value) + filepathColumn := fmt.Sprintf("%s || '%s' || %s", pathColumn, string(filepath.Separator), basenameColumn) + clause := makeClause(fmt.Sprintf("%s IS NOT NULL AND %s IS NOT NULL AND %s regexp ?", pathColumn, basenameColumn, filepathColumn), c.Value) + clause2 := makeClause(fmt.Sprintf("%s IS NOT NULL AND %[1]s regexp ?", folderPathColumn), c.Value) f.whereClauses = append(f.whereClauses, orClauses(clause, clause2)) case models.CriterionModifierNotMatchesRegex: if _, err := regexp.Compile(c.Value); err != nil { f.setError(err) return } - f.addWhere(fmt.Sprintf("(%s IS NULL OR %[1]s NOT regexp ?) AND (%s IS NULL OR %[2]s NOT regexp ?)", pathColumn, basenameColumn), c.Value, c.Value) - f.addWhere(fmt.Sprintf("(%s IS NULL OR %[1]s NOT regexp ?)", folderPathColumn), c.Value) + filepathColumn := fmt.Sprintf("%s || '%s' || %s", pathColumn, string(filepath.Separator), basenameColumn) + f.addWhere(fmt.Sprintf("%s IS NULL OR %s IS NULL OR %s NOT regexp ?", pathColumn, basenameColumn, filepathColumn), c.Value) + f.addWhere(fmt.Sprintf("%s IS NULL OR %[1]s NOT regexp ?", folderPathColumn), c.Value) case models.CriterionModifierIsNull: - f.whereClauses = append(f.whereClauses, makeClause(fmt.Sprintf("(%s IS NULL OR TRIM(%[1]s) = '' OR %s IS NULL OR TRIM(%[2]s) = '')", pathColumn, basenameColumn))) - f.whereClauses = append(f.whereClauses, makeClause(fmt.Sprintf("(%s IS NULL OR TRIM(%[1]s) = '')", folderPathColumn))) + f.addWhere(fmt.Sprintf("%s IS NULL OR TRIM(%[1]s) = '' OR %s IS NULL OR TRIM(%[2]s) = ''", pathColumn, basenameColumn)) + f.addWhere(fmt.Sprintf("%s IS NULL OR TRIM(%[1]s) = ''", folderPathColumn)) case models.CriterionModifierNotNull: - clause := makeClause(fmt.Sprintf("(%s IS NOT NULL AND TRIM(%[1]s) != '' AND %s IS NOT NULL AND TRIM(%[2]s) != '')", pathColumn, basenameColumn)) - clause2 := makeClause(fmt.Sprintf("(%s IS NOT NULL AND TRIM(%[1]s) != '')", folderPathColumn)) + clause := makeClause(fmt.Sprintf("%s IS NOT NULL AND TRIM(%[1]s) != '' AND %s IS NOT NULL AND TRIM(%[2]s) != ''", pathColumn, basenameColumn)) + clause2 := makeClause(fmt.Sprintf("%s IS NOT NULL AND TRIM(%[1]s) != ''", folderPathColumn)) f.whereClauses = append(f.whereClauses, orClauses(clause, clause2)) default: panic("unsupported string filter modifier") diff --git a/pkg/sqlite/gallery_test.go b/pkg/sqlite/gallery_test.go index 88c016d4c..80e25b6d1 100644 --- a/pkg/sqlite/gallery_test.go +++ b/pkg/sqlite/gallery_test.go @@ -54,7 +54,7 @@ func Test_galleryQueryBuilder_Create(t *testing.T) { var ( title = "title" url = "url" - rating = 3 + rating = 60 details = "details" createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) updatedAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) @@ -205,7 +205,7 @@ func Test_galleryQueryBuilder_Update(t *testing.T) { var ( title = "title" url = "url" - rating = 3 + rating = 60 details = "details" createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) updatedAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) @@ -399,7 +399,7 @@ func Test_galleryQueryBuilder_UpdatePartial(t *testing.T) { title = "title" details = "details" url = "url" - rating = 3 + rating = 60 createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) updatedAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) @@ -1547,7 +1547,7 @@ func TestGalleryQueryPathAndRating(t *testing.T) { Modifier: models.CriterionModifierEquals, }, And: &models.GalleryFilterType{ - Rating: &models.IntCriterionInput{ + Rating100: &models.IntCriterionInput{ Value: *galleryRating, Modifier: models.CriterionModifierEquals, }, @@ -1588,7 +1588,7 @@ func TestGalleryQueryPathNotRating(t *testing.T) { galleryFilter := models.GalleryFilterType{ Path: &pathCriterion, Not: &models.GalleryFilterType{ - Rating: &ratingCriterion, + Rating100: &ratingCriterion, }, } @@ -1699,32 +1699,32 @@ func verifyGalleryQuery(t *testing.T, filter models.GalleryFilterType, verifyFn }) } -func TestGalleryQueryRating(t *testing.T) { +func TestGalleryQueryLegacyRating(t *testing.T) { const rating = 3 ratingCriterion := models.IntCriterionInput{ Value: rating, Modifier: models.CriterionModifierEquals, } - verifyGalleriesRating(t, ratingCriterion) + verifyGalleriesLegacyRating(t, ratingCriterion) ratingCriterion.Modifier = models.CriterionModifierNotEquals - verifyGalleriesRating(t, ratingCriterion) + verifyGalleriesLegacyRating(t, ratingCriterion) ratingCriterion.Modifier = models.CriterionModifierGreaterThan - verifyGalleriesRating(t, ratingCriterion) + verifyGalleriesLegacyRating(t, ratingCriterion) ratingCriterion.Modifier = models.CriterionModifierLessThan - verifyGalleriesRating(t, ratingCriterion) + verifyGalleriesLegacyRating(t, ratingCriterion) ratingCriterion.Modifier = models.CriterionModifierIsNull - verifyGalleriesRating(t, ratingCriterion) + verifyGalleriesLegacyRating(t, ratingCriterion) ratingCriterion.Modifier = models.CriterionModifierNotNull - verifyGalleriesRating(t, ratingCriterion) + verifyGalleriesLegacyRating(t, ratingCriterion) } -func verifyGalleriesRating(t *testing.T, ratingCriterion models.IntCriterionInput) { +func verifyGalleriesLegacyRating(t *testing.T, ratingCriterion models.IntCriterionInput) { withTxn(func(ctx context.Context) error { sqb := db.Gallery galleryFilter := models.GalleryFilterType{ @@ -1736,6 +1736,54 @@ func verifyGalleriesRating(t *testing.T, ratingCriterion models.IntCriterionInpu t.Errorf("Error querying gallery: %s", err.Error()) } + // convert criterion value to the 100 value + ratingCriterion.Value = models.Rating5To100(ratingCriterion.Value) + + for _, gallery := range galleries { + verifyIntPtr(t, gallery.Rating, ratingCriterion) + } + + return nil + }) +} + +func TestGalleryQueryRating100(t *testing.T) { + const rating = 60 + ratingCriterion := models.IntCriterionInput{ + Value: rating, + Modifier: models.CriterionModifierEquals, + } + + verifyGalleriesRating100(t, ratingCriterion) + + ratingCriterion.Modifier = models.CriterionModifierNotEquals + verifyGalleriesRating100(t, ratingCriterion) + + ratingCriterion.Modifier = models.CriterionModifierGreaterThan + verifyGalleriesRating100(t, ratingCriterion) + + ratingCriterion.Modifier = models.CriterionModifierLessThan + verifyGalleriesRating100(t, ratingCriterion) + + ratingCriterion.Modifier = models.CriterionModifierIsNull + verifyGalleriesRating100(t, ratingCriterion) + + ratingCriterion.Modifier = models.CriterionModifierNotNull + verifyGalleriesRating100(t, ratingCriterion) +} + +func verifyGalleriesRating100(t *testing.T, ratingCriterion models.IntCriterionInput) { + withTxn(func(ctx context.Context) error { + sqb := db.Gallery + galleryFilter := models.GalleryFilterType{ + Rating100: &ratingCriterion, + } + + galleries, _, err := sqb.Query(ctx, &galleryFilter, nil) + if err != nil { + t.Errorf("Error querying gallery: %s", err.Error()) + } + for _, gallery := range galleries { verifyIntPtr(t, gallery.Rating, ratingCriterion) } diff --git a/pkg/sqlite/image.go b/pkg/sqlite/image.go index 3224ea66d..9cc0e957a 100644 --- a/pkg/sqlite/image.go +++ b/pkg/sqlite/image.go @@ -27,8 +27,9 @@ const ( ) type imageRow struct { - ID int `db:"id" goqu:"skipinsert"` - Title zero.String `db:"title"` + ID int `db:"id" goqu:"skipinsert"` + Title zero.String `db:"title"` + // expressed as 1-100 Rating null.Int `db:"rating"` Organized bool `db:"organized"` OCounter int `db:"o_counter"` @@ -619,6 +620,7 @@ func (qb *ImageStore) makeFilter(ctx context.Context, imageFilter *models.ImageF query.not(qb.makeFilter(ctx, imageFilter.Not)) } + query.handleCriterion(ctx, intCriterionHandler(imageFilter.ID, "images.id", nil)) query.handleCriterion(ctx, criterionHandlerFunc(func(ctx context.Context, f *filterBuilder) { if imageFilter.Checksum != nil { qb.addImagesFilesTable(f) @@ -631,7 +633,9 @@ func (qb *ImageStore) makeFilter(ctx context.Context, imageFilter *models.ImageF query.handleCriterion(ctx, pathCriterionHandler(imageFilter.Path, "folders.path", "files.basename", qb.addFoldersTable)) query.handleCriterion(ctx, imageFileCountCriterionHandler(qb, imageFilter.FileCount)) - query.handleCriterion(ctx, intCriterionHandler(imageFilter.Rating, "images.rating", nil)) + query.handleCriterion(ctx, intCriterionHandler(imageFilter.Rating100, "images.rating", nil)) + // legacy rating handler + query.handleCriterion(ctx, rating5CriterionHandler(imageFilter.Rating, "images.rating", nil)) query.handleCriterion(ctx, intCriterionHandler(imageFilter.OCounter, "images.o_counter", nil)) query.handleCriterion(ctx, boolCriterionHandler(imageFilter.Organized, "images.organized", nil)) @@ -646,6 +650,8 @@ func (qb *ImageStore) makeFilter(ctx context.Context, imageFilter *models.ImageF query.handleCriterion(ctx, imageStudioCriterionHandler(qb, imageFilter.Studios)) query.handleCriterion(ctx, imagePerformerTagsCriterionHandler(qb, imageFilter.PerformerTags)) query.handleCriterion(ctx, imagePerformerFavoriteCriterionHandler(imageFilter.PerformerFavorite)) + query.handleCriterion(ctx, timestampCriterionHandler(imageFilter.CreatedAt, "images.created_at")) + query.handleCriterion(ctx, timestampCriterionHandler(imageFilter.UpdatedAt, "images.updated_at")) return query } @@ -700,7 +706,8 @@ func (qb *ImageStore) makeQuery(ctx context.Context, imageFilter *models.ImageFi }, ) - searchColumns := []string{"images.title", "folders.path", "files.basename", "files_fingerprints.fingerprint"} + filepathColumn := "folders.path || '" + string(filepath.Separator) + "' || files.basename" + searchColumns := []string{"images.title", filepathColumn, "files_fingerprints.fingerprint"} query.parseQueryString(searchColumns, *q) } @@ -745,7 +752,7 @@ func (qb *ImageStore) queryGroupedFields(ctx context.Context, options models.Ima aggregateQuery := qb.newQuery() if options.Count { - aggregateQuery.addColumn("COUNT(temp.id) as total") + aggregateQuery.addColumn("COUNT(DISTINCT temp.id) as total") } // TODO - this doesn't work yet diff --git a/pkg/sqlite/image_test.go b/pkg/sqlite/image_test.go index b748dbe49..d40859de9 100644 --- a/pkg/sqlite/image_test.go +++ b/pkg/sqlite/image_test.go @@ -54,7 +54,7 @@ func loadImageRelationships(ctx context.Context, expected models.Image, actual * func Test_imageQueryBuilder_Create(t *testing.T) { var ( title = "title" - rating = 3 + rating = 60 ocounter = 5 createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) updatedAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) @@ -208,7 +208,7 @@ func makeImageFileWithID(i int) *file.ImageFile { func Test_imageQueryBuilder_Update(t *testing.T) { var ( title = "title" - rating = 3 + rating = 60 ocounter = 5 createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) updatedAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) @@ -382,7 +382,7 @@ func clearImagePartial() models.ImagePartial { func Test_imageQueryBuilder_UpdatePartial(t *testing.T) { var ( title = "title" - rating = 3 + rating = 60 ocounter = 5 createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) updatedAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) @@ -929,18 +929,15 @@ func Test_imageQueryBuilder_Destroy(t *testing.T) { for _, tt := range tests { runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { assert := assert.New(t) - withRollbackTxn(func(ctx context.Context) error { - if err := qb.Destroy(ctx, tt.id); (err != nil) != tt.wantErr { - t.Errorf("imageQueryBuilder.Destroy() error = %v, wantErr %v", err, tt.wantErr) - } + if err := qb.Destroy(ctx, tt.id); (err != nil) != tt.wantErr { + t.Errorf("imageQueryBuilder.Destroy() error = %v, wantErr %v", err, tt.wantErr) + } - // ensure cannot be found - i, err := qb.Find(ctx, tt.id) + // ensure cannot be found + i, err := qb.Find(ctx, tt.id) - assert.NotNil(err) - assert.Nil(i) - return nil - }) + assert.NotNil(err) + assert.Nil(i) }) } } @@ -1595,7 +1592,7 @@ func TestImageQueryPathAndRating(t *testing.T) { Modifier: models.CriterionModifierEquals, }, And: &models.ImageFilterType{ - Rating: &models.IntCriterionInput{ + Rating100: &models.IntCriterionInput{ Value: int(imageRating.Int64), Modifier: models.CriterionModifierEquals, }, @@ -1607,7 +1604,10 @@ func TestImageQueryPathAndRating(t *testing.T) { images := queryImages(ctx, t, sqb, &imageFilter, nil) - assert.Len(t, images, 1) + if !assert.Len(t, images, 1) { + return nil + } + assert.Equal(t, imagePath, images[0].Path) assert.Equal(t, int(imageRating.Int64), *images[0].Rating) @@ -1633,7 +1633,7 @@ func TestImageQueryPathNotRating(t *testing.T) { imageFilter := models.ImageFilterType{ Path: &pathCriterion, Not: &models.ImageFilterType{ - Rating: &ratingCriterion, + Rating100: &ratingCriterion, }, } @@ -1688,32 +1688,32 @@ func TestImageIllegalQuery(t *testing.T) { }) } -func TestImageQueryRating(t *testing.T) { +func TestImageQueryLegacyRating(t *testing.T) { const rating = 3 ratingCriterion := models.IntCriterionInput{ Value: rating, Modifier: models.CriterionModifierEquals, } - verifyImagesRating(t, ratingCriterion) + verifyImagesLegacyRating(t, ratingCriterion) ratingCriterion.Modifier = models.CriterionModifierNotEquals - verifyImagesRating(t, ratingCriterion) + verifyImagesLegacyRating(t, ratingCriterion) ratingCriterion.Modifier = models.CriterionModifierGreaterThan - verifyImagesRating(t, ratingCriterion) + verifyImagesLegacyRating(t, ratingCriterion) ratingCriterion.Modifier = models.CriterionModifierLessThan - verifyImagesRating(t, ratingCriterion) + verifyImagesLegacyRating(t, ratingCriterion) ratingCriterion.Modifier = models.CriterionModifierIsNull - verifyImagesRating(t, ratingCriterion) + verifyImagesLegacyRating(t, ratingCriterion) ratingCriterion.Modifier = models.CriterionModifierNotNull - verifyImagesRating(t, ratingCriterion) + verifyImagesLegacyRating(t, ratingCriterion) } -func verifyImagesRating(t *testing.T, ratingCriterion models.IntCriterionInput) { +func verifyImagesLegacyRating(t *testing.T, ratingCriterion models.IntCriterionInput) { withTxn(func(ctx context.Context) error { sqb := db.Image imageFilter := models.ImageFilterType{ @@ -1725,6 +1725,54 @@ func verifyImagesRating(t *testing.T, ratingCriterion models.IntCriterionInput) t.Errorf("Error querying image: %s", err.Error()) } + // convert criterion value to the 100 value + ratingCriterion.Value = models.Rating5To100(ratingCriterion.Value) + + for _, image := range images { + verifyIntPtr(t, image.Rating, ratingCriterion) + } + + return nil + }) +} + +func TestImageQueryRating100(t *testing.T) { + const rating = 60 + ratingCriterion := models.IntCriterionInput{ + Value: rating, + Modifier: models.CriterionModifierEquals, + } + + verifyImagesRating100(t, ratingCriterion) + + ratingCriterion.Modifier = models.CriterionModifierNotEquals + verifyImagesRating100(t, ratingCriterion) + + ratingCriterion.Modifier = models.CriterionModifierGreaterThan + verifyImagesRating100(t, ratingCriterion) + + ratingCriterion.Modifier = models.CriterionModifierLessThan + verifyImagesRating100(t, ratingCriterion) + + ratingCriterion.Modifier = models.CriterionModifierIsNull + verifyImagesRating100(t, ratingCriterion) + + ratingCriterion.Modifier = models.CriterionModifierNotNull + verifyImagesRating100(t, ratingCriterion) +} + +func verifyImagesRating100(t *testing.T, ratingCriterion models.IntCriterionInput) { + withTxn(func(ctx context.Context) error { + sqb := db.Image + imageFilter := models.ImageFilterType{ + Rating100: &ratingCriterion, + } + + images, _, err := queryImagesWithCount(ctx, sqb, &imageFilter, nil) + if err != nil { + t.Errorf("Error querying image: %s", err.Error()) + } + for _, image := range images { verifyIntPtr(t, image.Rating, ratingCriterion) } diff --git a/pkg/sqlite/migrations/32_files.up.sql b/pkg/sqlite/migrations/32_files.up.sql index 8e76b0d37..548f8c620 100644 --- a/pkg/sqlite/migrations/32_files.up.sql +++ b/pkg/sqlite/migrations/32_files.up.sql @@ -458,7 +458,7 @@ INSERT INTO `video_files` ) SELECT `files`.`id`, - `scenes`.`duration`, + COALESCE(`scenes`.`duration`, -1), -- special values for unset to be updated during scan COALESCE(`scenes`.`video_codec`, 'unset'), COALESCE(`scenes`.`format`, 'unset'), diff --git a/pkg/sqlite/migrations/32_postmigrate.go b/pkg/sqlite/migrations/32_postmigrate.go index ed80c9765..4dbd65df8 100644 --- a/pkg/sqlite/migrations/32_postmigrate.go +++ b/pkg/sqlite/migrations/32_postmigrate.go @@ -187,6 +187,10 @@ func (m *schema32Migrator) migrateFiles(ctx context.Context) error { if err != nil { return fmt.Errorf("migrating file %s: %w", p, err) } + } else { + // if we don't reassign from the placeholder, it will fail + // so log a warning at least here + logger.Warnf("Unable to migrate invalid path: %s", p) } lastID = id diff --git a/pkg/sqlite/migrations/35_assoc_tables.up.sql b/pkg/sqlite/migrations/35_assoc_tables.up.sql index bd65d66d4..31266b25a 100644 --- a/pkg/sqlite/migrations/35_assoc_tables.up.sql +++ b/pkg/sqlite/migrations/35_assoc_tables.up.sql @@ -15,7 +15,8 @@ INSERT INTO `performers_image_new` SELECT `performer_id`, `image` - FROM `performers_image`; + FROM `performers_image` WHERE + `performer_id` IS NOT NULL; DROP TABLE `performers_image`; ALTER TABLE `performers_image_new` rename to `performers_image`; @@ -38,7 +39,8 @@ INSERT INTO `studios_image_new` SELECT `studio_id`, `image` - FROM `studios_image`; + FROM `studios_image` WHERE + `studio_id` IS NOT NULL; DROP TABLE `studios_image`; ALTER TABLE `studios_image_new` rename to `studios_image`; @@ -64,7 +66,8 @@ INSERT INTO `movies_images_new` `movie_id`, `front_image`, `back_image` - FROM `movies_images`; + FROM `movies_images` WHERE + `movie_id` IS NOT NULL; DROP TABLE `movies_images`; ALTER TABLE `movies_images_new` rename to `movies_images`; @@ -87,7 +90,8 @@ INSERT INTO `tags_image_new` SELECT `tag_id`, `image` - FROM `tags_image`; + FROM `tags_image` WHERE + `tag_id` IS NOT NULL; DROP TABLE `tags_image`; ALTER TABLE `tags_image_new` rename to `tags_image`; @@ -112,7 +116,8 @@ INSERT INTO `performers_scenes_new` SELECT `performer_id`, `scene_id` - FROM `performers_scenes` WHERE true + FROM `performers_scenes` WHERE + `performer_id` IS NOT NULL AND `scene_id` IS NOT NULL ON CONFLICT (`scene_id`, `performer_id`) DO NOTHING; DROP TABLE `performers_scenes`; @@ -140,7 +145,8 @@ INSERT INTO `scene_markers_tags_new` SELECT `scene_marker_id`, `tag_id` - FROM `scene_markers_tags` WHERE true + FROM `scene_markers_tags` WHERE + `scene_marker_id` IS NOT NULL AND `tag_id` IS NOT NULL ON CONFLICT (`scene_marker_id`, `tag_id`) DO NOTHING; DROP TABLE `scene_markers_tags`; @@ -168,7 +174,8 @@ INSERT INTO `scenes_tags_new` SELECT `scene_id`, `tag_id` - FROM `scenes_tags` WHERE true + FROM `scenes_tags` WHERE + `scene_id` IS NOT NULL AND `tag_id` IS NOT NULL ON CONFLICT (`scene_id`, `tag_id`) DO NOTHING; DROP TABLE `scenes_tags`; @@ -199,7 +206,8 @@ INSERT INTO `movies_scenes_new` `movie_id`, `scene_id`, `scene_index` - FROM `movies_scenes` WHERE true + FROM `movies_scenes` WHERE + `movie_id` IS NOT NULL AND `scene_id` IS NOT NULL ON CONFLICT (`movie_id`, `scene_id`) DO NOTHING; DROP TABLE `movies_scenes`; @@ -225,7 +233,8 @@ INSERT INTO `scenes_cover_new` SELECT `scene_id`, `cover` - FROM `scenes_cover`; + FROM `scenes_cover` WHERE + `scene_id` IS NOT NULL; DROP TABLE `scenes_cover`; ALTER TABLE `scenes_cover_new` rename to `scenes_cover`; @@ -250,7 +259,8 @@ INSERT INTO `performers_images_new` SELECT `performer_id`, `image_id` - FROM `performers_images` WHERE true + FROM `performers_images` WHERE + `performer_id` IS NOT NULL AND `image_id` IS NOT NULL ON CONFLICT (`image_id`, `performer_id`) DO NOTHING; DROP TABLE `performers_images`; @@ -278,7 +288,8 @@ INSERT INTO `images_tags_new` SELECT `image_id`, `tag_id` - FROM `images_tags` WHERE true + FROM `images_tags` WHERE + `image_id` IS NOT NULL AND `tag_id` IS NOT NULL ON CONFLICT (`image_id`, `tag_id`) DO NOTHING; DROP TABLE `images_tags`; @@ -308,7 +319,8 @@ INSERT INTO `scene_stash_ids_new` `scene_id`, `endpoint`, `stash_id` - FROM `scene_stash_ids`; + FROM `scene_stash_ids` WHERE + `scene_id` IS NOT NULL AND `endpoint` IS NOT NULL AND `stash_id` IS NOT NULL; DROP TABLE `scene_stash_ids`; ALTER TABLE `scene_stash_ids_new` rename to `scene_stash_ids`; @@ -333,7 +345,8 @@ INSERT INTO `scenes_galleries_new` SELECT `scene_id`, `gallery_id` - FROM `scenes_galleries` WHERE true + FROM `scenes_galleries` WHERE + `scene_id` IS NOT NULL AND `gallery_id` IS NOT NULL ON CONFLICT (`scene_id`, `gallery_id`) DO NOTHING; DROP TABLE `scenes_galleries`; @@ -361,7 +374,8 @@ INSERT INTO `galleries_images_new` SELECT `gallery_id`, `image_id` - FROM `galleries_images` WHERE true + FROM `galleries_images` WHERE + `image_id` IS NOT NULL AND `gallery_id` IS NOT NULL ON CONFLICT (`gallery_id`, `image_id`) DO NOTHING; DROP TABLE `galleries_images`; @@ -389,7 +403,8 @@ INSERT INTO `performers_galleries_new` SELECT `performer_id`, `gallery_id` - FROM `performers_galleries` WHERE true + FROM `performers_galleries` WHERE + `performer_id` IS NOT NULL AND `gallery_id` IS NOT NULL ON CONFLICT (`gallery_id`, `performer_id`) DO NOTHING; DROP TABLE `performers_galleries`; @@ -417,7 +432,8 @@ INSERT INTO `galleries_tags_new` SELECT `gallery_id`, `tag_id` - FROM `galleries_tags` WHERE true + FROM `galleries_tags` WHERE + `tag_id` IS NOT NULL AND `gallery_id` IS NOT NULL ON CONFLICT (`gallery_id`, `tag_id`) DO NOTHING; DROP TABLE `galleries_tags`; diff --git a/pkg/sqlite/migrations/37_iso_country_names.up.sql b/pkg/sqlite/migrations/37_iso_country_names.up.sql new file mode 100644 index 000000000..1044a17bf --- /dev/null +++ b/pkg/sqlite/migrations/37_iso_country_names.up.sql @@ -0,0 +1,269 @@ +UPDATE `performers` +SET `country` = CASE + WHEN LENGTH(TRIM(`country`)) == 2 THEN TRIM(`country`) + ELSE CASE `country` + WHEN 'Afghanistan' THEN 'AF' + WHEN 'Albania' THEN 'AL' + WHEN 'Algeria' THEN 'DZ' + WHEN 'America' THEN 'US' + WHEN 'American' THEN 'US' + WHEN 'American Samoa' THEN 'AS' + WHEN 'Andorra' THEN 'AD' + WHEN 'Angola' THEN 'AO' + WHEN 'Anguilla' THEN 'AI' + WHEN 'Antarctica' THEN 'AQ' + WHEN 'Antigua and Barbuda' THEN 'AG' + WHEN 'Argentina' THEN 'AR' + WHEN 'Armenia' THEN 'AM' + WHEN 'Aruba' THEN 'AW' + WHEN 'Australia' THEN 'AU' + WHEN 'Austria' THEN 'AT' + WHEN 'Azerbaijan' THEN 'AZ' + WHEN 'Bahamas' THEN 'BS' + WHEN 'Bahrain' THEN 'BH' + WHEN 'Bangladesh' THEN 'BD' + WHEN 'Barbados' THEN 'BB' + WHEN 'Belarus' THEN 'BY' + WHEN 'Belgium' THEN 'BE' + WHEN 'Belize' THEN 'BZ' + WHEN 'Benin' THEN 'BJ' + WHEN 'Bermuda' THEN 'BM' + WHEN 'Bhutan' THEN 'BT' + WHEN 'Bolivia' THEN 'BO' + WHEN 'Bosnia and Herzegovina' THEN 'BA' + WHEN 'Botswana' THEN 'BW' + WHEN 'Bouvet Island' THEN 'BV' + WHEN 'Brazil' THEN 'BR' + WHEN 'British Indian Ocean Territory' THEN 'IO' + WHEN 'Brunei Darussalam' THEN 'BN' + WHEN 'Bulgaria' THEN 'BG' + WHEN 'Burkina Faso' THEN 'BF' + WHEN 'Burundi' THEN 'BI' + WHEN 'Cambodia' THEN 'KH' + WHEN 'Cameroon' THEN 'CM' + WHEN 'Canada' THEN 'CA' + WHEN 'Cape Verde' THEN 'CV' + WHEN 'Cayman Islands' THEN 'KY' + WHEN 'Central African Republic' THEN 'CF' + WHEN 'Chad' THEN 'TD' + WHEN 'Chile' THEN 'CL' + WHEN 'China' THEN 'CN' + WHEN 'Christmas Island' THEN 'CX' + WHEN 'Cocos (Keeling) Islands' THEN 'CC' + WHEN 'Colombia' THEN 'CO' + WHEN 'Comoros' THEN 'KM' + WHEN 'Congo' THEN 'CG' + WHEN 'Congo the Democratic Republic of the' THEN 'CD' + WHEN 'Cook Islands' THEN 'CK' + WHEN 'Costa Rica' THEN 'CR' + WHEN 'Cote D''Ivoire' THEN 'CI' + WHEN 'Croatia' THEN 'HR' + WHEN 'Cuba' THEN 'CU' + WHEN 'Cyprus' THEN 'CY' + WHEN 'Czech Republic' THEN 'CZ' + WHEN 'Czechia' THEN 'CZ' + WHEN 'Denmark' THEN 'DK' + WHEN 'Djibouti' THEN 'DJ' + WHEN 'Dominica' THEN 'DM' + WHEN 'Dominican Republic' THEN 'DO' + WHEN 'Ecuador' THEN 'EC' + WHEN 'Egypt' THEN 'EG' + WHEN 'El Salvador' THEN 'SV' + WHEN 'Equatorial Guinea' THEN 'GQ' + WHEN 'Eritrea' THEN 'ER' + WHEN 'Estonia' THEN 'EE' + WHEN 'Ethiopia' THEN 'ET' + WHEN 'Falkland Islands (Malvinas)' THEN 'FK' + WHEN 'Faroe Islands' THEN 'FO' + WHEN 'Fiji' THEN 'FJ' + WHEN 'Finland' THEN 'FI' + WHEN 'France' THEN 'FR' + WHEN 'French Guiana' THEN 'GF' + WHEN 'French Polynesia' THEN 'PF' + WHEN 'French Southern Territories' THEN 'TF' + WHEN 'Gabon' THEN 'GA' + WHEN 'Gambia' THEN 'GM' + WHEN 'Georgia' THEN 'GE' + WHEN 'Germany' THEN 'DE' + WHEN 'Ghana' THEN 'GH' + WHEN 'Gibraltar' THEN 'GI' + WHEN 'Greece' THEN 'GR' + WHEN 'Greenland' THEN 'GL' + WHEN 'Grenada' THEN 'GD' + WHEN 'Guadeloupe' THEN 'GP' + WHEN 'Guam' THEN 'GU' + WHEN 'Guatemala' THEN 'GT' + WHEN 'Guinea' THEN 'GN' + WHEN 'Guinea-Bissau' THEN 'GW' + WHEN 'Guyana' THEN 'GY' + WHEN 'Haiti' THEN 'HT' + WHEN 'Heard Island and McDonald Islands' THEN 'HM' + WHEN 'Holy See (Vatican City State)' THEN 'VA' + WHEN 'Honduras' THEN 'HN' + WHEN 'Hong Kong' THEN 'HK' + WHEN 'Hungary' THEN 'HU' + WHEN 'Iceland' THEN 'IS' + WHEN 'India' THEN 'IN' + WHEN 'Indonesia' THEN 'ID' + WHEN 'Iran' THEN 'IR' + WHEN 'Iran Islamic Republic of' THEN 'IR' + WHEN 'Iraq' THEN 'IQ' + WHEN 'Ireland' THEN 'IE' + WHEN 'Israel' THEN 'IL' + WHEN 'Italy' THEN 'IT' + WHEN 'Jamaica' THEN 'JM' + WHEN 'Japan' THEN 'JP' + WHEN 'Jordan' THEN 'JO' + WHEN 'Kazakhstan' THEN 'KZ' + WHEN 'Kenya' THEN 'KE' + WHEN 'Kiribati' THEN 'KI' + WHEN 'North Korea' THEN 'KP' + WHEN 'South Korea' THEN 'KR' + WHEN 'Kuwait' THEN 'KW' + WHEN 'Kyrgyzstan' THEN 'KG' + WHEN 'Lao People''s Democratic Republic' THEN 'LA' + WHEN 'Latvia' THEN 'LV' + WHEN 'Lebanon' THEN 'LB' + WHEN 'Lesotho' THEN 'LS' + WHEN 'Liberia' THEN 'LR' + WHEN 'Libya' THEN 'LY' + WHEN 'Liechtenstein' THEN 'LI' + WHEN 'Lithuania' THEN 'LT' + WHEN 'Luxembourg' THEN 'LU' + WHEN 'Macao' THEN 'MO' + WHEN 'Madagascar' THEN 'MG' + WHEN 'Malawi' THEN 'MW' + WHEN 'Malaysia' THEN 'MY' + WHEN 'Maldives' THEN 'MV' + WHEN 'Mali' THEN 'ML' + WHEN 'Malta' THEN 'MT' + WHEN 'Marshall Islands' THEN 'MH' + WHEN 'Martinique' THEN 'MQ' + WHEN 'Mauritania' THEN 'MR' + WHEN 'Mauritius' THEN 'MU' + WHEN 'Mayotte' THEN 'YT' + WHEN 'Mexico' THEN 'MX' + WHEN 'Micronesia Federated States of' THEN 'FM' + WHEN 'Moldova' THEN 'MD' + WHEN 'Moldova Republic of' THEN 'MD' + WHEN 'Moldova, Republic of' THEN 'MD' + WHEN 'Monaco' THEN 'MC' + WHEN 'Mongolia' THEN 'MN' + WHEN 'Montserrat' THEN 'MS' + WHEN 'Morocco' THEN 'MA' + WHEN 'Mozambique' THEN 'MZ' + WHEN 'Myanmar' THEN 'MM' + WHEN 'Namibia' THEN 'NA' + WHEN 'Nauru' THEN 'NR' + WHEN 'Nepal' THEN 'NP' + WHEN 'Netherlands' THEN 'NL' + WHEN 'New Caledonia' THEN 'NC' + WHEN 'New Zealand' THEN 'NZ' + WHEN 'Nicaragua' THEN 'NI' + WHEN 'Niger' THEN 'NE' + WHEN 'Nigeria' THEN 'NG' + WHEN 'Niue' THEN 'NU' + WHEN 'Norfolk Island' THEN 'NF' + WHEN 'North Macedonia Republic of' THEN 'MK' + WHEN 'Northern Mariana Islands' THEN 'MP' + WHEN 'Norway' THEN 'NO' + WHEN 'Oman' THEN 'OM' + WHEN 'Pakistan' THEN 'PK' + WHEN 'Palau' THEN 'PW' + WHEN 'Palestinian Territory Occupied' THEN 'PS' + WHEN 'Panama' THEN 'PA' + WHEN 'Papua New Guinea' THEN 'PG' + WHEN 'Paraguay' THEN 'PY' + WHEN 'Peru' THEN 'PE' + WHEN 'Philippines' THEN 'PH' + WHEN 'Pitcairn' THEN 'PN' + WHEN 'Poland' THEN 'PL' + WHEN 'Portugal' THEN 'PT' + WHEN 'Puerto Rico' THEN 'PR' + WHEN 'Qatar' THEN 'QA' + WHEN 'Reunion' THEN 'RE' + WHEN 'Romania' THEN 'RO' + WHEN 'Russia' THEN 'RU' + WHEN 'Russian Federation' THEN 'RU' + WHEN 'Rwanda' THEN 'RW' + WHEN 'Saint Helena' THEN 'SH' + WHEN 'Saint Kitts and Nevis' THEN 'KN' + WHEN 'Saint Lucia' THEN 'LC' + WHEN 'Saint Pierre and Miquelon' THEN 'PM' + WHEN 'Saint Vincent and the Grenadines' THEN 'VC' + WHEN 'Samoa' THEN 'WS' + WHEN 'San Marino' THEN 'SM' + WHEN 'Sao Tome and Principe' THEN 'ST' + WHEN 'Saudi Arabia' THEN 'SA' + WHEN 'Senegal' THEN 'SN' + WHEN 'Seychelles' THEN 'SC' + WHEN 'Sierra Leone' THEN 'SL' + WHEN 'Singapore' THEN 'SG' + WHEN 'Slovakia' THEN 'SK' + WHEN 'Slovak Republic' THEN 'SK' + WHEN 'Slovenia' THEN 'SI' + WHEN 'Solomon Islands' THEN 'SB' + WHEN 'Somalia' THEN 'SO' + WHEN 'South Africa' THEN 'ZA' + WHEN 'South Georgia and the South Sandwich Islands' THEN 'GS' + WHEN 'Spain' THEN 'ES' + WHEN 'Sri Lanka' THEN 'LK' + WHEN 'Sudan' THEN 'SD' + WHEN 'Suriname' THEN 'SR' + WHEN 'Svalbard and Jan Mayen' THEN 'SJ' + WHEN 'Eswatini' THEN 'SZ' + WHEN 'Sweden' THEN 'SE' + WHEN 'Switzerland' THEN 'CH' + WHEN 'Syrian Arab Republic' THEN 'SY' + WHEN 'Taiwan' THEN 'TW' + WHEN 'Tajikistan' THEN 'TJ' + WHEN 'Tanzania United Republic of' THEN 'TZ' + WHEN 'Thailand' THEN 'TH' + WHEN 'Timor-Leste' THEN 'TL' + WHEN 'Togo' THEN 'TG' + WHEN 'Tokelau' THEN 'TK' + WHEN 'Tonga' THEN 'TO' + WHEN 'Trinidad and Tobago' THEN 'TT' + WHEN 'Tunisia' THEN 'TN' + WHEN 'Turkey' THEN 'TR' + WHEN 'Turkmenistan' THEN 'TM' + WHEN 'Turks and Caicos Islands' THEN 'TC' + WHEN 'Tuvalu' THEN 'TV' + WHEN 'Uganda' THEN 'UG' + WHEN 'Ukraine' THEN 'UA' + WHEN 'United Arab Emirates' THEN 'AE' + WHEN 'England' THEN 'GB' + WHEN 'Great Britain' THEN 'GB' + WHEN 'United Kingdom' THEN 'GB' + WHEN 'USA' THEN 'US' + WHEN 'United States' THEN 'US' + WHEN 'United States of America' THEN 'US' + WHEN 'United States Minor Outlying Islands' THEN 'UM' + WHEN 'Uruguay' THEN 'UY' + WHEN 'Uzbekistan' THEN 'UZ' + WHEN 'Vanuatu' THEN 'VU' + WHEN 'Venezuela' THEN 'VE' + WHEN 'Vietnam' THEN 'VN' + WHEN 'Virgin Islands British' THEN 'VG' + WHEN 'Virgin Islands U.S.' THEN 'VI' + WHEN 'Wallis and Futuna' THEN 'WF' + WHEN 'Western Sahara' THEN 'EH' + WHEN 'Yemen' THEN 'YE' + WHEN 'Zambia' THEN 'ZM' + WHEN 'Zimbabwe' THEN 'ZW' + WHEN 'Åland Islands' THEN 'AX' + WHEN 'Bonaire Sint Eustatius and Saba' THEN 'BQ' + WHEN 'Curaçao' THEN 'CW' + WHEN 'Guernsey' THEN 'GG' + WHEN 'Isle of Man' THEN 'IM' + WHEN 'Jersey' THEN 'JE' + WHEN 'Montenegro' THEN 'ME' + WHEN 'Saint Barthélemy' THEN 'BL' + WHEN 'Saint Martin (French part)' THEN 'MF' + WHEN 'Serbia' THEN 'RS' + WHEN 'Sint Maarten (Dutch part)' THEN 'SX' + WHEN 'South Sudan' THEN 'SS' + WHEN 'Kosovo' THEN 'XK' + ELSE `country` + END +END; diff --git a/pkg/sqlite/migrations/38_scenes_director_code.up.sql b/pkg/sqlite/migrations/38_scenes_director_code.up.sql new file mode 100644 index 000000000..0252f4ea0 --- /dev/null +++ b/pkg/sqlite/migrations/38_scenes_director_code.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE `scenes` ADD COLUMN `code` text; +ALTER TABLE `scenes` ADD COLUMN `director` text; diff --git a/pkg/sqlite/migrations/39_performer_height.up.sql b/pkg/sqlite/migrations/39_performer_height.up.sql new file mode 100644 index 000000000..4203405ce --- /dev/null +++ b/pkg/sqlite/migrations/39_performer_height.up.sql @@ -0,0 +1,103 @@ +-- add primary keys to association tables that are missing them +PRAGMA foreign_keys=OFF; + +CREATE TABLE `performers_new` ( + `id` integer not null primary key autoincrement, + `checksum` varchar(255) not null, + `name` varchar(255), + `gender` varchar(20), + `url` varchar(255), + `twitter` varchar(255), + `instagram` varchar(255), + `birthdate` date, + `ethnicity` varchar(255), + `country` varchar(255), + `eye_color` varchar(255), + -- changed from varchar(255) + `height` int, + `measurements` varchar(255), + `fake_tits` varchar(255), + `career_length` varchar(255), + `tattoos` varchar(255), + `piercings` varchar(255), + `aliases` varchar(255), + `favorite` boolean not null default '0', + `created_at` datetime not null, + `updated_at` datetime not null, + `details` text, + `death_date` date, + `hair_color` varchar(255), + `weight` integer, + `rating` tinyint, + `ignore_auto_tag` boolean not null default '0' +); + +INSERT INTO `performers_new` + ( + `id`, + `checksum`, + `name`, + `gender`, + `url`, + `twitter`, + `instagram`, + `birthdate`, + `ethnicity`, + `country`, + `eye_color`, + `height`, + `measurements`, + `fake_tits`, + `career_length`, + `tattoos`, + `piercings`, + `aliases`, + `favorite`, + `created_at`, + `updated_at`, + `details`, + `death_date`, + `hair_color`, + `weight`, + `rating`, + `ignore_auto_tag` + ) + SELECT + `id`, + `checksum`, + `name`, + `gender`, + `url`, + `twitter`, + `instagram`, + `birthdate`, + `ethnicity`, + `country`, + `eye_color`, + CASE `height` + WHEN '' THEN NULL + WHEN NULL THEN NULL + ELSE CAST(`height` as int) + END, + `measurements`, + `fake_tits`, + `career_length`, + `tattoos`, + `piercings`, + `aliases`, + `favorite`, + `created_at`, + `updated_at`, + `details`, + `death_date`, + `hair_color`, + `weight`, + `rating`, + `ignore_auto_tag` + FROM `performers`; + +DROP TABLE `performers`; +ALTER TABLE `performers_new` rename to `performers`; + +CREATE UNIQUE INDEX `performers_checksum_unique` on `performers` (`checksum`); +CREATE INDEX `index_performers_on_name` on `performers` (`name`); diff --git a/pkg/sqlite/migrations/40_newratings.up.sql b/pkg/sqlite/migrations/40_newratings.up.sql new file mode 100644 index 000000000..37b7ade9f --- /dev/null +++ b/pkg/sqlite/migrations/40_newratings.up.sql @@ -0,0 +1,6 @@ +UPDATE `scenes` SET `rating` = (`rating` * 20) WHERE `rating` < 6; +UPDATE `galleries` SET `rating` = (`rating` * 20) WHERE `rating` < 6; +UPDATE `images` SET `rating` = (`rating` * 20) WHERE `rating` < 6; +UPDATE `movies` SET `rating` = (`rating` * 20) WHERE `rating` < 6; +UPDATE `performers` SET `rating` = (`rating` * 20) WHERE `rating` < 6; +UPDATE `studios` SET `rating` = (`rating` * 20) WHERE `rating` < 6; \ No newline at end of file diff --git a/pkg/sqlite/migrations/41_scene_activity.up.sql b/pkg/sqlite/migrations/41_scene_activity.up.sql new file mode 100644 index 000000000..2e7c70a16 --- /dev/null +++ b/pkg/sqlite/migrations/41_scene_activity.up.sql @@ -0,0 +1,4 @@ +ALTER TABLE `scenes` ADD COLUMN `resume_time` float not null default 0; +ALTER TABLE `scenes` ADD COLUMN `last_played_at` datetime default null; +ALTER TABLE `scenes` ADD COLUMN `play_count` tinyint not null default 0; +ALTER TABLE `scenes` ADD COLUMN `play_duration` float not null default 0; \ No newline at end of file diff --git a/pkg/sqlite/movies.go b/pkg/sqlite/movies.go index 388b26947..a3b0e2f2b 100644 --- a/pkg/sqlite/movies.go +++ b/pkg/sqlite/movies.go @@ -147,12 +147,17 @@ func (qb *movieQueryBuilder) makeFilter(ctx context.Context, movieFilter *models query.handleCriterion(ctx, stringCriterionHandler(movieFilter.Name, "movies.name")) query.handleCriterion(ctx, stringCriterionHandler(movieFilter.Director, "movies.director")) query.handleCriterion(ctx, stringCriterionHandler(movieFilter.Synopsis, "movies.synopsis")) - query.handleCriterion(ctx, intCriterionHandler(movieFilter.Rating, "movies.rating", nil)) - query.handleCriterion(ctx, durationCriterionHandler(movieFilter.Duration, "movies.duration", nil)) + query.handleCriterion(ctx, intCriterionHandler(movieFilter.Rating100, "movies.rating", nil)) + // legacy rating handler + query.handleCriterion(ctx, rating5CriterionHandler(movieFilter.Rating, "movies.rating", nil)) + query.handleCriterion(ctx, floatIntCriterionHandler(movieFilter.Duration, "movies.duration", nil)) query.handleCriterion(ctx, movieIsMissingCriterionHandler(qb, movieFilter.IsMissing)) query.handleCriterion(ctx, stringCriterionHandler(movieFilter.URL, "movies.url")) query.handleCriterion(ctx, movieStudioCriterionHandler(qb, movieFilter.Studios)) query.handleCriterion(ctx, moviePerformersCriterionHandler(qb, movieFilter.Performers)) + query.handleCriterion(ctx, dateCriterionHandler(movieFilter.Date, "movies.date")) + query.handleCriterion(ctx, timestampCriterionHandler(movieFilter.CreatedAt, "movies.created_at")) + query.handleCriterion(ctx, timestampCriterionHandler(movieFilter.UpdatedAt, "movies.updated_at")) return query } diff --git a/pkg/sqlite/performer.go b/pkg/sqlite/performer.go index 6bf42dc18..fae593ba6 100644 --- a/pkg/sqlite/performer.go +++ b/pkg/sqlite/performer.go @@ -3,15 +3,18 @@ package sqlite import ( "context" "database/sql" - "errors" "fmt" + "strconv" "strings" "github.com/doug-martin/goqu/v9" + "github.com/doug-martin/goqu/v9/exp" "github.com/jmoiron/sqlx" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/sliceutil/intslice" "github.com/stashapp/stash/pkg/utils" + "gopkg.in/guregu/null.v4" + "gopkg.in/guregu/null.v4/zero" ) const performerTable = "performers" @@ -19,82 +22,229 @@ const performerIDColumn = "performer_id" const performersTagsTable = "performers_tags" const performersImageTable = "performers_image" // performer cover image -var countPerformersForTagQuery = ` -SELECT tag_id AS id FROM performers_tags -WHERE performers_tags.tag_id = ? -GROUP BY performers_tags.performer_id -` +type performerRow struct { + ID int `db:"id" goqu:"skipinsert"` + Checksum string `db:"checksum"` + Name zero.String `db:"name"` + Gender zero.String `db:"gender"` + URL zero.String `db:"url"` + Twitter zero.String `db:"twitter"` + Instagram zero.String `db:"instagram"` + Birthdate models.SQLiteDate `db:"birthdate"` + Ethnicity zero.String `db:"ethnicity"` + Country zero.String `db:"country"` + EyeColor zero.String `db:"eye_color"` + Height null.Int `db:"height"` + Measurements zero.String `db:"measurements"` + FakeTits zero.String `db:"fake_tits"` + CareerLength zero.String `db:"career_length"` + Tattoos zero.String `db:"tattoos"` + Piercings zero.String `db:"piercings"` + Aliases zero.String `db:"aliases"` + Favorite sql.NullBool `db:"favorite"` + CreatedAt models.SQLiteTimestamp `db:"created_at"` + UpdatedAt models.SQLiteTimestamp `db:"updated_at"` + // expressed as 1-100 + Rating null.Int `db:"rating"` + Details zero.String `db:"details"` + DeathDate models.SQLiteDate `db:"death_date"` + HairColor zero.String `db:"hair_color"` + Weight null.Int `db:"weight"` + IgnoreAutoTag bool `db:"ignore_auto_tag"` +} -type performerQueryBuilder struct { +func (r *performerRow) fromPerformer(o models.Performer) { + r.ID = o.ID + r.Checksum = o.Checksum + r.Name = zero.StringFrom(o.Name) + if o.Gender.IsValid() { + r.Gender = zero.StringFrom(o.Gender.String()) + } + r.URL = zero.StringFrom(o.URL) + r.Twitter = zero.StringFrom(o.Twitter) + r.Instagram = zero.StringFrom(o.Instagram) + if o.Birthdate != nil { + _ = r.Birthdate.Scan(o.Birthdate.Time) + } + r.Ethnicity = zero.StringFrom(o.Ethnicity) + r.Country = zero.StringFrom(o.Country) + r.EyeColor = zero.StringFrom(o.EyeColor) + r.Height = intFromPtr(o.Height) + r.Measurements = zero.StringFrom(o.Measurements) + r.FakeTits = zero.StringFrom(o.FakeTits) + r.CareerLength = zero.StringFrom(o.CareerLength) + r.Tattoos = zero.StringFrom(o.Tattoos) + r.Piercings = zero.StringFrom(o.Piercings) + r.Aliases = zero.StringFrom(o.Aliases) + r.Favorite = sql.NullBool{Bool: o.Favorite, Valid: true} + r.CreatedAt = models.SQLiteTimestamp{Timestamp: o.CreatedAt} + r.UpdatedAt = models.SQLiteTimestamp{Timestamp: o.UpdatedAt} + r.Rating = intFromPtr(o.Rating) + r.Details = zero.StringFrom(o.Details) + if o.DeathDate != nil { + _ = r.DeathDate.Scan(o.DeathDate.Time) + } + r.HairColor = zero.StringFrom(o.HairColor) + r.Weight = intFromPtr(o.Weight) + r.IgnoreAutoTag = o.IgnoreAutoTag +} + +func (r *performerRow) resolve() *models.Performer { + ret := &models.Performer{ + ID: r.ID, + Checksum: r.Checksum, + Name: r.Name.String, + Gender: models.GenderEnum(r.Gender.String), + URL: r.URL.String, + Twitter: r.Twitter.String, + Instagram: r.Instagram.String, + Birthdate: r.Birthdate.DatePtr(), + Ethnicity: r.Ethnicity.String, + Country: r.Country.String, + EyeColor: r.EyeColor.String, + Height: nullIntPtr(r.Height), + Measurements: r.Measurements.String, + FakeTits: r.FakeTits.String, + CareerLength: r.CareerLength.String, + Tattoos: r.Tattoos.String, + Piercings: r.Piercings.String, + Aliases: r.Aliases.String, + Favorite: r.Favorite.Bool, + CreatedAt: r.CreatedAt.Timestamp, + UpdatedAt: r.UpdatedAt.Timestamp, + // expressed as 1-100 + Rating: nullIntPtr(r.Rating), + Details: r.Details.String, + DeathDate: r.DeathDate.DatePtr(), + HairColor: r.HairColor.String, + Weight: nullIntPtr(r.Weight), + IgnoreAutoTag: r.IgnoreAutoTag, + } + + return ret +} + +type performerRowRecord struct { + updateRecord +} + +func (r *performerRowRecord) fromPartial(o models.PerformerPartial) { + r.setNullString("checksum", o.Checksum) + r.setNullString("name", o.Name) + r.setNullString("gender", o.Gender) + r.setNullString("url", o.URL) + r.setNullString("twitter", o.Twitter) + r.setNullString("instagram", o.Instagram) + r.setSQLiteDate("birthdate", o.Birthdate) + r.setNullString("ethnicity", o.Ethnicity) + r.setNullString("country", o.Country) + r.setNullString("eye_color", o.EyeColor) + r.setNullInt("height", o.Height) + r.setNullString("measurements", o.Measurements) + r.setNullString("fake_tits", o.FakeTits) + r.setNullString("career_length", o.CareerLength) + r.setNullString("tattoos", o.Tattoos) + r.setNullString("piercings", o.Piercings) + r.setNullString("aliases", o.Aliases) + r.setBool("favorite", o.Favorite) + r.setSQLiteTimestamp("created_at", o.CreatedAt) + r.setSQLiteTimestamp("updated_at", o.UpdatedAt) + r.setNullInt("rating", o.Rating) + r.setNullString("details", o.Details) + r.setSQLiteDate("death_date", o.DeathDate) + r.setNullString("hair_color", o.HairColor) + r.setNullInt("weight", o.Weight) + r.setBool("ignore_auto_tag", o.IgnoreAutoTag) +} + +type PerformerStore struct { repository + + tableMgr *table } -var PerformerReaderWriter = &performerQueryBuilder{ - repository{ - tableName: performerTable, - idColumn: idColumn, - }, +func NewPerformerStore() *PerformerStore { + return &PerformerStore{ + repository: repository{ + tableName: performerTable, + idColumn: idColumn, + }, + tableMgr: performerTableMgr, + } } -func (qb *performerQueryBuilder) Create(ctx context.Context, newObject models.Performer) (*models.Performer, error) { - var ret models.Performer - if err := qb.insertObject(ctx, newObject, &ret); err != nil { - return nil, err - } +func (qb *PerformerStore) Create(ctx context.Context, newObject *models.Performer) error { + var r performerRow + r.fromPerformer(*newObject) - return &ret, nil -} - -func (qb *performerQueryBuilder) Update(ctx context.Context, updatedObject models.PerformerPartial) (*models.Performer, error) { - const partial = true - if err := qb.update(ctx, updatedObject.ID, updatedObject, partial); err != nil { - return nil, err - } - - var ret models.Performer - if err := qb.getByID(ctx, updatedObject.ID, &ret); err != nil { - return nil, err - } - - return &ret, nil -} - -func (qb *performerQueryBuilder) UpdateFull(ctx context.Context, updatedObject models.Performer) (*models.Performer, error) { - const partial = false - if err := qb.update(ctx, updatedObject.ID, updatedObject, partial); err != nil { - return nil, err - } - - var ret models.Performer - if err := qb.getByID(ctx, updatedObject.ID, &ret); err != nil { - return nil, err - } - - return &ret, nil -} - -func (qb *performerQueryBuilder) Destroy(ctx context.Context, id int) error { - // TODO - add on delete cascade to performers_scenes - _, err := qb.tx.Exec(ctx, "DELETE FROM performers_scenes WHERE performer_id = ?", id) + id, err := qb.tableMgr.insertID(ctx, r) if err != nil { return err } + updated, err := qb.Find(ctx, id) + if err != nil { + return fmt.Errorf("finding after create: %w", err) + } + + *newObject = *updated + + return nil +} + +func (qb *PerformerStore) UpdatePartial(ctx context.Context, id int, updatedObject models.PerformerPartial) (*models.Performer, error) { + r := performerRowRecord{ + updateRecord{ + Record: make(exp.Record), + }, + } + + r.fromPartial(updatedObject) + + if len(r.Record) > 0 { + if err := qb.tableMgr.updateByID(ctx, id, r.Record); err != nil { + return nil, err + } + } + + return qb.Find(ctx, id) +} + +func (qb *PerformerStore) Update(ctx context.Context, updatedObject *models.Performer) error { + var r performerRow + r.fromPerformer(*updatedObject) + + if err := qb.tableMgr.updateByID(ctx, updatedObject.ID, r); err != nil { + return err + } + + return nil +} + +func (qb *PerformerStore) Destroy(ctx context.Context, id int) error { return qb.destroyExisting(ctx, []int{id}) } -func (qb *performerQueryBuilder) Find(ctx context.Context, id int) (*models.Performer, error) { - var ret models.Performer - if err := qb.getByID(ctx, id, &ret); err != nil { - if errors.Is(err, sql.ErrNoRows) { - return nil, nil - } - return nil, err - } - return &ret, nil +func (qb *PerformerStore) table() exp.IdentifierExpression { + return qb.tableMgr.table } -func (qb *performerQueryBuilder) FindMany(ctx context.Context, ids []int) ([]*models.Performer, error) { +func (qb *PerformerStore) selectDataset() *goqu.SelectDataset { + return dialect.From(qb.table()).Select(qb.table().All()) +} + +func (qb *PerformerStore) Find(ctx context.Context, id int) (*models.Performer, error) { + q := qb.selectDataset().Where(qb.tableMgr.byID(id)) + + ret, err := qb.get(ctx, q) + if err != nil { + return nil, fmt.Errorf("getting scene by id %d: %w", id, err) + } + + return ret, nil +} + +func (qb *PerformerStore) FindMany(ctx context.Context, ids []int) ([]*models.Performer, error) { tableMgr := performerTableMgr q := goqu.Select("*").From(tableMgr.table).Where(tableMgr.byIDInts(ids...)) unsorted, err := qb.getMany(ctx, q) @@ -118,16 +268,31 @@ func (qb *performerQueryBuilder) FindMany(ctx context.Context, ids []int) ([]*mo return ret, nil } -func (qb *performerQueryBuilder) getMany(ctx context.Context, q *goqu.SelectDataset) ([]*models.Performer, error) { +func (qb *PerformerStore) get(ctx context.Context, q *goqu.SelectDataset) (*models.Performer, error) { + ret, err := qb.getMany(ctx, q) + if err != nil { + return nil, err + } + + if len(ret) == 0 { + return nil, sql.ErrNoRows + } + + return ret[0], nil +} + +func (qb *PerformerStore) getMany(ctx context.Context, q *goqu.SelectDataset) ([]*models.Performer, error) { const single = false var ret []*models.Performer if err := queryFunc(ctx, q, single, func(r *sqlx.Rows) error { - var f models.Performer + var f performerRow if err := r.StructScan(&f); err != nil { return err } - ret = append(ret, &f) + s := f.resolve() + + ret = append(ret, s) return nil }); err != nil { return nil, err @@ -136,95 +301,127 @@ func (qb *performerQueryBuilder) getMany(ctx context.Context, q *goqu.SelectData return ret, nil } -func (qb *performerQueryBuilder) FindBySceneID(ctx context.Context, sceneID int) ([]*models.Performer, error) { - query := selectAll("performers") + ` - LEFT JOIN performers_scenes as scenes_join on scenes_join.performer_id = performers.id - WHERE scenes_join.scene_id = ? - ` - args := []interface{}{sceneID} - return qb.queryPerformers(ctx, query, args) +func (qb *PerformerStore) findBySubquery(ctx context.Context, sq *goqu.SelectDataset) ([]*models.Performer, error) { + table := qb.table() + + q := qb.selectDataset().Where( + table.Col(idColumn).Eq( + sq, + ), + ) + + return qb.getMany(ctx, q) } -func (qb *performerQueryBuilder) FindByImageID(ctx context.Context, imageID int) ([]*models.Performer, error) { - query := selectAll("performers") + ` - LEFT JOIN performers_images as images_join on images_join.performer_id = performers.id - WHERE images_join.image_id = ? - ` - args := []interface{}{imageID} - return qb.queryPerformers(ctx, query, args) -} +func (qb *PerformerStore) FindBySceneID(ctx context.Context, sceneID int) ([]*models.Performer, error) { + sq := dialect.From(scenesPerformersJoinTable).Select(scenesPerformersJoinTable.Col(performerIDColumn)).Where( + scenesPerformersJoinTable.Col(sceneIDColumn).Eq(sceneID), + ) + ret, err := qb.findBySubquery(ctx, sq) -func (qb *performerQueryBuilder) FindByGalleryID(ctx context.Context, galleryID int) ([]*models.Performer, error) { - query := selectAll("performers") + ` - LEFT JOIN performers_galleries as galleries_join on galleries_join.performer_id = performers.id - WHERE galleries_join.gallery_id = ? - ` - args := []interface{}{galleryID} - return qb.queryPerformers(ctx, query, args) -} - -func (qb *performerQueryBuilder) FindNamesBySceneID(ctx context.Context, sceneID int) ([]*models.Performer, error) { - query := ` - SELECT performers.name FROM performers - LEFT JOIN performers_scenes as scenes_join on scenes_join.performer_id = performers.id - WHERE scenes_join.scene_id = ? - ` - args := []interface{}{sceneID} - return qb.queryPerformers(ctx, query, args) -} - -func (qb *performerQueryBuilder) FindByNames(ctx context.Context, names []string, nocase bool) ([]*models.Performer, error) { - query := "SELECT * FROM performers WHERE name" - if nocase { - query += " COLLATE NOCASE" + if err != nil { + return nil, fmt.Errorf("getting performers for scene %d: %w", sceneID, err) } - query += " IN " + getInBinding(len(names)) + + return ret, nil +} + +func (qb *PerformerStore) FindByImageID(ctx context.Context, imageID int) ([]*models.Performer, error) { + sq := dialect.From(performersImagesJoinTable).Select(performersImagesJoinTable.Col(performerIDColumn)).Where( + performersImagesJoinTable.Col(imageIDColumn).Eq(imageID), + ) + ret, err := qb.findBySubquery(ctx, sq) + + if err != nil { + return nil, fmt.Errorf("getting performers for image %d: %w", imageID, err) + } + + return ret, nil +} + +func (qb *PerformerStore) FindByGalleryID(ctx context.Context, galleryID int) ([]*models.Performer, error) { + sq := dialect.From(performersGalleriesJoinTable).Select(performersGalleriesJoinTable.Col(performerIDColumn)).Where( + performersGalleriesJoinTable.Col(galleryIDColumn).Eq(galleryID), + ) + ret, err := qb.findBySubquery(ctx, sq) + + if err != nil { + return nil, fmt.Errorf("getting performers for gallery %d: %w", galleryID, err) + } + + return ret, nil +} + +func (qb *PerformerStore) FindByNames(ctx context.Context, names []string, nocase bool) ([]*models.Performer, error) { + clause := "name " + if nocase { + clause += "COLLATE NOCASE " + } + clause += "IN " + getInBinding(len(names)) var args []interface{} for _, name := range names { args = append(args, name) } - return qb.queryPerformers(ctx, query, args) -} -func (qb *performerQueryBuilder) CountByTagID(ctx context.Context, tagID int) (int, error) { - args := []interface{}{tagID} - return qb.runCountQuery(ctx, qb.buildCountQuery(countPerformersForTagQuery), args) -} + sq := qb.selectDataset().Prepared(true).Where( + goqu.L(clause, args...), + ) + ret, err := qb.getMany(ctx, sq) -func (qb *performerQueryBuilder) Count(ctx context.Context) (int, error) { - return qb.runCountQuery(ctx, qb.buildCountQuery("SELECT performers.id FROM performers"), nil) -} - -func (qb *performerQueryBuilder) All(ctx context.Context) ([]*models.Performer, error) { - return qb.queryPerformers(ctx, selectAll("performers")+qb.getPerformerSort(nil), nil) -} - -func (qb *performerQueryBuilder) QueryForAutoTag(ctx context.Context, words []string) ([]*models.Performer, error) { - // TODO - Query needs to be changed to support queries of this type, and - // this method should be removed - query := selectAll(performerTable) - - var whereClauses []string - var args []interface{} - - for _, w := range words { - whereClauses = append(whereClauses, "name like ?") - args = append(args, w+"%") - // TODO - commented out until alias matching works both ways - // whereClauses = append(whereClauses, "aliases like ?") - // args = append(args, w+"%") + if err != nil { + return nil, fmt.Errorf("getting performers by names: %w", err) } - whereOr := "(" + strings.Join(whereClauses, " OR ") + ")" - where := strings.Join([]string{ - "ignore_auto_tag = 0", - whereOr, - }, " AND ") - return qb.queryPerformers(ctx, query+" WHERE "+where, args) + return ret, nil } -func (qb *performerQueryBuilder) validateFilter(filter *models.PerformerFilterType) error { +func (qb *PerformerStore) CountByTagID(ctx context.Context, tagID int) (int, error) { + joinTable := performersTagsJoinTable + + q := dialect.Select(goqu.COUNT("*")).From(joinTable).Where(joinTable.Col(tagIDColumn).Eq(tagID)) + return count(ctx, q) +} + +func (qb *PerformerStore) Count(ctx context.Context) (int, error) { + q := dialect.Select(goqu.COUNT("*")).From(qb.table()) + return count(ctx, q) +} + +func (qb *PerformerStore) All(ctx context.Context) ([]*models.Performer, error) { + table := qb.table() + return qb.getMany(ctx, qb.selectDataset().Order(table.Col("name").Asc())) +} + +func (qb *PerformerStore) QueryForAutoTag(ctx context.Context, words []string) ([]*models.Performer, error) { + // TODO - Query needs to be changed to support queries of this type, and + // this method should be removed + table := qb.table() + sq := dialect.From(table).Select(table.Col(idColumn)).Where() + + var whereClauses []exp.Expression + + for _, w := range words { + whereClauses = append(whereClauses, table.Col("name").Like(w+"%")) + // TODO - commented out until alias matching works both ways + // whereClauses = append(whereClauses, table.Col("aliases").Like(w+"%") + } + + sq = sq.Where( + goqu.Or(whereClauses...), + table.Col("ignore_auto_tag").Eq(0), + ) + + ret, err := qb.findBySubquery(ctx, sq) + + if err != nil { + return nil, fmt.Errorf("getting performers for autotag: %w", err) + } + + return ret, nil +} + +func (qb *PerformerStore) validateFilter(filter *models.PerformerFilterType) error { const and = "AND" const or = "OR" const not = "NOT" @@ -252,10 +449,26 @@ func (qb *performerQueryBuilder) validateFilter(filter *models.PerformerFilterTy return qb.validateFilter(filter.Not) } + // if legacy height filter used, ensure only supported modifiers are used + if filter.Height != nil { + // treat as an int filter + intCrit := &models.IntCriterionInput{ + Modifier: filter.Height.Modifier, + } + if !intCrit.ValidModifier() { + return fmt.Errorf("invalid height modifier: %s", filter.Height.Modifier) + } + + // ensure value is a valid number + if _, err := strconv.Atoi(filter.Height.Value); err != nil { + return fmt.Errorf("invalid height value: %s", filter.Height.Value) + } + } + return nil } -func (qb *performerQueryBuilder) makeFilter(ctx context.Context, filter *models.PerformerFilterType) *filterBuilder { +func (qb *PerformerStore) makeFilter(ctx context.Context, filter *models.PerformerFilterType) *filterBuilder { query := &filterBuilder{} if filter.And != nil { @@ -290,13 +503,27 @@ func (qb *performerQueryBuilder) makeFilter(ctx context.Context, filter *models. query.handleCriterion(ctx, stringCriterionHandler(filter.Ethnicity, tableName+".ethnicity")) query.handleCriterion(ctx, stringCriterionHandler(filter.Country, tableName+".country")) query.handleCriterion(ctx, stringCriterionHandler(filter.EyeColor, tableName+".eye_color")) - query.handleCriterion(ctx, stringCriterionHandler(filter.Height, tableName+".height")) + + // special handler for legacy height filter + heightCmCrit := filter.HeightCm + if heightCmCrit == nil && filter.Height != nil { + heightCm, _ := strconv.Atoi(filter.Height.Value) // already validated + heightCmCrit = &models.IntCriterionInput{ + Value: heightCm, + Modifier: filter.Height.Modifier, + } + } + + query.handleCriterion(ctx, intCriterionHandler(heightCmCrit, tableName+".height", nil)) + query.handleCriterion(ctx, stringCriterionHandler(filter.Measurements, tableName+".measurements")) query.handleCriterion(ctx, stringCriterionHandler(filter.FakeTits, tableName+".fake_tits")) query.handleCriterion(ctx, stringCriterionHandler(filter.CareerLength, tableName+".career_length")) query.handleCriterion(ctx, stringCriterionHandler(filter.Tattoos, tableName+".tattoos")) query.handleCriterion(ctx, stringCriterionHandler(filter.Piercings, tableName+".piercings")) - query.handleCriterion(ctx, intCriterionHandler(filter.Rating, tableName+".rating", nil)) + query.handleCriterion(ctx, intCriterionHandler(filter.Rating100, tableName+".rating", nil)) + // legacy rating handler + query.handleCriterion(ctx, rating5CriterionHandler(filter.Rating, tableName+".rating", nil)) query.handleCriterion(ctx, stringCriterionHandler(filter.HairColor, tableName+".hair_color")) query.handleCriterion(ctx, stringCriterionHandler(filter.URL, tableName+".url")) query.handleCriterion(ctx, intCriterionHandler(filter.Weight, tableName+".weight", nil)) @@ -306,6 +533,12 @@ func (qb *performerQueryBuilder) makeFilter(ctx context.Context, filter *models. stringCriterionHandler(filter.StashID, "performer_stash_ids.stash_id")(ctx, f) } })) + query.handleCriterion(ctx, &stashIDCriterionHandler{ + c: filter.StashIDEndpoint, + stashIDRepository: qb.stashIDRepository(), + stashIDTableAs: "performer_stash_ids", + parentIDCol: "performers.id", + }) // TODO - need better handling of aliases query.handleCriterion(ctx, stringCriterionHandler(filter.Aliases, tableName+".aliases")) @@ -318,11 +551,15 @@ func (qb *performerQueryBuilder) makeFilter(ctx context.Context, filter *models. query.handleCriterion(ctx, performerSceneCountCriterionHandler(qb, filter.SceneCount)) query.handleCriterion(ctx, performerImageCountCriterionHandler(qb, filter.ImageCount)) query.handleCriterion(ctx, performerGalleryCountCriterionHandler(qb, filter.GalleryCount)) + query.handleCriterion(ctx, dateCriterionHandler(filter.Birthdate, tableName+".birthdate")) + query.handleCriterion(ctx, dateCriterionHandler(filter.DeathDate, tableName+".death_date")) + query.handleCriterion(ctx, timestampCriterionHandler(filter.CreatedAt, tableName+".created_at")) + query.handleCriterion(ctx, timestampCriterionHandler(filter.UpdatedAt, tableName+".updated_at")) return query } -func (qb *performerQueryBuilder) Query(ctx context.Context, performerFilter *models.PerformerFilterType, findFilter *models.FindFilterType) ([]*models.Performer, int, error) { +func (qb *PerformerStore) Query(ctx context.Context, performerFilter *models.PerformerFilterType, findFilter *models.FindFilterType) ([]*models.Performer, int, error) { if performerFilter == nil { performerFilter = &models.PerformerFilterType{} } @@ -359,7 +596,7 @@ func (qb *performerQueryBuilder) Query(ctx context.Context, performerFilter *mod return performers, countResult, nil } -func performerIsMissingCriterionHandler(qb *performerQueryBuilder, isMissing *string) criterionHandlerFunc { +func performerIsMissingCriterionHandler(qb *PerformerStore, isMissing *string) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if isMissing != nil && *isMissing != "" { switch *isMissing { @@ -400,7 +637,7 @@ func performerAgeFilterCriterionHandler(age *models.IntCriterionInput) criterion } } -func performerTagsCriterionHandler(qb *performerQueryBuilder, tags *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { +func performerTagsCriterionHandler(qb *PerformerStore, tags *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { h := joinedHierarchicalMultiCriterionHandlerBuilder{ tx: qb.tx, @@ -417,7 +654,7 @@ func performerTagsCriterionHandler(qb *performerQueryBuilder, tags *models.Hiera return h.handler(tags) } -func performerTagCountCriterionHandler(qb *performerQueryBuilder, count *models.IntCriterionInput) criterionHandlerFunc { +func performerTagCountCriterionHandler(qb *PerformerStore, count *models.IntCriterionInput) criterionHandlerFunc { h := countCriterionHandlerBuilder{ primaryTable: performerTable, joinTable: performersTagsTable, @@ -427,7 +664,7 @@ func performerTagCountCriterionHandler(qb *performerQueryBuilder, count *models. return h.handler(count) } -func performerSceneCountCriterionHandler(qb *performerQueryBuilder, count *models.IntCriterionInput) criterionHandlerFunc { +func performerSceneCountCriterionHandler(qb *PerformerStore, count *models.IntCriterionInput) criterionHandlerFunc { h := countCriterionHandlerBuilder{ primaryTable: performerTable, joinTable: performersScenesTable, @@ -437,7 +674,7 @@ func performerSceneCountCriterionHandler(qb *performerQueryBuilder, count *model return h.handler(count) } -func performerImageCountCriterionHandler(qb *performerQueryBuilder, count *models.IntCriterionInput) criterionHandlerFunc { +func performerImageCountCriterionHandler(qb *PerformerStore, count *models.IntCriterionInput) criterionHandlerFunc { h := countCriterionHandlerBuilder{ primaryTable: performerTable, joinTable: performersImagesTable, @@ -447,7 +684,7 @@ func performerImageCountCriterionHandler(qb *performerQueryBuilder, count *model return h.handler(count) } -func performerGalleryCountCriterionHandler(qb *performerQueryBuilder, count *models.IntCriterionInput) criterionHandlerFunc { +func performerGalleryCountCriterionHandler(qb *PerformerStore, count *models.IntCriterionInput) criterionHandlerFunc { h := countCriterionHandlerBuilder{ primaryTable: performerTable, joinTable: performersGalleriesTable, @@ -457,7 +694,7 @@ func performerGalleryCountCriterionHandler(qb *performerQueryBuilder, count *mod return h.handler(count) } -func performerStudiosCriterionHandler(qb *performerQueryBuilder, studios *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { +func performerStudiosCriterionHandler(qb *PerformerStore, studios *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if studios != nil { formatMaps := []utils.StrFormatMap{ @@ -534,7 +771,7 @@ func performerStudiosCriterionHandler(qb *performerQueryBuilder, studios *models } } -func (qb *performerQueryBuilder) getPerformerSort(findFilter *models.FindFilterType) string { +func (qb *PerformerStore) getPerformerSort(findFilter *models.FindFilterType) string { var sort string var direction string if findFilter == nil { @@ -561,16 +798,7 @@ func (qb *performerQueryBuilder) getPerformerSort(findFilter *models.FindFilterT return getSort(sort, direction, "performers") } -func (qb *performerQueryBuilder) queryPerformers(ctx context.Context, query string, args []interface{}) ([]*models.Performer, error) { - var ret models.Performers - if err := qb.query(ctx, query, args, &ret); err != nil { - return nil, err - } - - return []*models.Performer(ret), nil -} - -func (qb *performerQueryBuilder) tagsRepository() *joinRepository { +func (qb *PerformerStore) tagsRepository() *joinRepository { return &joinRepository{ repository: repository{ tx: qb.tx, @@ -581,16 +809,16 @@ func (qb *performerQueryBuilder) tagsRepository() *joinRepository { } } -func (qb *performerQueryBuilder) GetTagIDs(ctx context.Context, id int) ([]int, error) { +func (qb *PerformerStore) GetTagIDs(ctx context.Context, id int) ([]int, error) { return qb.tagsRepository().getIDs(ctx, id) } -func (qb *performerQueryBuilder) UpdateTags(ctx context.Context, id int, tagIDs []int) error { +func (qb *PerformerStore) UpdateTags(ctx context.Context, id int, tagIDs []int) error { // Delete the existing joins and then create new ones return qb.tagsRepository().replace(ctx, id, tagIDs) } -func (qb *performerQueryBuilder) imageRepository() *imageRepository { +func (qb *PerformerStore) imageRepository() *imageRepository { return &imageRepository{ repository: repository{ tx: qb.tx, @@ -601,19 +829,19 @@ func (qb *performerQueryBuilder) imageRepository() *imageRepository { } } -func (qb *performerQueryBuilder) GetImage(ctx context.Context, performerID int) ([]byte, error) { +func (qb *PerformerStore) GetImage(ctx context.Context, performerID int) ([]byte, error) { return qb.imageRepository().get(ctx, performerID) } -func (qb *performerQueryBuilder) UpdateImage(ctx context.Context, performerID int, image []byte) error { +func (qb *PerformerStore) UpdateImage(ctx context.Context, performerID int, image []byte) error { return qb.imageRepository().replace(ctx, performerID, image) } -func (qb *performerQueryBuilder) DestroyImage(ctx context.Context, performerID int) error { +func (qb *PerformerStore) DestroyImage(ctx context.Context, performerID int) error { return qb.imageRepository().destroy(ctx, []int{performerID}) } -func (qb *performerQueryBuilder) stashIDRepository() *stashIDRepository { +func (qb *PerformerStore) stashIDRepository() *stashIDRepository { return &stashIDRepository{ repository{ tx: qb.tx, @@ -623,40 +851,51 @@ func (qb *performerQueryBuilder) stashIDRepository() *stashIDRepository { } } -func (qb *performerQueryBuilder) GetStashIDs(ctx context.Context, performerID int) ([]models.StashID, error) { +func (qb *PerformerStore) GetStashIDs(ctx context.Context, performerID int) ([]models.StashID, error) { return qb.stashIDRepository().get(ctx, performerID) } -func (qb *performerQueryBuilder) UpdateStashIDs(ctx context.Context, performerID int, stashIDs []models.StashID) error { +func (qb *PerformerStore) UpdateStashIDs(ctx context.Context, performerID int, stashIDs []models.StashID) error { return qb.stashIDRepository().replace(ctx, performerID, stashIDs) } -func (qb *performerQueryBuilder) FindByStashID(ctx context.Context, stashID models.StashID) ([]*models.Performer, error) { - query := selectAll("performers") + ` - LEFT JOIN performer_stash_ids on performer_stash_ids.performer_id = performers.id - WHERE performer_stash_ids.stash_id = ? - AND performer_stash_ids.endpoint = ? - ` - args := []interface{}{stashID.StashID, stashID.Endpoint} - return qb.queryPerformers(ctx, query, args) -} +func (qb *PerformerStore) FindByStashID(ctx context.Context, stashID models.StashID) ([]*models.Performer, error) { + sq := dialect.From(performersStashIDsJoinTable).Select(performersStashIDsJoinTable.Col(performerIDColumn)).Where( + performersStashIDsJoinTable.Col("stash_id").Eq(stashID.StashID), + performersStashIDsJoinTable.Col("endpoint").Eq(stashID.Endpoint), + ) + ret, err := qb.findBySubquery(ctx, sq) -func (qb *performerQueryBuilder) FindByStashIDStatus(ctx context.Context, hasStashID bool, stashboxEndpoint string) ([]*models.Performer, error) { - query := selectAll("performers") + ` - LEFT JOIN performer_stash_ids on performer_stash_ids.performer_id = performers.id - ` - - if hasStashID { - query += ` - WHERE performer_stash_ids.stash_id IS NOT NULL - AND performer_stash_ids.endpoint = ? - ` - } else { - query += ` - WHERE performer_stash_ids.stash_id IS NULL - ` + if err != nil { + return nil, fmt.Errorf("getting performers for stash ID %s: %w", stashID.StashID, err) } - args := []interface{}{stashboxEndpoint} - return qb.queryPerformers(ctx, query, args) + return ret, nil +} + +func (qb *PerformerStore) FindByStashIDStatus(ctx context.Context, hasStashID bool, stashboxEndpoint string) ([]*models.Performer, error) { + table := qb.table() + sq := dialect.From(table).LeftJoin( + performersStashIDsJoinTable, + goqu.On(table.Col(idColumn).Eq(performersStashIDsJoinTable.Col(performerIDColumn))), + ).Select(table.Col(idColumn)) + + if hasStashID { + sq = sq.Where( + performersStashIDsJoinTable.Col("stash_id").IsNotNull(), + performersStashIDsJoinTable.Col("endpoint").Eq(stashboxEndpoint), + ) + } else { + sq = sq.Where( + performersStashIDsJoinTable.Col("stash_id").IsNull(), + ) + } + + ret, err := qb.findBySubquery(ctx, sq) + + if err != nil { + return nil, fmt.Errorf("getting performers for stash-box endpoint %s: %w", stashboxEndpoint, err) + } + + return ret, nil } diff --git a/pkg/sqlite/performer_test.go b/pkg/sqlite/performer_test.go index 2075407a5..934287524 100644 --- a/pkg/sqlite/performer_test.go +++ b/pkg/sqlite/performer_test.go @@ -5,7 +5,6 @@ package sqlite_test import ( "context" - "database/sql" "fmt" "math" "strconv" @@ -13,16 +12,246 @@ import ( "testing" "time" - "github.com/stretchr/testify/assert" - "github.com/stashapp/stash/pkg/hash/md5" "github.com/stashapp/stash/pkg/models" - "github.com/stashapp/stash/pkg/sqlite" + "github.com/stretchr/testify/assert" ) +func Test_PerformerStore_Update(t *testing.T) { + var ( + name = "name" + gender = models.GenderEnumFemale + checksum = "checksum" + details = "details" + url = "url" + twitter = "twitter" + instagram = "instagram" + rating = 3 + ethnicity = "ethnicity" + country = "country" + eyeColor = "eyeColor" + height = 134 + measurements = "measurements" + fakeTits = "fakeTits" + careerLength = "careerLength" + tattoos = "tattoos" + piercings = "piercings" + aliases = "aliases" + hairColor = "hairColor" + weight = 123 + ignoreAutoTag = true + favorite = true + createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) + updatedAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) + + birthdate = models.NewDate("2003-02-01") + deathdate = models.NewDate("2023-02-01") + ) + + tests := []struct { + name string + updatedObject *models.Performer + wantErr bool + }{ + { + "full", + &models.Performer{ + ID: performerIDs[performerIdxWithGallery], + Name: name, + Checksum: checksum, + Gender: gender, + URL: url, + Twitter: twitter, + Instagram: instagram, + Birthdate: &birthdate, + Ethnicity: ethnicity, + Country: country, + EyeColor: eyeColor, + Height: &height, + Measurements: measurements, + FakeTits: fakeTits, + CareerLength: careerLength, + Tattoos: tattoos, + Piercings: piercings, + Aliases: aliases, + Favorite: favorite, + Rating: &rating, + Details: details, + DeathDate: &deathdate, + HairColor: hairColor, + Weight: &weight, + IgnoreAutoTag: ignoreAutoTag, + CreatedAt: createdAt, + UpdatedAt: updatedAt, + }, + false, + }, + { + "clear all", + &models.Performer{ + ID: performerIDs[performerIdxWithGallery], + }, + false, + }, + } + + qb := db.Performer + for _, tt := range tests { + runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { + assert := assert.New(t) + + copy := *tt.updatedObject + + if err := qb.Update(ctx, tt.updatedObject); (err != nil) != tt.wantErr { + t.Errorf("PerformerStore.Update() error = %v, wantErr %v", err, tt.wantErr) + } + + if tt.wantErr { + return + } + + s, err := qb.Find(ctx, tt.updatedObject.ID) + if err != nil { + t.Errorf("PerformerStore.Find() error = %v", err) + } + + assert.Equal(copy, *s) + }) + } +} + +func Test_PerformerStore_UpdatePartial(t *testing.T) { + var ( + name = "name" + gender = models.GenderEnumFemale + checksum = "checksum" + details = "details" + url = "url" + twitter = "twitter" + instagram = "instagram" + rating = 3 + ethnicity = "ethnicity" + country = "country" + eyeColor = "eyeColor" + height = 143 + measurements = "measurements" + fakeTits = "fakeTits" + careerLength = "careerLength" + tattoos = "tattoos" + piercings = "piercings" + aliases = "aliases" + hairColor = "hairColor" + weight = 123 + ignoreAutoTag = true + favorite = true + createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) + updatedAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) + + birthdate = models.NewDate("2003-02-01") + deathdate = models.NewDate("2023-02-01") + ) + + tests := []struct { + name string + id int + partial models.PerformerPartial + want models.Performer + wantErr bool + }{ + { + "full", + performerIDs[performerIdxWithDupName], + models.PerformerPartial{ + Name: models.NewOptionalString(name), + Checksum: models.NewOptionalString(checksum), + Gender: models.NewOptionalString(gender.String()), + URL: models.NewOptionalString(url), + Twitter: models.NewOptionalString(twitter), + Instagram: models.NewOptionalString(instagram), + Birthdate: models.NewOptionalDate(birthdate), + Ethnicity: models.NewOptionalString(ethnicity), + Country: models.NewOptionalString(country), + EyeColor: models.NewOptionalString(eyeColor), + Height: models.NewOptionalInt(height), + Measurements: models.NewOptionalString(measurements), + FakeTits: models.NewOptionalString(fakeTits), + CareerLength: models.NewOptionalString(careerLength), + Tattoos: models.NewOptionalString(tattoos), + Piercings: models.NewOptionalString(piercings), + Aliases: models.NewOptionalString(aliases), + Favorite: models.NewOptionalBool(favorite), + Rating: models.NewOptionalInt(rating), + Details: models.NewOptionalString(details), + DeathDate: models.NewOptionalDate(deathdate), + HairColor: models.NewOptionalString(hairColor), + Weight: models.NewOptionalInt(weight), + IgnoreAutoTag: models.NewOptionalBool(ignoreAutoTag), + CreatedAt: models.NewOptionalTime(createdAt), + UpdatedAt: models.NewOptionalTime(updatedAt), + }, + models.Performer{ + ID: performerIDs[performerIdxWithDupName], + Name: name, + Checksum: checksum, + Gender: gender, + URL: url, + Twitter: twitter, + Instagram: instagram, + Birthdate: &birthdate, + Ethnicity: ethnicity, + Country: country, + EyeColor: eyeColor, + Height: &height, + Measurements: measurements, + FakeTits: fakeTits, + CareerLength: careerLength, + Tattoos: tattoos, + Piercings: piercings, + Aliases: aliases, + Favorite: favorite, + Rating: &rating, + Details: details, + DeathDate: &deathdate, + HairColor: hairColor, + Weight: &weight, + IgnoreAutoTag: ignoreAutoTag, + CreatedAt: createdAt, + UpdatedAt: updatedAt, + }, + false, + }, + } + for _, tt := range tests { + qb := db.Performer + + runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { + assert := assert.New(t) + + got, err := qb.UpdatePartial(ctx, tt.id, tt.partial) + if (err != nil) != tt.wantErr { + t.Errorf("PerformerStore.UpdatePartial() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if tt.wantErr { + return + } + + assert.Equal(tt.want, *got) + + s, err := qb.Find(ctx, tt.id) + if err != nil { + t.Errorf("PerformerStore.Find() error = %v", err) + } + + assert.Equal(tt.want, *s) + }) + } +} + func TestPerformerFindBySceneID(t *testing.T) { withTxn(func(ctx context.Context) error { - pqb := sqlite.PerformerReaderWriter + pqb := db.Performer sceneID := sceneIDs[sceneIdxWithPerformer] performers, err := pqb.FindBySceneID(ctx, sceneID) @@ -31,10 +260,13 @@ func TestPerformerFindBySceneID(t *testing.T) { t.Errorf("Error finding performer: %s", err.Error()) } - assert.Equal(t, 1, len(performers)) + if !assert.Equal(t, 1, len(performers)) { + return nil + } + performer := performers[0] - assert.Equal(t, getPerformerStringValue(performerIdxWithScene, "Name"), performer.Name.String) + assert.Equal(t, getPerformerStringValue(performerIdxWithScene, "Name"), performer.Name) performers, err = pqb.FindBySceneID(ctx, 0) @@ -48,11 +280,73 @@ func TestPerformerFindBySceneID(t *testing.T) { }) } +func TestPerformerFindByImageID(t *testing.T) { + withTxn(func(ctx context.Context) error { + pqb := db.Performer + imageID := imageIDs[imageIdxWithPerformer] + + performers, err := pqb.FindByImageID(ctx, imageID) + + if err != nil { + t.Errorf("Error finding performer: %s", err.Error()) + } + + if !assert.Equal(t, 1, len(performers)) { + return nil + } + + performer := performers[0] + + assert.Equal(t, getPerformerStringValue(performerIdxWithImage, "Name"), performer.Name) + + performers, err = pqb.FindByImageID(ctx, 0) + + if err != nil { + t.Errorf("Error finding performer: %s", err.Error()) + } + + assert.Equal(t, 0, len(performers)) + + return nil + }) +} + +func TestPerformerFindByGalleryID(t *testing.T) { + withTxn(func(ctx context.Context) error { + pqb := db.Performer + galleryID := galleryIDs[galleryIdxWithPerformer] + + performers, err := pqb.FindByGalleryID(ctx, galleryID) + + if err != nil { + t.Errorf("Error finding performer: %s", err.Error()) + } + + if !assert.Equal(t, 1, len(performers)) { + return nil + } + + performer := performers[0] + + assert.Equal(t, getPerformerStringValue(performerIdxWithGallery, "Name"), performer.Name) + + performers, err = pqb.FindByGalleryID(ctx, 0) + + if err != nil { + t.Errorf("Error finding performer: %s", err.Error()) + } + + assert.Equal(t, 0, len(performers)) + + return nil + }) +} + func TestPerformerFindByNames(t *testing.T) { getNames := func(p []*models.Performer) []string { var ret []string for _, pp := range p { - ret = append(ret, pp.Name.String) + ret = append(ret, pp.Name) } return ret } @@ -60,7 +354,7 @@ func TestPerformerFindByNames(t *testing.T) { withTxn(func(ctx context.Context) error { var names []string - pqb := sqlite.PerformerReaderWriter + pqb := db.Performer names = append(names, performerNames[performerIdxWithScene]) // find performers by names @@ -69,15 +363,15 @@ func TestPerformerFindByNames(t *testing.T) { t.Errorf("Error finding performers: %s", err.Error()) } assert.Len(t, performers, 1) - assert.Equal(t, performerNames[performerIdxWithScene], performers[0].Name.String) + assert.Equal(t, performerNames[performerIdxWithScene], performers[0].Name) performers, err = pqb.FindByNames(ctx, names, true) // find performers by names nocase if err != nil { t.Errorf("Error finding performers: %s", err.Error()) } assert.Len(t, performers, 2) // performerIdxWithScene and performerIdxWithDupName - assert.Equal(t, strings.ToLower(performerNames[performerIdxWithScene]), strings.ToLower(performers[0].Name.String)) - assert.Equal(t, strings.ToLower(performerNames[performerIdxWithScene]), strings.ToLower(performers[1].Name.String)) + assert.Equal(t, strings.ToLower(performerNames[performerIdxWithScene]), strings.ToLower(performers[0].Name)) + assert.Equal(t, strings.ToLower(performerNames[performerIdxWithScene]), strings.ToLower(performers[1].Name)) names = append(names, performerNames[performerIdx1WithScene]) // find performers by names ( 2 names ) @@ -125,13 +419,11 @@ func TestPerformerQueryEthnicityOr(t *testing.T) { } withTxn(func(ctx context.Context) error { - sqb := sqlite.PerformerReaderWriter - - performers := queryPerformers(ctx, t, sqb, &performerFilter, nil) + performers := queryPerformers(ctx, t, &performerFilter, nil) assert.Len(t, performers, 2) - assert.Equal(t, performer1Eth, performers[0].Ethnicity.String) - assert.Equal(t, performer2Eth, performers[1].Ethnicity.String) + assert.Equal(t, performer1Eth, performers[0].Ethnicity) + assert.Equal(t, performer2Eth, performers[1].Ethnicity) return nil }) @@ -140,7 +432,7 @@ func TestPerformerQueryEthnicityOr(t *testing.T) { func TestPerformerQueryEthnicityAndRating(t *testing.T) { const performerIdx = 1 performerEth := getPerformerStringValue(performerIdx, "Ethnicity") - performerRating := getRating(performerIdx) + performerRating := int(getRating(performerIdx).Int64) performerFilter := models.PerformerFilterType{ Ethnicity: &models.StringCriterionInput{ @@ -148,21 +440,24 @@ func TestPerformerQueryEthnicityAndRating(t *testing.T) { Modifier: models.CriterionModifierEquals, }, And: &models.PerformerFilterType{ - Rating: &models.IntCriterionInput{ - Value: int(performerRating.Int64), + Rating100: &models.IntCriterionInput{ + Value: performerRating, Modifier: models.CriterionModifierEquals, }, }, } withTxn(func(ctx context.Context) error { - sqb := sqlite.PerformerReaderWriter + performers := queryPerformers(ctx, t, &performerFilter, nil) - performers := queryPerformers(ctx, t, sqb, &performerFilter, nil) + if !assert.Len(t, performers, 1) { + return nil + } - assert.Len(t, performers, 1) - assert.Equal(t, performerEth, performers[0].Ethnicity.String) - assert.Equal(t, performerRating.Int64, performers[0].Rating.Int64) + assert.Equal(t, performerEth, performers[0].Ethnicity) + if assert.NotNil(t, performers[0].Rating) { + assert.Equal(t, performerRating, *performers[0].Rating) + } return nil }) @@ -186,19 +481,17 @@ func TestPerformerQueryEthnicityNotRating(t *testing.T) { performerFilter := models.PerformerFilterType{ Ethnicity: ðCriterion, Not: &models.PerformerFilterType{ - Rating: &ratingCriterion, + Rating100: &ratingCriterion, }, } withTxn(func(ctx context.Context) error { - sqb := sqlite.PerformerReaderWriter - - performers := queryPerformers(ctx, t, sqb, &performerFilter, nil) + performers := queryPerformers(ctx, t, &performerFilter, nil) for _, performer := range performers { - verifyString(t, performer.Ethnicity.String, ethCriterion) + verifyString(t, performer.Ethnicity, ethCriterion) ratingCriterion.Modifier = models.CriterionModifierNotEquals - verifyInt64(t, performer.Rating, ratingCriterion) + verifyIntPtr(t, performer.Rating, ratingCriterion) } return nil @@ -216,29 +509,62 @@ func TestPerformerIllegalQuery(t *testing.T) { }, } - performerFilter := &models.PerformerFilterType{ - And: &subFilter, - Or: &subFilter, + tests := []struct { + name string + filter models.PerformerFilterType + }{ + { + // And and Or in the same filter + "AndOr", + models.PerformerFilterType{ + And: &subFilter, + Or: &subFilter, + }, + }, + { + // And and Not in the same filter + "AndNot", + models.PerformerFilterType{ + And: &subFilter, + Not: &subFilter, + }, + }, + { + // Or and Not in the same filter + "OrNot", + models.PerformerFilterType{ + Or: &subFilter, + Not: &subFilter, + }, + }, + { + "invalid height modifier", + models.PerformerFilterType{ + Height: &models.StringCriterionInput{ + Modifier: models.CriterionModifierMatchesRegex, + Value: "123", + }, + }, + }, + { + "invalid height value", + models.PerformerFilterType{ + Height: &models.StringCriterionInput{ + Modifier: models.CriterionModifierEquals, + Value: "foo", + }, + }, + }, } - withTxn(func(ctx context.Context) error { - sqb := sqlite.PerformerReaderWriter + sqb := db.Performer - _, _, err := sqb.Query(ctx, performerFilter, nil) - assert.NotNil(err) - - performerFilter.Or = nil - performerFilter.Not = &subFilter - _, _, err = sqb.Query(ctx, performerFilter, nil) - assert.NotNil(err) - - performerFilter.And = nil - performerFilter.Or = &subFilter - _, _, err = sqb.Query(ctx, performerFilter, nil) - assert.NotNil(err) - - return nil - }) + for _, tt := range tests { + runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { + _, _, err := sqb.Query(ctx, &tt.filter, nil) + assert.NotNil(err) + }) + } } func TestPerformerQueryIgnoreAutoTag(t *testing.T) { @@ -248,9 +574,7 @@ func TestPerformerQueryIgnoreAutoTag(t *testing.T) { IgnoreAutoTag: &ignoreAutoTag, } - sqb := sqlite.PerformerReaderWriter - - performers := queryPerformers(ctx, t, sqb, &performerFilter, nil) + performers := queryPerformers(ctx, t, &performerFilter, nil) assert.Len(t, performers, int(math.Ceil(float64(totalPerformers)/5))) for _, p := range performers { @@ -261,9 +585,103 @@ func TestPerformerQueryIgnoreAutoTag(t *testing.T) { }) } +func TestPerformerQuery(t *testing.T) { + var ( + endpoint = performerStashID(performerIdxWithGallery).Endpoint + stashID = performerStashID(performerIdxWithGallery).StashID + ) + + tests := []struct { + name string + findFilter *models.FindFilterType + filter *models.PerformerFilterType + includeIdxs []int + excludeIdxs []int + wantErr bool + }{ + { + "stash id with endpoint", + nil, + &models.PerformerFilterType{ + StashIDEndpoint: &models.StashIDCriterionInput{ + Endpoint: &endpoint, + StashID: &stashID, + Modifier: models.CriterionModifierEquals, + }, + }, + []int{performerIdxWithGallery}, + nil, + false, + }, + { + "exclude stash id with endpoint", + nil, + &models.PerformerFilterType{ + StashIDEndpoint: &models.StashIDCriterionInput{ + Endpoint: &endpoint, + StashID: &stashID, + Modifier: models.CriterionModifierNotEquals, + }, + }, + nil, + []int{performerIdxWithGallery}, + false, + }, + { + "null stash id with endpoint", + nil, + &models.PerformerFilterType{ + StashIDEndpoint: &models.StashIDCriterionInput{ + Endpoint: &endpoint, + Modifier: models.CriterionModifierIsNull, + }, + }, + nil, + []int{performerIdxWithGallery}, + false, + }, + { + "not null stash id with endpoint", + nil, + &models.PerformerFilterType{ + StashIDEndpoint: &models.StashIDCriterionInput{ + Endpoint: &endpoint, + Modifier: models.CriterionModifierNotNull, + }, + }, + []int{performerIdxWithGallery}, + nil, + false, + }, + } + + for _, tt := range tests { + runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { + assert := assert.New(t) + + performers, _, err := db.Performer.Query(ctx, tt.filter, tt.findFilter) + if (err != nil) != tt.wantErr { + t.Errorf("PerformerStore.Query() error = %v, wantErr %v", err, tt.wantErr) + return + } + + ids := performersToIDs(performers) + include := indexesToIDs(performerIDs, tt.includeIdxs) + exclude := indexesToIDs(performerIDs, tt.excludeIdxs) + + for _, i := range include { + assert.Contains(ids, i) + } + for _, e := range exclude { + assert.NotContains(ids, e) + } + }) + } +} + func TestPerformerQueryForAutoTag(t *testing.T) { withTxn(func(ctx context.Context) error { - tqb := sqlite.PerformerReaderWriter + tqb := db.Performer name := performerNames[performerIdx1WithScene] // find a performer by name @@ -274,44 +692,43 @@ func TestPerformerQueryForAutoTag(t *testing.T) { } assert.Len(t, performers, 2) - assert.Equal(t, strings.ToLower(performerNames[performerIdx1WithScene]), strings.ToLower(performers[0].Name.String)) - assert.Equal(t, strings.ToLower(performerNames[performerIdx1WithScene]), strings.ToLower(performers[1].Name.String)) + assert.Equal(t, strings.ToLower(performerNames[performerIdx1WithScene]), strings.ToLower(performers[0].Name)) + assert.Equal(t, strings.ToLower(performerNames[performerIdx1WithScene]), strings.ToLower(performers[1].Name)) return nil }) } func TestPerformerUpdatePerformerImage(t *testing.T) { - if err := withTxn(func(ctx context.Context) error { - qb := sqlite.PerformerReaderWriter + if err := withRollbackTxn(func(ctx context.Context) error { + qb := db.Performer // create performer to test against const name = "TestPerformerUpdatePerformerImage" performer := models.Performer{ - Name: sql.NullString{String: name, Valid: true}, + Name: name, Checksum: md5.FromString(name), - Favorite: sql.NullBool{Bool: false, Valid: true}, } - created, err := qb.Create(ctx, performer) + err := qb.Create(ctx, &performer) if err != nil { return fmt.Errorf("Error creating performer: %s", err.Error()) } image := []byte("image") - err = qb.UpdateImage(ctx, created.ID, image) + err = qb.UpdateImage(ctx, performer.ID, image) if err != nil { return fmt.Errorf("Error updating performer image: %s", err.Error()) } // ensure image set - storedImage, err := qb.GetImage(ctx, created.ID) + storedImage, err := qb.GetImage(ctx, performer.ID) if err != nil { return fmt.Errorf("Error getting image: %s", err.Error()) } assert.Equal(t, storedImage, image) // set nil image - err = qb.UpdateImage(ctx, created.ID, nil) + err = qb.UpdateImage(ctx, performer.ID, nil) if err == nil { return fmt.Errorf("Expected error setting nil image") } @@ -323,34 +740,33 @@ func TestPerformerUpdatePerformerImage(t *testing.T) { } func TestPerformerDestroyPerformerImage(t *testing.T) { - if err := withTxn(func(ctx context.Context) error { - qb := sqlite.PerformerReaderWriter + if err := withRollbackTxn(func(ctx context.Context) error { + qb := db.Performer // create performer to test against const name = "TestPerformerDestroyPerformerImage" performer := models.Performer{ - Name: sql.NullString{String: name, Valid: true}, + Name: name, Checksum: md5.FromString(name), - Favorite: sql.NullBool{Bool: false, Valid: true}, } - created, err := qb.Create(ctx, performer) + err := qb.Create(ctx, &performer) if err != nil { return fmt.Errorf("Error creating performer: %s", err.Error()) } image := []byte("image") - err = qb.UpdateImage(ctx, created.ID, image) + err = qb.UpdateImage(ctx, performer.ID, image) if err != nil { return fmt.Errorf("Error updating performer image: %s", err.Error()) } - err = qb.DestroyImage(ctx, created.ID) + err = qb.DestroyImage(ctx, performer.ID) if err != nil { return fmt.Errorf("Error destroying performer image: %s", err.Error()) } // image should be nil - storedImage, err := qb.GetImage(ctx, created.ID) + storedImage, err := qb.GetImage(ctx, performer.ID) if err != nil { return fmt.Errorf("Error getting image: %s", err.Error()) } @@ -383,7 +799,7 @@ func TestPerformerQueryAge(t *testing.T) { func verifyPerformerAge(t *testing.T, ageCriterion models.IntCriterionInput) { withTxn(func(ctx context.Context) error { - qb := sqlite.PerformerReaderWriter + qb := db.Performer performerFilter := models.PerformerFilterType{ Age: &ageCriterion, } @@ -397,12 +813,11 @@ func verifyPerformerAge(t *testing.T, ageCriterion models.IntCriterionInput) { for _, performer := range performers { cd := now - if performer.DeathDate.Valid { - cd, _ = time.Parse("2006-01-02", performer.DeathDate.String) + if performer.DeathDate != nil { + cd = performer.DeathDate.Time } - bd := performer.Birthdate.String - d, _ := time.Parse("2006-01-02", bd) + d := performer.Birthdate.Time age := cd.Year() - d.Year() if cd.YearDay() < d.YearDay() { age = age - 1 @@ -436,7 +851,7 @@ func TestPerformerQueryCareerLength(t *testing.T) { func verifyPerformerCareerLength(t *testing.T, criterion models.StringCriterionInput) { withTxn(func(ctx context.Context) error { - qb := sqlite.PerformerReaderWriter + qb := db.Performer performerFilter := models.PerformerFilterType{ CareerLength: &criterion, } @@ -448,7 +863,7 @@ func verifyPerformerCareerLength(t *testing.T, criterion models.StringCriterionI for _, performer := range performers { cl := performer.CareerLength - verifyNullString(t, cl, criterion) + verifyString(t, cl, criterion) } return nil @@ -470,7 +885,7 @@ func TestPerformerQueryURL(t *testing.T) { verifyFn := func(g *models.Performer) { t.Helper() - verifyNullString(t, g.URL, urlCriterion) + verifyString(t, g.URL, urlCriterion) } verifyPerformerQuery(t, filter, verifyFn) @@ -496,9 +911,7 @@ func TestPerformerQueryURL(t *testing.T) { func verifyPerformerQuery(t *testing.T, filter models.PerformerFilterType, verifyFn func(s *models.Performer)) { withTxn(func(ctx context.Context) error { t.Helper() - sqb := sqlite.PerformerReaderWriter - - performers := queryPerformers(ctx, t, sqb, &filter, nil) + performers := queryPerformers(ctx, t, &filter, nil) // assume it should find at least one assert.Greater(t, len(performers), 0) @@ -511,8 +924,8 @@ func verifyPerformerQuery(t *testing.T, filter models.PerformerFilterType, verif }) } -func queryPerformers(ctx context.Context, t *testing.T, qb models.PerformerReader, performerFilter *models.PerformerFilterType, findFilter *models.FindFilterType) []*models.Performer { - performers, _, err := qb.Query(ctx, performerFilter, findFilter) +func queryPerformers(ctx context.Context, t *testing.T, performerFilter *models.PerformerFilterType, findFilter *models.FindFilterType) []*models.Performer { + performers, _, err := db.Performer.Query(ctx, performerFilter, findFilter) if err != nil { t.Errorf("Error querying performers: %s", err.Error()) } @@ -522,7 +935,6 @@ func queryPerformers(ctx context.Context, t *testing.T, qb models.PerformerReade func TestPerformerQueryTags(t *testing.T) { withTxn(func(ctx context.Context) error { - sqb := sqlite.PerformerReaderWriter tagCriterion := models.HierarchicalMultiCriterionInput{ Value: []string{ strconv.Itoa(tagIDs[tagIdxWithPerformer]), @@ -536,7 +948,7 @@ func TestPerformerQueryTags(t *testing.T) { } // ensure ids are correct - performers := queryPerformers(ctx, t, sqb, &performerFilter, nil) + performers := queryPerformers(ctx, t, &performerFilter, nil) assert.Len(t, performers, 2) for _, performer := range performers { assert.True(t, performer.ID == performerIDs[performerIdxWithTag] || performer.ID == performerIDs[performerIdxWithTwoTags]) @@ -550,7 +962,7 @@ func TestPerformerQueryTags(t *testing.T) { Modifier: models.CriterionModifierIncludesAll, } - performers = queryPerformers(ctx, t, sqb, &performerFilter, nil) + performers = queryPerformers(ctx, t, &performerFilter, nil) assert.Len(t, performers, 1) assert.Equal(t, sceneIDs[performerIdxWithTwoTags], performers[0].ID) @@ -567,7 +979,7 @@ func TestPerformerQueryTags(t *testing.T) { Q: &q, } - performers = queryPerformers(ctx, t, sqb, &performerFilter, &findFilter) + performers = queryPerformers(ctx, t, &performerFilter, &findFilter) assert.Len(t, performers, 0) return nil @@ -595,12 +1007,12 @@ func TestPerformerQueryTagCount(t *testing.T) { func verifyPerformersTagCount(t *testing.T, tagCountCriterion models.IntCriterionInput) { withTxn(func(ctx context.Context) error { - sqb := sqlite.PerformerReaderWriter + sqb := db.Performer performerFilter := models.PerformerFilterType{ TagCount: &tagCountCriterion, } - performers := queryPerformers(ctx, t, sqb, &performerFilter, nil) + performers := queryPerformers(ctx, t, &performerFilter, nil) assert.Greater(t, len(performers), 0) for _, performer := range performers { @@ -636,12 +1048,11 @@ func TestPerformerQuerySceneCount(t *testing.T) { func verifyPerformersSceneCount(t *testing.T, sceneCountCriterion models.IntCriterionInput) { withTxn(func(ctx context.Context) error { - sqb := sqlite.PerformerReaderWriter performerFilter := models.PerformerFilterType{ SceneCount: &sceneCountCriterion, } - performers := queryPerformers(ctx, t, sqb, &performerFilter, nil) + performers := queryPerformers(ctx, t, &performerFilter, nil) assert.Greater(t, len(performers), 0) for _, performer := range performers { @@ -677,12 +1088,11 @@ func TestPerformerQueryImageCount(t *testing.T) { func verifyPerformersImageCount(t *testing.T, imageCountCriterion models.IntCriterionInput) { withTxn(func(ctx context.Context) error { - sqb := sqlite.PerformerReaderWriter performerFilter := models.PerformerFilterType{ ImageCount: &imageCountCriterion, } - performers := queryPerformers(ctx, t, sqb, &performerFilter, nil) + performers := queryPerformers(ctx, t, &performerFilter, nil) assert.Greater(t, len(performers), 0) for _, performer := range performers { @@ -733,12 +1143,11 @@ func TestPerformerQueryGalleryCount(t *testing.T) { func verifyPerformersGalleryCount(t *testing.T, galleryCountCriterion models.IntCriterionInput) { withTxn(func(ctx context.Context) error { - sqb := sqlite.PerformerReaderWriter performerFilter := models.PerformerFilterType{ GalleryCount: &galleryCountCriterion, } - performers := queryPerformers(ctx, t, sqb, &performerFilter, nil) + performers := queryPerformers(ctx, t, &performerFilter, nil) assert.Greater(t, len(performers), 0) for _, performer := range performers { @@ -773,8 +1182,6 @@ func TestPerformerQueryStudio(t *testing.T) { {studioIndex: studioIdxWithGalleryPerformer, performerIndex: performerIdxWithGalleryStudio}, } - sqb := sqlite.PerformerReaderWriter - for _, tc := range testCases { studioCriterion := models.HierarchicalMultiCriterionInput{ Value: []string{ @@ -787,7 +1194,7 @@ func TestPerformerQueryStudio(t *testing.T) { Studios: &studioCriterion, } - performers := queryPerformers(ctx, t, sqb, &performerFilter, nil) + performers := queryPerformers(ctx, t, &performerFilter, nil) assert.Len(t, performers, 1) @@ -806,7 +1213,7 @@ func TestPerformerQueryStudio(t *testing.T) { Q: &q, } - performers = queryPerformers(ctx, t, sqb, &performerFilter, &findFilter) + performers = queryPerformers(ctx, t, &performerFilter, &findFilter) assert.Len(t, performers, 0) } @@ -821,21 +1228,21 @@ func TestPerformerQueryStudio(t *testing.T) { Q: &q, } - performers := queryPerformers(ctx, t, sqb, performerFilter, findFilter) + performers := queryPerformers(ctx, t, performerFilter, findFilter) assert.Len(t, performers, 1) assert.Equal(t, imageIDs[performerIdx1WithImage], performers[0].ID) q = getPerformerStringValue(performerIdxWithSceneStudio, "Name") - performers = queryPerformers(ctx, t, sqb, performerFilter, findFilter) + performers = queryPerformers(ctx, t, performerFilter, findFilter) assert.Len(t, performers, 0) performerFilter.Studios.Modifier = models.CriterionModifierNotNull - performers = queryPerformers(ctx, t, sqb, performerFilter, findFilter) + performers = queryPerformers(ctx, t, performerFilter, findFilter) assert.Len(t, performers, 1) assert.Equal(t, imageIDs[performerIdxWithSceneStudio], performers[0].ID) q = getPerformerStringValue(performerIdx1WithImage, "Name") - performers = queryPerformers(ctx, t, sqb, performerFilter, findFilter) + performers = queryPerformers(ctx, t, performerFilter, findFilter) assert.Len(t, performers, 0) return nil @@ -843,63 +1250,105 @@ func TestPerformerQueryStudio(t *testing.T) { } func TestPerformerStashIDs(t *testing.T) { - if err := withTxn(func(ctx context.Context) error { - qb := sqlite.PerformerReaderWriter + if err := withRollbackTxn(func(ctx context.Context) error { + qb := db.Performer // create performer to test against const name = "TestStashIDs" performer := models.Performer{ - Name: sql.NullString{String: name, Valid: true}, + Name: name, Checksum: md5.FromString(name), - Favorite: sql.NullBool{Bool: false, Valid: true}, } - created, err := qb.Create(ctx, performer) + err := qb.Create(ctx, &performer) if err != nil { return fmt.Errorf("Error creating performer: %s", err.Error()) } - testStashIDReaderWriter(ctx, t, qb, created.ID) + testStashIDReaderWriter(ctx, t, qb, performer.ID) return nil }); err != nil { t.Error(err.Error()) } } -func TestPerformerQueryRating(t *testing.T) { +func TestPerformerQueryLegacyRating(t *testing.T) { const rating = 3 ratingCriterion := models.IntCriterionInput{ Value: rating, Modifier: models.CriterionModifierEquals, } - verifyPerformersRating(t, ratingCriterion) + verifyPerformersLegacyRating(t, ratingCriterion) ratingCriterion.Modifier = models.CriterionModifierNotEquals - verifyPerformersRating(t, ratingCriterion) + verifyPerformersLegacyRating(t, ratingCriterion) ratingCriterion.Modifier = models.CriterionModifierGreaterThan - verifyPerformersRating(t, ratingCriterion) + verifyPerformersLegacyRating(t, ratingCriterion) ratingCriterion.Modifier = models.CriterionModifierLessThan - verifyPerformersRating(t, ratingCriterion) + verifyPerformersLegacyRating(t, ratingCriterion) ratingCriterion.Modifier = models.CriterionModifierIsNull - verifyPerformersRating(t, ratingCriterion) + verifyPerformersLegacyRating(t, ratingCriterion) ratingCriterion.Modifier = models.CriterionModifierNotNull - verifyPerformersRating(t, ratingCriterion) + verifyPerformersLegacyRating(t, ratingCriterion) } -func verifyPerformersRating(t *testing.T, ratingCriterion models.IntCriterionInput) { +func verifyPerformersLegacyRating(t *testing.T, ratingCriterion models.IntCriterionInput) { withTxn(func(ctx context.Context) error { - sqb := sqlite.PerformerReaderWriter performerFilter := models.PerformerFilterType{ Rating: &ratingCriterion, } - performers := queryPerformers(ctx, t, sqb, &performerFilter, nil) + performers := queryPerformers(ctx, t, &performerFilter, nil) + + // convert criterion value to the 100 value + ratingCriterion.Value = models.Rating5To100(ratingCriterion.Value) for _, performer := range performers { - verifyInt64(t, performer.Rating, ratingCriterion) + verifyIntPtr(t, performer.Rating, ratingCriterion) + } + + return nil + }) +} + +func TestPerformerQueryRating100(t *testing.T) { + const rating = 60 + ratingCriterion := models.IntCriterionInput{ + Value: rating, + Modifier: models.CriterionModifierEquals, + } + + verifyPerformersRating100(t, ratingCriterion) + + ratingCriterion.Modifier = models.CriterionModifierNotEquals + verifyPerformersRating100(t, ratingCriterion) + + ratingCriterion.Modifier = models.CriterionModifierGreaterThan + verifyPerformersRating100(t, ratingCriterion) + + ratingCriterion.Modifier = models.CriterionModifierLessThan + verifyPerformersRating100(t, ratingCriterion) + + ratingCriterion.Modifier = models.CriterionModifierIsNull + verifyPerformersRating100(t, ratingCriterion) + + ratingCriterion.Modifier = models.CriterionModifierNotNull + verifyPerformersRating100(t, ratingCriterion) +} + +func verifyPerformersRating100(t *testing.T, ratingCriterion models.IntCriterionInput) { + withTxn(func(ctx context.Context) error { + performerFilter := models.PerformerFilterType{ + Rating100: &ratingCriterion, + } + + performers := queryPerformers(ctx, t, &performerFilter, nil) + + for _, performer := range performers { + verifyIntPtr(t, performer.Rating, ratingCriterion) } return nil @@ -908,18 +1357,17 @@ func verifyPerformersRating(t *testing.T, ratingCriterion models.IntCriterionInp func TestPerformerQueryIsMissingRating(t *testing.T) { withTxn(func(ctx context.Context) error { - sqb := sqlite.PerformerReaderWriter isMissing := "rating" performerFilter := models.PerformerFilterType{ IsMissing: &isMissing, } - performers := queryPerformers(ctx, t, sqb, &performerFilter, nil) + performers := queryPerformers(ctx, t, &performerFilter, nil) assert.True(t, len(performers) > 0) for _, performer := range performers { - assert.True(t, !performer.Rating.Valid) + assert.Nil(t, performer.Rating) } return nil @@ -934,7 +1382,7 @@ func TestPerformerQueryIsMissingImage(t *testing.T) { } // ensure query does not error - performers, _, err := sqlite.PerformerReaderWriter.Query(ctx, performerFilter, nil) + performers, _, err := db.Performer.Query(ctx, performerFilter, nil) if err != nil { t.Errorf("Error querying performers: %s", err.Error()) } @@ -942,7 +1390,7 @@ func TestPerformerQueryIsMissingImage(t *testing.T) { assert.True(t, len(performers) > 0) for _, performer := range performers { - img, err := sqlite.PerformerReaderWriter.GetImage(ctx, performer.ID) + img, err := db.Performer.GetImage(ctx, performer.ID) if err != nil { t.Errorf("error getting performer image: %s", err.Error()) } @@ -963,7 +1411,7 @@ func TestPerformerQuerySortScenesCount(t *testing.T) { withTxn(func(ctx context.Context) error { // just ensure it queries without error - performers, _, err := sqlite.PerformerReaderWriter.Query(ctx, nil, findFilter) + performers, _, err := db.Performer.Query(ctx, nil, findFilter) if err != nil { t.Errorf("Error querying performers: %s", err.Error()) } @@ -978,7 +1426,7 @@ func TestPerformerQuerySortScenesCount(t *testing.T) { // sort in ascending order direction = models.SortDirectionEnumAsc - performers, _, err = sqlite.PerformerReaderWriter.Query(ctx, nil, findFilter) + performers, _, err = db.Performer.Query(ctx, nil, findFilter) if err != nil { t.Errorf("Error querying performers: %s", err.Error()) } @@ -992,10 +1440,173 @@ func TestPerformerQuerySortScenesCount(t *testing.T) { }) } +func TestPerformerCountByTagID(t *testing.T) { + withTxn(func(ctx context.Context) error { + sqb := db.Performer + count, err := sqb.CountByTagID(ctx, tagIDs[tagIdxWithPerformer]) + + if err != nil { + t.Errorf("Error counting performers: %s", err.Error()) + } + + assert.Equal(t, 1, count) + + count, err = sqb.CountByTagID(ctx, 0) + + if err != nil { + t.Errorf("Error counting performers: %s", err.Error()) + } + + assert.Equal(t, 0, count) + + return nil + }) +} + +func TestPerformerCount(t *testing.T) { + withTxn(func(ctx context.Context) error { + sqb := db.Performer + count, err := sqb.Count(ctx) + + if err != nil { + t.Errorf("Error counting performers: %s", err.Error()) + } + + assert.Equal(t, totalPerformers, count) + + return nil + }) +} + +func TestPerformerAll(t *testing.T) { + withTxn(func(ctx context.Context) error { + sqb := db.Performer + all, err := sqb.All(ctx) + + if err != nil { + t.Errorf("Error counting performers: %s", err.Error()) + } + + assert.Len(t, all, totalPerformers) + + return nil + }) +} + +func performersToIDs(i []*models.Performer) []int { + ret := make([]int, len(i)) + for i, v := range i { + ret[i] = v.ID + } + + return ret +} + +func TestPerformerStore_FindByStashID(t *testing.T) { + type args struct { + stashID models.StashID + } + tests := []struct { + name string + stashID models.StashID + expectedIDs []int + wantErr bool + }{ + { + name: "existing", + stashID: performerStashID(performerIdxWithScene), + expectedIDs: []int{performerIDs[performerIdxWithScene]}, + wantErr: false, + }, + { + name: "non-existing", + stashID: models.StashID{ + StashID: getPerformerStringValue(performerIdxWithScene, "stashid"), + Endpoint: "non-existing", + }, + expectedIDs: []int{}, + wantErr: false, + }, + } + + qb := db.Performer + + for _, tt := range tests { + runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { + got, err := qb.FindByStashID(ctx, tt.stashID) + if (err != nil) != tt.wantErr { + t.Errorf("PerformerStore.FindByStashID() error = %v, wantErr %v", err, tt.wantErr) + return + } + + assert.ElementsMatch(t, performersToIDs(got), tt.expectedIDs) + }) + } +} + +func TestPerformerStore_FindByStashIDStatus(t *testing.T) { + type args struct { + stashID models.StashID + } + tests := []struct { + name string + hasStashID bool + stashboxEndpoint string + include []int + exclude []int + wantErr bool + }{ + { + name: "existing", + hasStashID: true, + stashboxEndpoint: getPerformerStringValue(performerIdxWithScene, "endpoint"), + include: []int{performerIdxWithScene}, + wantErr: false, + }, + { + name: "non-existing", + hasStashID: true, + stashboxEndpoint: getPerformerStringValue(performerIdxWithScene, "non-existing"), + exclude: []int{performerIdxWithScene}, + wantErr: false, + }, + { + name: "!hasStashID", + hasStashID: false, + stashboxEndpoint: getPerformerStringValue(performerIdxWithScene, "endpoint"), + include: []int{performerIdxWithImage}, + exclude: []int{performerIdx2WithScene}, + wantErr: false, + }, + } + + qb := db.Performer + + for _, tt := range tests { + runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { + got, err := qb.FindByStashIDStatus(ctx, tt.hasStashID, tt.stashboxEndpoint) + if (err != nil) != tt.wantErr { + t.Errorf("PerformerStore.FindByStashIDStatus() error = %v, wantErr %v", err, tt.wantErr) + return + } + + include := indexesToIDs(performerIDs, tt.include) + exclude := indexesToIDs(performerIDs, tt.exclude) + + ids := performersToIDs(got) + + assert := assert.New(t) + for _, i := range include { + assert.Contains(ids, i) + } + for _, e := range exclude { + assert.NotContains(ids, e) + } + }) + } +} + // TODO Update // TODO Destroy // TODO Find -// TODO Count -// TODO All -// TODO AllSlim // TODO Query diff --git a/pkg/sqlite/record.go b/pkg/sqlite/record.go index a0fb789ef..7e79dc721 100644 --- a/pkg/sqlite/record.go +++ b/pkg/sqlite/record.go @@ -68,14 +68,14 @@ func (r *updateRecord) setNullInt(destField string, v models.OptionalInt) { // } // } -// func (r *updateRecord) setFloat64(destField string, v models.OptionalFloat64) { -// if v.Set { -// if v.Null { -// panic("null value not allowed in optional float64") -// } -// r.set(destField, v.Value) -// } -// } +func (r *updateRecord) setFloat64(destField string, v models.OptionalFloat64) { + if v.Set { + if v.Null { + panic("null value not allowed in optional float64") + } + r.set(destField, v.Value) + } +} // func (r *updateRecord) setNullFloat64(destField string, v models.OptionalFloat64) { // if v.Set { diff --git a/pkg/sqlite/saved_filter.go b/pkg/sqlite/saved_filter.go index 54fddcbc8..a00bd1048 100644 --- a/pkg/sqlite/saved_filter.go +++ b/pkg/sqlite/saved_filter.go @@ -100,7 +100,7 @@ func (qb *savedFilterQueryBuilder) FindMany(ctx context.Context, ids []int, igno func (qb *savedFilterQueryBuilder) FindByMode(ctx context.Context, mode models.FilterMode) ([]*models.SavedFilter, error) { // exclude empty-named filters - these are the internal default filters - query := fmt.Sprintf(`SELECT * FROM %s WHERE mode = ? AND name != ?`, savedFilterTable) + query := fmt.Sprintf(`SELECT * FROM %s WHERE mode = ? AND name != ? ORDER BY name ASC`, savedFilterTable) var ret models.SavedFilters if err := qb.query(ctx, query, []interface{}{mode, savedFilterDefaultName}, &ret); err != nil { diff --git a/pkg/sqlite/scene.go b/pkg/sqlite/scene.go index a61f0cbb4..341e9ee26 100644 --- a/pkg/sqlite/scene.go +++ b/pkg/sqlite/scene.go @@ -8,6 +8,7 @@ import ( "path/filepath" "strconv" "strings" + "time" "github.com/doug-martin/goqu/v9" "github.com/doug-martin/goqu/v9/exp" @@ -52,23 +53,32 @@ ORDER BY files.size DESC ` type sceneRow struct { - ID int `db:"id" goqu:"skipinsert"` - Title zero.String `db:"title"` - Details zero.String `db:"details"` - URL zero.String `db:"url"` - Date models.SQLiteDate `db:"date"` - Rating null.Int `db:"rating"` - Organized bool `db:"organized"` - OCounter int `db:"o_counter"` - StudioID null.Int `db:"studio_id,omitempty"` - CreatedAt models.SQLiteTimestamp `db:"created_at"` - UpdatedAt models.SQLiteTimestamp `db:"updated_at"` + ID int `db:"id" goqu:"skipinsert"` + Title zero.String `db:"title"` + Code zero.String `db:"code"` + Details zero.String `db:"details"` + Director zero.String `db:"director"` + URL zero.String `db:"url"` + Date models.SQLiteDate `db:"date"` + // expressed as 1-100 + Rating null.Int `db:"rating"` + Organized bool `db:"organized"` + OCounter int `db:"o_counter"` + StudioID null.Int `db:"studio_id,omitempty"` + CreatedAt models.SQLiteTimestamp `db:"created_at"` + UpdatedAt models.SQLiteTimestamp `db:"updated_at"` + LastPlayedAt models.NullSQLiteTimestamp `db:"last_played_at"` + ResumeTime float64 `db:"resume_time"` + PlayDuration float64 `db:"play_duration"` + PlayCount int `db:"play_count"` } func (r *sceneRow) fromScene(o models.Scene) { r.ID = o.ID r.Title = zero.StringFrom(o.Title) + r.Code = zero.StringFrom(o.Code) r.Details = zero.StringFrom(o.Details) + r.Director = zero.StringFrom(o.Director) r.URL = zero.StringFrom(o.URL) if o.Date != nil { _ = r.Date.Scan(o.Date.Time) @@ -79,6 +89,15 @@ func (r *sceneRow) fromScene(o models.Scene) { r.StudioID = intFromPtr(o.StudioID) r.CreatedAt = models.SQLiteTimestamp{Timestamp: o.CreatedAt} r.UpdatedAt = models.SQLiteTimestamp{Timestamp: o.UpdatedAt} + if o.LastPlayedAt != nil { + r.LastPlayedAt = models.NullSQLiteTimestamp{ + Timestamp: *o.LastPlayedAt, + Valid: true, + } + } + r.ResumeTime = o.ResumeTime + r.PlayDuration = o.PlayDuration + r.PlayCount = o.PlayCount } type sceneQueryRow struct { @@ -94,7 +113,9 @@ func (r *sceneQueryRow) resolve() *models.Scene { ret := &models.Scene{ ID: r.ID, Title: r.Title.String, + Code: r.Code.String, Details: r.Details.String, + Director: r.Director.String, URL: r.URL.String, Date: r.Date.DatePtr(), Rating: nullIntPtr(r.Rating), @@ -108,12 +129,20 @@ func (r *sceneQueryRow) resolve() *models.Scene { CreatedAt: r.CreatedAt.Timestamp, UpdatedAt: r.UpdatedAt.Timestamp, + + ResumeTime: r.ResumeTime, + PlayDuration: r.PlayDuration, + PlayCount: r.PlayCount, } if r.PrimaryFileFolderPath.Valid && r.PrimaryFileBasename.Valid { ret.Path = filepath.Join(r.PrimaryFileFolderPath.String, r.PrimaryFileBasename.String) } + if r.LastPlayedAt.Valid { + ret.LastPlayedAt = &r.LastPlayedAt.Timestamp + } + return ret } @@ -123,7 +152,9 @@ type sceneRowRecord struct { func (r *sceneRowRecord) fromPartial(o models.ScenePartial) { r.setNullString("title", o.Title) + r.setNullString("code", o.Code) r.setNullString("details", o.Details) + r.setNullString("director", o.Director) r.setNullString("url", o.URL) r.setSQLiteDate("date", o.Date) r.setNullInt("rating", o.Rating) @@ -132,6 +163,10 @@ func (r *sceneRowRecord) fromPartial(o models.ScenePartial) { r.setNullInt("studio_id", o.StudioID) r.setSQLiteTimestamp("created_at", o.CreatedAt) r.setSQLiteTimestamp("updated_at", o.UpdatedAt) + r.setSQLiteTimestamp("last_played_at", o.LastPlayedAt) + r.setFloat64("resume_time", o.ResumeTime) + r.setFloat64("play_duration", o.PlayDuration) + r.setInt("play_count", o.PlayCount) } type SceneStore struct { @@ -317,12 +352,6 @@ func (qb *SceneStore) Update(ctx context.Context, updatedObject *models.Scene) e } func (qb *SceneStore) Destroy(ctx context.Context, id int) error { - // delete all related table rows - // TODO - this should be handled by a delete cascade - if err := qb.performersRepository().destroy(ctx, []int{id}); err != nil { - return err - } - // scene markers should be handled prior to calling destroy // galleries should be handled prior to calling destroy @@ -804,10 +833,13 @@ func (qb *SceneStore) makeFilter(ctx context.Context, sceneFilter *models.SceneF query.not(qb.makeFilter(ctx, sceneFilter.Not)) } + query.handleCriterion(ctx, intCriterionHandler(sceneFilter.ID, "scenes.id", nil)) query.handleCriterion(ctx, pathCriterionHandler(sceneFilter.Path, "folders.path", "files.basename", qb.addFoldersTable)) query.handleCriterion(ctx, sceneFileCountCriterionHandler(qb, sceneFilter.FileCount)) query.handleCriterion(ctx, stringCriterionHandler(sceneFilter.Title, "scenes.title")) + query.handleCriterion(ctx, stringCriterionHandler(sceneFilter.Code, "scenes.code")) query.handleCriterion(ctx, stringCriterionHandler(sceneFilter.Details, "scenes.details")) + query.handleCriterion(ctx, stringCriterionHandler(sceneFilter.Director, "scenes.director")) query.handleCriterion(ctx, criterionHandlerFunc(func(ctx context.Context, f *filterBuilder) { if sceneFilter.Oshash != nil { qb.addSceneFilesTable(f) @@ -839,11 +871,13 @@ func (qb *SceneStore) makeFilter(ctx context.Context, sceneFilter *models.SceneF } })) - query.handleCriterion(ctx, intCriterionHandler(sceneFilter.Rating, "scenes.rating", nil)) + query.handleCriterion(ctx, intCriterionHandler(sceneFilter.Rating100, "scenes.rating", nil)) + // legacy rating handler + query.handleCriterion(ctx, rating5CriterionHandler(sceneFilter.Rating, "scenes.rating", nil)) query.handleCriterion(ctx, intCriterionHandler(sceneFilter.OCounter, "scenes.o_counter", nil)) query.handleCriterion(ctx, boolCriterionHandler(sceneFilter.Organized, "scenes.organized", nil)) - query.handleCriterion(ctx, durationCriterionHandler(sceneFilter.Duration, "video_files.duration", qb.addVideoFilesTable)) + query.handleCriterion(ctx, floatIntCriterionHandler(sceneFilter.Duration, "video_files.duration", qb.addVideoFilesTable)) query.handleCriterion(ctx, resolutionCriterionHandler(sceneFilter.Resolution, "video_files.height", "video_files.width", qb.addVideoFilesTable)) query.handleCriterion(ctx, hasMarkersCriterionHandler(sceneFilter.HasMarkers)) @@ -856,12 +890,22 @@ func (qb *SceneStore) makeFilter(ctx context.Context, sceneFilter *models.SceneF stringCriterionHandler(sceneFilter.StashID, "scene_stash_ids.stash_id")(ctx, f) } })) + query.handleCriterion(ctx, &stashIDCriterionHandler{ + c: sceneFilter.StashIDEndpoint, + stashIDRepository: qb.stashIDRepository(), + stashIDTableAs: "scene_stash_ids", + parentIDCol: "scenes.id", + }) query.handleCriterion(ctx, boolCriterionHandler(sceneFilter.Interactive, "video_files.interactive", qb.addVideoFilesTable)) query.handleCriterion(ctx, intCriterionHandler(sceneFilter.InteractiveSpeed, "video_files.interactive_speed", qb.addVideoFilesTable)) query.handleCriterion(ctx, sceneCaptionCriterionHandler(qb, sceneFilter.Captions)) + query.handleCriterion(ctx, floatIntCriterionHandler(sceneFilter.ResumeTime, "scenes.resume_time", nil)) + query.handleCriterion(ctx, floatIntCriterionHandler(sceneFilter.PlayDuration, "scenes.play_duration", nil)) + query.handleCriterion(ctx, intCriterionHandler(sceneFilter.PlayCount, "scenes.play_count", nil)) + query.handleCriterion(ctx, sceneTagsCriterionHandler(qb, sceneFilter.Tags)) query.handleCriterion(ctx, sceneTagCountCriterionHandler(qb, sceneFilter.TagCount)) query.handleCriterion(ctx, scenePerformersCriterionHandler(qb, sceneFilter.Performers)) @@ -872,6 +916,9 @@ func (qb *SceneStore) makeFilter(ctx context.Context, sceneFilter *models.SceneF query.handleCriterion(ctx, scenePerformerFavoriteCriterionHandler(sceneFilter.PerformerFavorite)) query.handleCriterion(ctx, scenePerformerAgeCriterionHandler(sceneFilter.PerformerAge)) query.handleCriterion(ctx, scenePhashDuplicatedCriterionHandler(sceneFilter.Duplicated, qb.addSceneFilesTable)) + query.handleCriterion(ctx, dateCriterionHandler(sceneFilter.Date, "scenes.date")) + query.handleCriterion(ctx, timestampCriterionHandler(sceneFilter.CreatedAt, "scenes.created_at")) + query.handleCriterion(ctx, timestampCriterionHandler(sceneFilter.UpdatedAt, "scenes.updated_at")) return query } @@ -933,7 +980,8 @@ func (qb *SceneStore) Query(ctx context.Context, options models.SceneQueryOption }, ) - searchColumns := []string{"scenes.title", "scenes.details", "folders.path", "files.basename", "files_fingerprints.fingerprint", "scene_markers.title"} + filepathColumn := "folders.path || '" + string(filepath.Separator) + "' || files.basename" + searchColumns := []string{"scenes.title", "scenes.details", filepathColumn, "files_fingerprints.fingerprint", "scene_markers.title"} query.parseQueryString(searchColumns, *q) } @@ -970,7 +1018,7 @@ func (qb *SceneStore) queryGroupedFields(ctx context.Context, options models.Sce aggregateQuery := qb.newQuery() if options.Count { - aggregateQuery.addColumn("COUNT(temp.id) as total") + aggregateQuery.addColumn("COUNT(DISTINCT temp.id) as total") } if options.TotalDuration { @@ -1052,7 +1100,7 @@ func scenePhashDuplicatedCriterionHandler(duplicatedFilter *models.PHashDuplicat } } -func durationCriterionHandler(durationFilter *models.IntCriterionInput, column string, addJoinFn func(f *filterBuilder)) criterionHandlerFunc { +func floatIntCriterionHandler(durationFilter *models.IntCriterionInput, column string, addJoinFn func(f *filterBuilder)) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if durationFilter != nil { if addJoinFn != nil { @@ -1399,6 +1447,9 @@ func (qb *SceneStore) setSceneSort(query *queryBuilder, findFilter *models.FindF addFileTable() addFolderTable() query.sortAndPagination += " ORDER BY scenes.title COLLATE NATURAL_CS " + direction + ", folders.path " + direction + ", files.basename COLLATE NATURAL_CS " + direction + case "play_count": + // handle here since getSort has special handling for _count suffix + query.sortAndPagination += " ORDER BY scenes.play_count " + direction default: query.sortAndPagination += getSort(sort, direction, "scenes") } @@ -1415,6 +1466,62 @@ func (qb *SceneStore) imageRepository() *imageRepository { } } +func (qb *SceneStore) getPlayCount(ctx context.Context, id int) (int, error) { + q := dialect.From(qb.tableMgr.table).Select("play_count").Where(goqu.Ex{"id": id}) + + const single = true + var ret int + if err := queryFunc(ctx, q, single, func(rows *sqlx.Rows) error { + if err := rows.Scan(&ret); err != nil { + return err + } + return nil + }); err != nil { + return 0, err + } + + return ret, nil +} + +func (qb *SceneStore) SaveActivity(ctx context.Context, id int, resumeTime *float64, playDuration *float64) (bool, error) { + if err := qb.tableMgr.checkIDExists(ctx, id); err != nil { + return false, err + } + + record := goqu.Record{} + + if resumeTime != nil { + record["resume_time"] = resumeTime + } + + if playDuration != nil { + record["play_duration"] = goqu.L("play_duration + ?", playDuration) + } + + if len(record) > 0 { + if err := qb.tableMgr.updateByID(ctx, id, record); err != nil { + return false, err + } + } + + return true, nil +} + +func (qb *SceneStore) IncrementWatchCount(ctx context.Context, id int) (int, error) { + if err := qb.tableMgr.checkIDExists(ctx, id); err != nil { + return 0, err + } + + if err := qb.tableMgr.updateByID(ctx, id, goqu.Record{ + "play_count": goqu.L("play_count + 1"), + "last_played_at": time.Now(), + }); err != nil { + return 0, err + } + + return qb.getPlayCount(ctx, id) +} + func (qb *SceneStore) GetCover(ctx context.Context, sceneID int) ([]byte, error) { return qb.imageRepository().get(ctx, sceneID) } @@ -1427,6 +1534,22 @@ func (qb *SceneStore) DestroyCover(ctx context.Context, sceneID int) error { return qb.imageRepository().destroy(ctx, []int{sceneID}) } +func (qb *SceneStore) AssignFiles(ctx context.Context, sceneID int, fileIDs []file.ID) error { + // assuming a file can only be assigned to a single scene + if err := scenesFilesTableMgr.destroyJoins(ctx, fileIDs); err != nil { + return err + } + + // assign primary only if destination has no files + existingFileIDs, err := qb.filesRepository().get(ctx, sceneID) + if err != nil { + return err + } + + firstPrimary := len(existingFileIDs) == 0 + return scenesFilesTableMgr.insertJoins(ctx, sceneID, firstPrimary, fileIDs) +} + func (qb *SceneStore) moviesRepository() *repository { return &repository{ tx: qb.tx, diff --git a/pkg/sqlite/scene_marker.go b/pkg/sqlite/scene_marker.go index 669ee9a6d..b0ed5ac84 100644 --- a/pkg/sqlite/scene_marker.go +++ b/pkg/sqlite/scene_marker.go @@ -131,6 +131,11 @@ func (qb *sceneMarkerQueryBuilder) makeFilter(ctx context.Context, sceneMarkerFi query.handleCriterion(ctx, sceneMarkerTagsCriterionHandler(qb, sceneMarkerFilter.Tags)) query.handleCriterion(ctx, sceneMarkerSceneTagsCriterionHandler(qb, sceneMarkerFilter.SceneTags)) query.handleCriterion(ctx, sceneMarkerPerformersCriterionHandler(qb, sceneMarkerFilter.Performers)) + query.handleCriterion(ctx, timestampCriterionHandler(sceneMarkerFilter.CreatedAt, "scene_markers.created_at")) + query.handleCriterion(ctx, timestampCriterionHandler(sceneMarkerFilter.UpdatedAt, "scene_markers.updated_at")) + query.handleCriterion(ctx, dateCriterionHandler(sceneMarkerFilter.SceneDate, "scenes.date")) + query.handleCriterion(ctx, timestampCriterionHandler(sceneMarkerFilter.SceneCreatedAt, "scenes.created_at")) + query.handleCriterion(ctx, timestampCriterionHandler(sceneMarkerFilter.SceneUpdatedAt, "scenes.updated_at")) return query } diff --git a/pkg/sqlite/scene_test.go b/pkg/sqlite/scene_test.go index 2e4dda8b3..697dba113 100644 --- a/pkg/sqlite/scene_test.go +++ b/pkg/sqlite/scene_test.go @@ -72,19 +72,25 @@ func loadSceneRelationships(ctx context.Context, expected models.Scene, actual * func Test_sceneQueryBuilder_Create(t *testing.T) { var ( - title = "title" - details = "details" - url = "url" - rating = 3 - ocounter = 5 - createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) - updatedAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) - sceneIndex = 123 - sceneIndex2 = 234 - endpoint1 = "endpoint1" - endpoint2 = "endpoint2" - stashID1 = "stashid1" - stashID2 = "stashid2" + title = "title" + code = "1337" + details = "details" + director = "director" + url = "url" + rating = 60 + ocounter = 5 + lastPlayedAt = time.Date(2002, 1, 1, 0, 0, 0, 0, time.UTC) + resumeTime = 10.0 + playCount = 3 + playDuration = 34.0 + createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) + updatedAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) + sceneIndex = 123 + sceneIndex2 = 234 + endpoint1 = "endpoint1" + endpoint2 = "endpoint2" + stashID1 = "stashid1" + stashID2 = "stashid2" date = models.NewDate("2003-02-01") @@ -100,7 +106,9 @@ func Test_sceneQueryBuilder_Create(t *testing.T) { "full", models.Scene{ Title: title, + Code: code, Details: details, + Director: director, URL: url, Date: &date, Rating: &rating, @@ -132,6 +140,10 @@ func Test_sceneQueryBuilder_Create(t *testing.T) { Endpoint: endpoint2, }, }), + LastPlayedAt: &lastPlayedAt, + ResumeTime: float64(resumeTime), + PlayCount: playCount, + PlayDuration: playDuration, }, false, }, @@ -139,7 +151,9 @@ func Test_sceneQueryBuilder_Create(t *testing.T) { "with file", models.Scene{ Title: title, + Code: code, Details: details, + Director: director, URL: url, Date: &date, Rating: &rating, @@ -174,6 +188,10 @@ func Test_sceneQueryBuilder_Create(t *testing.T) { Endpoint: endpoint2, }, }), + LastPlayedAt: &lastPlayedAt, + ResumeTime: resumeTime, + PlayCount: playCount, + PlayDuration: playDuration, }, false, }, @@ -293,19 +311,25 @@ func makeSceneFileWithID(i int) *file.VideoFile { func Test_sceneQueryBuilder_Update(t *testing.T) { var ( - title = "title" - details = "details" - url = "url" - rating = 3 - ocounter = 5 - createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) - updatedAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) - sceneIndex = 123 - sceneIndex2 = 234 - endpoint1 = "endpoint1" - endpoint2 = "endpoint2" - stashID1 = "stashid1" - stashID2 = "stashid2" + title = "title" + code = "1337" + details = "details" + director = "director" + url = "url" + rating = 60 + ocounter = 5 + lastPlayedAt = time.Date(2002, 1, 1, 0, 0, 0, 0, time.UTC) + resumeTime = 10.0 + playCount = 3 + playDuration = 34.0 + createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) + updatedAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) + sceneIndex = 123 + sceneIndex2 = 234 + endpoint1 = "endpoint1" + endpoint2 = "endpoint2" + stashID1 = "stashid1" + stashID2 = "stashid2" date = models.NewDate("2003-02-01") ) @@ -320,7 +344,9 @@ func Test_sceneQueryBuilder_Update(t *testing.T) { &models.Scene{ ID: sceneIDs[sceneIdxWithGallery], Title: title, + Code: code, Details: details, + Director: director, URL: url, Date: &date, Rating: &rating, @@ -352,6 +378,10 @@ func Test_sceneQueryBuilder_Update(t *testing.T) { Endpoint: endpoint2, }, }), + LastPlayedAt: &lastPlayedAt, + ResumeTime: resumeTime, + PlayCount: playCount, + PlayDuration: playDuration, }, false, }, @@ -481,7 +511,9 @@ func clearScenePartial() models.ScenePartial { // leave mandatory fields return models.ScenePartial{ Title: models.OptionalString{Set: true, Null: true}, + Code: models.OptionalString{Set: true, Null: true}, Details: models.OptionalString{Set: true, Null: true}, + Director: models.OptionalString{Set: true, Null: true}, URL: models.OptionalString{Set: true, Null: true}, Date: models.OptionalDate{Set: true, Null: true}, Rating: models.OptionalInt{Set: true, Null: true}, @@ -495,19 +527,25 @@ func clearScenePartial() models.ScenePartial { func Test_sceneQueryBuilder_UpdatePartial(t *testing.T) { var ( - title = "title" - details = "details" - url = "url" - rating = 3 - ocounter = 5 - createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) - updatedAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) - sceneIndex = 123 - sceneIndex2 = 234 - endpoint1 = "endpoint1" - endpoint2 = "endpoint2" - stashID1 = "stashid1" - stashID2 = "stashid2" + title = "title" + code = "1337" + details = "details" + director = "director" + url = "url" + rating = 60 + ocounter = 5 + lastPlayedAt = time.Date(2002, 1, 1, 0, 0, 0, 0, time.UTC) + resumeTime = 10.0 + playCount = 3 + playDuration = 34.0 + createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) + updatedAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) + sceneIndex = 123 + sceneIndex2 = 234 + endpoint1 = "endpoint1" + endpoint2 = "endpoint2" + stashID1 = "stashid1" + stashID2 = "stashid2" date = models.NewDate("2003-02-01") ) @@ -524,7 +562,9 @@ func Test_sceneQueryBuilder_UpdatePartial(t *testing.T) { sceneIDs[sceneIdxWithSpacedName], models.ScenePartial{ Title: models.NewOptionalString(title), + Code: models.NewOptionalString(code), Details: models.NewOptionalString(details), + Director: models.NewOptionalString(director), URL: models.NewOptionalString(url), Date: models.NewOptionalDate(date), Rating: models.NewOptionalInt(rating), @@ -571,6 +611,10 @@ func Test_sceneQueryBuilder_UpdatePartial(t *testing.T) { }, Mode: models.RelationshipUpdateModeSet, }, + LastPlayedAt: models.NewOptionalTime(lastPlayedAt), + ResumeTime: models.NewOptionalFloat64(resumeTime), + PlayCount: models.NewOptionalInt(playCount), + PlayDuration: models.NewOptionalFloat64(playDuration), }, models.Scene{ ID: sceneIDs[sceneIdxWithSpacedName], @@ -578,7 +622,9 @@ func Test_sceneQueryBuilder_UpdatePartial(t *testing.T) { makeSceneFile(sceneIdxWithSpacedName), }), Title: title, + Code: code, Details: details, + Director: director, URL: url, Date: &date, Rating: &rating, @@ -610,6 +656,10 @@ func Test_sceneQueryBuilder_UpdatePartial(t *testing.T) { Endpoint: endpoint2, }, }), + LastPlayedAt: &lastPlayedAt, + ResumeTime: resumeTime, + PlayCount: playCount, + PlayDuration: playDuration, }, false, }, @@ -1380,18 +1430,15 @@ func Test_sceneQueryBuilder_Destroy(t *testing.T) { for _, tt := range tests { runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { assert := assert.New(t) - withRollbackTxn(func(ctx context.Context) error { - if err := qb.Destroy(ctx, tt.id); (err != nil) != tt.wantErr { - t.Errorf("sceneQueryBuilder.Destroy() error = %v, wantErr %v", err, tt.wantErr) - } + if err := qb.Destroy(ctx, tt.id); (err != nil) != tt.wantErr { + t.Errorf("sceneQueryBuilder.Destroy() error = %v, wantErr %v", err, tt.wantErr) + } - // ensure cannot be found - i, err := qb.Find(ctx, tt.id) + // ensure cannot be found + i, err := qb.Find(ctx, tt.id) - assert.NotNil(err) - assert.Nil(i) - return nil - }) + assert.NotNil(err) + assert.Nil(i) }) } } @@ -1459,26 +1506,23 @@ func Test_sceneQueryBuilder_Find(t *testing.T) { for _, tt := range tests { runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { assert := assert.New(t) - withTxn(func(ctx context.Context) error { - got, err := qb.Find(ctx, tt.id) - if (err != nil) != tt.wantErr { - t.Errorf("sceneQueryBuilder.Find() error = %v, wantErr %v", err, tt.wantErr) - return nil + got, err := qb.Find(ctx, tt.id) + if (err != nil) != tt.wantErr { + t.Errorf("sceneQueryBuilder.Find() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if got != nil { + // load relationships + if err := loadSceneRelationships(ctx, *tt.want, got); err != nil { + t.Errorf("loadSceneRelationships() error = %v", err) + return } - if got != nil { - // load relationships - if err := loadSceneRelationships(ctx, *tt.want, got); err != nil { - t.Errorf("loadSceneRelationships() error = %v", err) - return nil - } + clearSceneFileIDs(got) + } - clearSceneFileIDs(got) - } - - assert.Equal(tt.want, got) - return nil - }) + assert.Equal(tt.want, got) }) } } @@ -1602,23 +1646,19 @@ func Test_sceneQueryBuilder_FindByChecksum(t *testing.T) { for _, tt := range tests { runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { - withTxn(func(ctx context.Context) error { - assert := assert.New(t) - got, err := qb.FindByChecksum(ctx, tt.checksum) - if (err != nil) != tt.wantErr { - t.Errorf("sceneQueryBuilder.FindByChecksum() error = %v, wantErr %v", err, tt.wantErr) - return nil - } + assert := assert.New(t) + got, err := qb.FindByChecksum(ctx, tt.checksum) + if (err != nil) != tt.wantErr { + t.Errorf("sceneQueryBuilder.FindByChecksum() error = %v, wantErr %v", err, tt.wantErr) + return + } - if err := postFindScenes(ctx, tt.want, got); err != nil { - t.Errorf("loadSceneRelationships() error = %v", err) - return nil - } + if err := postFindScenes(ctx, tt.want, got); err != nil { + t.Errorf("loadSceneRelationships() error = %v", err) + return + } - assert.Equal(tt.want, got) - - return nil - }) + assert.Equal(tt.want, got) }) } } @@ -1676,23 +1716,20 @@ func Test_sceneQueryBuilder_FindByOSHash(t *testing.T) { for _, tt := range tests { runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { - withTxn(func(ctx context.Context) error { - got, err := qb.FindByOSHash(ctx, tt.oshash) - if (err != nil) != tt.wantErr { - t.Errorf("sceneQueryBuilder.FindByOSHash() error = %v, wantErr %v", err, tt.wantErr) - return nil - } + got, err := qb.FindByOSHash(ctx, tt.oshash) + if (err != nil) != tt.wantErr { + t.Errorf("sceneQueryBuilder.FindByOSHash() error = %v, wantErr %v", err, tt.wantErr) + return + } - if err := postFindScenes(ctx, tt.want, got); err != nil { - t.Errorf("loadSceneRelationships() error = %v", err) - return nil - } + if err := postFindScenes(ctx, tt.want, got); err != nil { + t.Errorf("loadSceneRelationships() error = %v", err) + return + } - if !reflect.DeepEqual(got, tt.want) { - t.Errorf("sceneQueryBuilder.FindByOSHash() = %v, want %v", got, tt.want) - } - return nil - }) + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("sceneQueryBuilder.FindByOSHash() = %v, want %v", got, tt.want) + } }) } } @@ -1750,23 +1787,19 @@ func Test_sceneQueryBuilder_FindByPath(t *testing.T) { for _, tt := range tests { runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { - withTxn(func(ctx context.Context) error { - assert := assert.New(t) - got, err := qb.FindByPath(ctx, tt.path) - if (err != nil) != tt.wantErr { - t.Errorf("sceneQueryBuilder.FindByPath() error = %v, wantErr %v", err, tt.wantErr) - return nil - } + assert := assert.New(t) + got, err := qb.FindByPath(ctx, tt.path) + if (err != nil) != tt.wantErr { + t.Errorf("sceneQueryBuilder.FindByPath() error = %v, wantErr %v", err, tt.wantErr) + return + } - if err := postFindScenes(ctx, tt.want, got); err != nil { - t.Errorf("loadSceneRelationships() error = %v", err) - return nil - } + if err := postFindScenes(ctx, tt.want, got); err != nil { + t.Errorf("loadSceneRelationships() error = %v", err) + return + } - assert.Equal(tt.want, got) - - return nil - }) + assert.Equal(tt.want, got) }) } } @@ -2073,6 +2106,143 @@ func sceneQueryQ(ctx context.Context, t *testing.T, sqb models.SceneReader, q st assert.Len(t, scenes, totalScenes) } +func TestSceneQuery(t *testing.T) { + var ( + endpoint = sceneStashID(sceneIdxWithGallery).Endpoint + stashID = sceneStashID(sceneIdxWithGallery).StashID + ) + + tests := []struct { + name string + findFilter *models.FindFilterType + filter *models.SceneFilterType + includeIdxs []int + excludeIdxs []int + wantErr bool + }{ + { + "specific resume time", + nil, + &models.SceneFilterType{ + ResumeTime: &models.IntCriterionInput{ + Modifier: models.CriterionModifierEquals, + Value: int(getSceneResumeTime(sceneIdxWithGallery)), + }, + }, + []int{sceneIdxWithGallery}, + []int{sceneIdxWithMovie}, + false, + }, + { + "specific play duration", + nil, + &models.SceneFilterType{ + PlayDuration: &models.IntCriterionInput{ + Modifier: models.CriterionModifierEquals, + Value: int(getScenePlayDuration(sceneIdxWithGallery)), + }, + }, + []int{sceneIdxWithGallery}, + []int{sceneIdxWithMovie}, + false, + }, + { + "specific play count", + nil, + &models.SceneFilterType{ + PlayCount: &models.IntCriterionInput{ + Modifier: models.CriterionModifierEquals, + Value: getScenePlayCount(sceneIdxWithGallery), + }, + }, + []int{sceneIdxWithGallery}, + []int{sceneIdxWithMovie}, + false, + }, + { + "stash id with endpoint", + nil, + &models.SceneFilterType{ + StashIDEndpoint: &models.StashIDCriterionInput{ + Endpoint: &endpoint, + StashID: &stashID, + Modifier: models.CriterionModifierEquals, + }, + }, + []int{sceneIdxWithGallery}, + nil, + false, + }, + { + "exclude stash id with endpoint", + nil, + &models.SceneFilterType{ + StashIDEndpoint: &models.StashIDCriterionInput{ + Endpoint: &endpoint, + StashID: &stashID, + Modifier: models.CriterionModifierNotEquals, + }, + }, + nil, + []int{sceneIdxWithGallery}, + false, + }, + { + "null stash id with endpoint", + nil, + &models.SceneFilterType{ + StashIDEndpoint: &models.StashIDCriterionInput{ + Endpoint: &endpoint, + Modifier: models.CriterionModifierIsNull, + }, + }, + nil, + []int{sceneIdxWithGallery}, + false, + }, + { + "not null stash id with endpoint", + nil, + &models.SceneFilterType{ + StashIDEndpoint: &models.StashIDCriterionInput{ + Endpoint: &endpoint, + Modifier: models.CriterionModifierNotNull, + }, + }, + []int{sceneIdxWithGallery}, + nil, + false, + }, + } + + for _, tt := range tests { + runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { + assert := assert.New(t) + + results, err := db.Scene.Query(ctx, models.SceneQueryOptions{ + SceneFilter: tt.filter, + QueryOptions: models.QueryOptions{ + FindFilter: tt.findFilter, + }, + }) + if (err != nil) != tt.wantErr { + t.Errorf("PerformerStore.Query() error = %v, wantErr %v", err, tt.wantErr) + return + } + + include := indexesToIDs(performerIDs, tt.includeIdxs) + exclude := indexesToIDs(performerIDs, tt.excludeIdxs) + + for _, i := range include { + assert.Contains(results.IDs, i) + } + for _, e := range exclude { + assert.NotContains(results.IDs, e) + } + }) + } +} + func TestSceneQueryPath(t *testing.T) { const ( sceneIdx = 1 @@ -2097,42 +2267,6 @@ func TestSceneQueryPath(t *testing.T) { []int{sceneIdx}, []int{otherSceneIdx}, }, - { - "equals folder name", - models.StringCriterionInput{ - Value: folder, - Modifier: models.CriterionModifierEquals, - }, - []int{sceneIdx}, - nil, - }, - { - "equals folder name trailing slash", - models.StringCriterionInput{ - Value: folder + string(filepath.Separator), - Modifier: models.CriterionModifierEquals, - }, - []int{sceneIdx}, - nil, - }, - { - "equals base name", - models.StringCriterionInput{ - Value: basename, - Modifier: models.CriterionModifierEquals, - }, - []int{sceneIdx}, - nil, - }, - { - "equals base name leading slash", - models.StringCriterionInput{ - Value: string(filepath.Separator) + basename, - Modifier: models.CriterionModifierEquals, - }, - []int{sceneIdx}, - nil, - }, { "equals full path wildcard", models.StringCriterionInput{ @@ -2151,24 +2285,6 @@ func TestSceneQueryPath(t *testing.T) { []int{otherSceneIdx}, []int{sceneIdx}, }, - { - "not equals folder name", - models.StringCriterionInput{ - Value: folder, - Modifier: models.CriterionModifierNotEquals, - }, - nil, - []int{sceneIdx}, - }, - { - "not equals basename", - models.StringCriterionInput{ - Value: basename, - Modifier: models.CriterionModifierNotEquals, - }, - nil, - []int{sceneIdx}, - }, { "includes folder name", models.StringCriterionInput{ @@ -2331,7 +2447,7 @@ func TestSceneQueryPathAndRating(t *testing.T) { Modifier: models.CriterionModifierEquals, }, And: &models.SceneFilterType{ - Rating: &models.IntCriterionInput{ + Rating100: &models.IntCriterionInput{ Value: sceneRating, Modifier: models.CriterionModifierEquals, }, @@ -2371,7 +2487,7 @@ func TestSceneQueryPathNotRating(t *testing.T) { sceneFilter := models.SceneFilterType{ Path: &pathCriterion, Not: &models.SceneFilterType{ - Rating: &ratingCriterion, + Rating100: &ratingCriterion, }, } @@ -2558,25 +2674,25 @@ func TestSceneQueryRating(t *testing.T) { Modifier: models.CriterionModifierEquals, } - verifyScenesRating(t, ratingCriterion) + verifyScenesLegacyRating(t, ratingCriterion) ratingCriterion.Modifier = models.CriterionModifierNotEquals - verifyScenesRating(t, ratingCriterion) + verifyScenesLegacyRating(t, ratingCriterion) ratingCriterion.Modifier = models.CriterionModifierGreaterThan - verifyScenesRating(t, ratingCriterion) + verifyScenesLegacyRating(t, ratingCriterion) ratingCriterion.Modifier = models.CriterionModifierLessThan - verifyScenesRating(t, ratingCriterion) + verifyScenesLegacyRating(t, ratingCriterion) ratingCriterion.Modifier = models.CriterionModifierIsNull - verifyScenesRating(t, ratingCriterion) + verifyScenesLegacyRating(t, ratingCriterion) ratingCriterion.Modifier = models.CriterionModifierNotNull - verifyScenesRating(t, ratingCriterion) + verifyScenesLegacyRating(t, ratingCriterion) } -func verifyScenesRating(t *testing.T, ratingCriterion models.IntCriterionInput) { +func verifyScenesLegacyRating(t *testing.T, ratingCriterion models.IntCriterionInput) { withTxn(func(ctx context.Context) error { sqb := db.Scene sceneFilter := models.SceneFilterType{ @@ -2585,6 +2701,51 @@ func verifyScenesRating(t *testing.T, ratingCriterion models.IntCriterionInput) scenes := queryScene(ctx, t, sqb, &sceneFilter, nil) + // convert criterion value to the 100 value + ratingCriterion.Value = models.Rating5To100(ratingCriterion.Value) + + for _, scene := range scenes { + verifyIntPtr(t, scene.Rating, ratingCriterion) + } + + return nil + }) +} + +func TestSceneQueryRating100(t *testing.T) { + const rating = 60 + ratingCriterion := models.IntCriterionInput{ + Value: rating, + Modifier: models.CriterionModifierEquals, + } + + verifyScenesRating100(t, ratingCriterion) + + ratingCriterion.Modifier = models.CriterionModifierNotEquals + verifyScenesRating100(t, ratingCriterion) + + ratingCriterion.Modifier = models.CriterionModifierGreaterThan + verifyScenesRating100(t, ratingCriterion) + + ratingCriterion.Modifier = models.CriterionModifierLessThan + verifyScenesRating100(t, ratingCriterion) + + ratingCriterion.Modifier = models.CriterionModifierIsNull + verifyScenesRating100(t, ratingCriterion) + + ratingCriterion.Modifier = models.CriterionModifierNotNull + verifyScenesRating100(t, ratingCriterion) +} + +func verifyScenesRating100(t *testing.T, ratingCriterion models.IntCriterionInput) { + withTxn(func(ctx context.Context) error { + sqb := db.Scene + sceneFilter := models.SceneFilterType{ + Rating100: &ratingCriterion, + } + + scenes := queryScene(ctx, t, sqb, &sceneFilter, nil) + for _, scene := range scenes { verifyIntPtr(t, scene.Rating, ratingCriterion) } @@ -3607,6 +3768,34 @@ func TestSceneQuerySorting(t *testing.T) { -1, -1, }, + { + "play_count", + "play_count", + models.SortDirectionEnumDesc, + sceneIDs[sceneIdx1WithPerformer], + -1, + }, + { + "last_played_at", + "last_played_at", + models.SortDirectionEnumDesc, + sceneIDs[sceneIdx1WithPerformer], + -1, + }, + { + "resume_time", + "resume_time", + models.SortDirectionEnumDesc, + sceneIDs[sceneIdx1WithPerformer], + -1, + }, + { + "play_duration", + "play_duration", + models.SortDirectionEnumDesc, + sceneIDs[sceneIdx1WithPerformer], + -1, + }, } qb := db.Scene @@ -4113,5 +4302,196 @@ func TestSceneStore_FindDuplicates(t *testing.T) { }) } +func TestSceneStore_AssignFiles(t *testing.T) { + tests := []struct { + name string + sceneID int + fileID file.ID + wantErr bool + }{ + { + "valid", + sceneIDs[sceneIdx1WithPerformer], + sceneFileIDs[sceneIdx1WithStudio], + false, + }, + { + "invalid file id", + sceneIDs[sceneIdx1WithPerformer], + invalidFileID, + true, + }, + { + "invalid scene id", + invalidID, + sceneFileIDs[sceneIdx1WithStudio], + true, + }, + } + + qb := db.Scene + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + withRollbackTxn(func(ctx context.Context) error { + if err := qb.AssignFiles(ctx, tt.sceneID, []file.ID{tt.fileID}); (err != nil) != tt.wantErr { + t.Errorf("SceneStore.AssignFiles() error = %v, wantErr %v", err, tt.wantErr) + } + + return nil + }) + }) + } +} + +func TestSceneStore_IncrementWatchCount(t *testing.T) { + tests := []struct { + name string + sceneID int + expectedCount int + wantErr bool + }{ + { + "valid", + sceneIDs[sceneIdx1WithPerformer], + getScenePlayCount(sceneIdx1WithPerformer) + 1, + false, + }, + { + "invalid scene id", + invalidID, + 0, + true, + }, + } + + qb := db.Scene + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + withRollbackTxn(func(ctx context.Context) error { + newVal, err := qb.IncrementWatchCount(ctx, tt.sceneID) + if (err != nil) != tt.wantErr { + t.Errorf("SceneStore.IncrementWatchCount() error = %v, wantErr %v", err, tt.wantErr) + } + + if err != nil { + return nil + } + + assert := assert.New(t) + assert.Equal(tt.expectedCount, newVal) + + // find the scene and check the count + scene, err := qb.Find(ctx, tt.sceneID) + if err != nil { + t.Errorf("SceneStore.Find() error = %v", err) + } + + assert.Equal(tt.expectedCount, scene.PlayCount) + assert.True(scene.LastPlayedAt.After(time.Now().Add(-1 * time.Minute))) + + return nil + }) + }) + } +} + +func TestSceneStore_SaveActivity(t *testing.T) { + var ( + resumeTime = 111.2 + playDuration = 98.7 + ) + + tests := []struct { + name string + sceneIdx int + resumeTime *float64 + playDuration *float64 + wantErr bool + }{ + { + "both", + sceneIdx1WithPerformer, + &resumeTime, + &playDuration, + false, + }, + { + "resumeTime only", + sceneIdx1WithPerformer, + &resumeTime, + nil, + false, + }, + { + "playDuration only", + sceneIdx1WithPerformer, + nil, + &playDuration, + false, + }, + { + "none", + sceneIdx1WithPerformer, + nil, + nil, + false, + }, + { + "invalid scene id", + -1, + &resumeTime, + &playDuration, + true, + }, + } + + qb := db.Scene + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + withRollbackTxn(func(ctx context.Context) error { + id := -1 + if tt.sceneIdx != -1 { + id = sceneIDs[tt.sceneIdx] + } + + _, err := qb.SaveActivity(ctx, id, tt.resumeTime, tt.playDuration) + if (err != nil) != tt.wantErr { + t.Errorf("SceneStore.SaveActivity() error = %v, wantErr %v", err, tt.wantErr) + } + + if err != nil { + return nil + } + + assert := assert.New(t) + + // find the scene and check the values + scene, err := qb.Find(ctx, id) + if err != nil { + t.Errorf("SceneStore.Find() error = %v", err) + } + + expectedResumeTime := getSceneResumeTime(tt.sceneIdx) + expectedPlayDuration := getScenePlayDuration(tt.sceneIdx) + + if tt.resumeTime != nil { + expectedResumeTime = *tt.resumeTime + } + if tt.playDuration != nil { + expectedPlayDuration += *tt.playDuration + } + + assert.Equal(expectedResumeTime, scene.ResumeTime) + assert.Equal(expectedPlayDuration, scene.PlayDuration) + + return nil + }) + }) + } +} + // TODO Count // TODO SizeCount diff --git a/pkg/sqlite/setup_test.go b/pkg/sqlite/setup_test.go index d93ac82f8..75d3360d0 100644 --- a/pkg/sqlite/setup_test.go +++ b/pkg/sqlite/setup_test.go @@ -552,7 +552,7 @@ func populateDB() error { return fmt.Errorf("error creating movies: %s", err.Error()) } - if err := createPerformers(ctx, sqlite.PerformerReaderWriter, performersNameCase, performersNameNoCase); err != nil { + if err := createPerformers(ctx, performersNameCase, performersNameNoCase); err != nil { return fmt.Errorf("error creating performers: %s", err.Error()) } @@ -584,7 +584,7 @@ func populateDB() error { return fmt.Errorf("error creating saved filters: %s", err.Error()) } - if err := linkPerformerTags(ctx, sqlite.PerformerReaderWriter); err != nil { + if err := linkPerformerTags(ctx); err != nil { return fmt.Errorf("error linking performer tags: %s", err.Error()) } @@ -823,7 +823,7 @@ func getSceneTitle(index int) string { func getRating(index int) sql.NullInt64 { rating := index % 6 - return sql.NullInt64{Int64: int64(rating), Valid: rating > 0} + return sql.NullInt64{Int64: int64(rating * 20), Valid: rating > 0} } func getIntPtr(r sql.NullInt64) *int { @@ -944,6 +944,35 @@ func makeSceneFile(i int) *file.VideoFile { } } +func getScenePlayCount(index int) int { + return index % 5 +} + +func getScenePlayDuration(index int) float64 { + if index%5 == 0 { + return 0 + } + + return float64(index%5) * 123.4 +} + +func getSceneResumeTime(index int) float64 { + if index%5 == 0 { + return 0 + } + + return float64(index%5) * 1.2 +} + +func getSceneLastPlayed(index int) *time.Time { + if index%5 == 0 { + return nil + } + + t := time.Date(2020, 1, index%5, 1, 2, 3, 0, time.UTC) + return &t +} + func makeScene(i int) *models.Scene { title := getSceneTitle(i) details := getSceneStringValue(i, "Details") @@ -967,11 +996,13 @@ func makeScene(i int) *models.Scene { } } + rating := getRating(i) + return &models.Scene{ Title: title, Details: details, URL: getSceneEmptyString(i, urlField), - Rating: getIntPtr(getRating(i)), + Rating: getIntPtr(rating), OCounter: getOCounter(i), Date: getObjectDateObject(i), StudioID: studioID, @@ -982,6 +1013,10 @@ func makeScene(i int) *models.Scene { StashIDs: models.NewRelatedStashIDs([]models.StashID{ sceneStashID(i), }), + PlayCount: getScenePlayCount(i), + PlayDuration: getScenePlayDuration(i), + LastPlayedAt: getSceneLastPlayed(i), + ResumeTime: getSceneResumeTime(i), } } @@ -1226,8 +1261,10 @@ func getPerformerStringValue(index int, field string) string { return getPrefixedStringValue("performer", index, field) } -func getPerformerNullStringValue(index int, field string) sql.NullString { - return getPrefixedNullStringValue("performer", index, field) +func getPerformerNullStringValue(index int, field string) string { + ret := getPrefixedNullStringValue("performer", index, field) + + return ret.String } func getPerformerBoolValue(index int) bool { @@ -1235,24 +1272,29 @@ func getPerformerBoolValue(index int) bool { return index == 1 } -func getPerformerBirthdate(index int) string { +func getPerformerBirthdate(index int) *models.Date { const minAge = 18 birthdate := time.Now() birthdate = birthdate.AddDate(-minAge-index, -1, -1) - return birthdate.Format("2006-01-02") + + ret := models.Date{ + Time: birthdate, + } + return &ret } -func getPerformerDeathDate(index int) models.SQLiteDate { +func getPerformerDeathDate(index int) *models.Date { if index != 5 { - return models.SQLiteDate{} + return nil } deathDate := time.Now() deathDate = deathDate.AddDate(-index+1, -1, -1) - return models.SQLiteDate{ - String: deathDate.Format("2006-01-02"), - Valid: true, + + ret := models.Date{ + Time: deathDate, } + return &ret } func getPerformerCareerLength(index int) *string { @@ -1268,8 +1310,17 @@ func getIgnoreAutoTag(index int) bool { return index%5 == 0 } +func performerStashID(i int) models.StashID { + return models.StashID{ + StashID: getPerformerStringValue(i, "stashid"), + Endpoint: getPerformerStringValue(i, "endpoint"), + } +} + // createPerformers creates n performers with plain Name and o performers with camel cased NaMe included -func createPerformers(ctx context.Context, pqb models.PerformerReaderWriter, n int, o int) error { +func createPerformers(ctx context.Context, n int, o int) error { + pqb := db.Performer + const namePlain = "Name" const nameNoCase = "NaMe" @@ -1285,34 +1336,39 @@ func createPerformers(ctx context.Context, pqb models.PerformerReaderWriter, n i // performers [ i ] and [ n + o - i - 1 ] should have similar names with only the Name!=NaMe part different performer := models.Performer{ - Name: sql.NullString{String: getPerformerStringValue(index, name), Valid: true}, - Checksum: getPerformerStringValue(i, checksumField), - URL: getPerformerNullStringValue(i, urlField), - Favorite: sql.NullBool{Bool: getPerformerBoolValue(i), Valid: true}, - Birthdate: models.SQLiteDate{ - String: getPerformerBirthdate(i), - Valid: true, - }, + Name: getPerformerStringValue(index, name), + Checksum: getPerformerStringValue(i, checksumField), + URL: getPerformerNullStringValue(i, urlField), + Favorite: getPerformerBoolValue(i), + Birthdate: getPerformerBirthdate(i), DeathDate: getPerformerDeathDate(i), - Details: sql.NullString{String: getPerformerStringValue(i, "Details"), Valid: true}, - Ethnicity: sql.NullString{String: getPerformerStringValue(i, "Ethnicity"), Valid: true}, - Rating: getRating(i), + Details: getPerformerStringValue(i, "Details"), + Ethnicity: getPerformerStringValue(i, "Ethnicity"), + Rating: getIntPtr(getRating(i)), IgnoreAutoTag: getIgnoreAutoTag(i), } careerLength := getPerformerCareerLength(i) if careerLength != nil { - performer.CareerLength = models.NullString(*careerLength) + performer.CareerLength = *careerLength } - created, err := pqb.Create(ctx, performer) + err := pqb.Create(ctx, &performer) if err != nil { return fmt.Errorf("Error creating performer %v+: %s", performer, err.Error()) } - performerIDs = append(performerIDs, created.ID) - performerNames = append(performerNames, created.Name.String) + if (index+1)%5 != 0 { + if err := pqb.UpdateStashIDs(ctx, performer.ID, []models.StashID{ + performerStashID(i), + }); err != nil { + return fmt.Errorf("setting performer stash ids: %w", err) + } + } + + performerIDs = append(performerIDs, performer.ID) + performerNames = append(performerNames, performer.Name) } return nil @@ -1382,7 +1438,7 @@ func getTagChildCount(id int) int { return 0 } -//createTags creates n tags with plain Name and o tags with camel cased NaMe included +// createTags creates n tags with plain Name and o tags with camel cased NaMe included func createTags(ctx context.Context, tqb models.TagReaderWriter, n int, o int) error { const namePlain = "Name" const nameNoCase = "NaMe" @@ -1581,7 +1637,8 @@ func doLinks(links [][2]int, fn func(idx1, idx2 int) error) error { return nil } -func linkPerformerTags(ctx context.Context, qb models.PerformerReaderWriter) error { +func linkPerformerTags(ctx context.Context) error { + qb := db.Performer return doLinks(performerTagLinks, func(performerIndex, tagIndex int) error { performerID := performerIDs[performerIndex] tagID := tagIDs[tagIndex] diff --git a/pkg/sqlite/sql.go b/pkg/sqlite/sql.go index e80cceef8..8611f42fa 100644 --- a/pkg/sqlite/sql.go +++ b/pkg/sqlite/sql.go @@ -9,6 +9,7 @@ import ( "regexp" "strconv" "strings" + "time" "github.com/stashapp/stash/pkg/models" ) @@ -178,7 +179,77 @@ func getIntWhereClause(column string, modifier models.CriterionModifier, value i return fmt.Sprintf("%s > ?", column), args } - panic("unsupported int modifier type") + panic("unsupported int modifier type " + modifier) +} + +func getDateCriterionWhereClause(column string, input models.DateCriterionInput) (string, []interface{}) { + return getDateWhereClause(column, input.Modifier, input.Value, input.Value2) +} + +func getDateWhereClause(column string, modifier models.CriterionModifier, value string, upper *string) (string, []interface{}) { + if upper == nil { + u := time.Now().AddDate(0, 0, 1).Format(time.RFC3339) + upper = &u + } + + args := []interface{}{value} + betweenArgs := []interface{}{value, *upper} + + switch modifier { + case models.CriterionModifierIsNull: + return fmt.Sprintf("(%s IS NULL OR %s = '')", column, column), nil + case models.CriterionModifierNotNull: + return fmt.Sprintf("(%s IS NOT NULL AND %s != '')", column, column), nil + case models.CriterionModifierEquals: + return fmt.Sprintf("%s = ?", column), args + case models.CriterionModifierNotEquals: + return fmt.Sprintf("%s != ?", column), args + case models.CriterionModifierBetween: + return fmt.Sprintf("%s BETWEEN ? AND ?", column), betweenArgs + case models.CriterionModifierNotBetween: + return fmt.Sprintf("%s NOT BETWEEN ? AND ?", column), betweenArgs + case models.CriterionModifierLessThan: + return fmt.Sprintf("%s < ?", column), args + case models.CriterionModifierGreaterThan: + return fmt.Sprintf("%s > ?", column), args + } + + panic("unsupported date modifier type") +} + +func getTimestampCriterionWhereClause(column string, input models.TimestampCriterionInput) (string, []interface{}) { + return getTimestampWhereClause(column, input.Modifier, input.Value, input.Value2) +} + +func getTimestampWhereClause(column string, modifier models.CriterionModifier, value string, upper *string) (string, []interface{}) { + if upper == nil { + u := time.Now().AddDate(0, 0, 1).Format(time.RFC3339) + upper = &u + } + + args := []interface{}{value} + betweenArgs := []interface{}{value, *upper} + + switch modifier { + case models.CriterionModifierIsNull: + return fmt.Sprintf("%s IS NULL", column), nil + case models.CriterionModifierNotNull: + return fmt.Sprintf("%s IS NOT NULL", column), nil + case models.CriterionModifierEquals: + return fmt.Sprintf("%s = ?", column), args + case models.CriterionModifierNotEquals: + return fmt.Sprintf("%s != ?", column), args + case models.CriterionModifierBetween: + return fmt.Sprintf("%s BETWEEN ? AND ?", column), betweenArgs + case models.CriterionModifierNotBetween: + return fmt.Sprintf("%s NOT BETWEEN ? AND ?", column), betweenArgs + case models.CriterionModifierLessThan: + return fmt.Sprintf("%s < ?", column), args + case models.CriterionModifierGreaterThan: + return fmt.Sprintf("%s > ?", column), args + } + + panic("unsupported date modifier type") } // returns where clause and having clause diff --git a/pkg/sqlite/studio.go b/pkg/sqlite/studio.go index e7b12c9e3..8509b74d7 100644 --- a/pkg/sqlite/studio.go +++ b/pkg/sqlite/studio.go @@ -234,7 +234,9 @@ func (qb *studioQueryBuilder) makeFilter(ctx context.Context, studioFilter *mode query.handleCriterion(ctx, stringCriterionHandler(studioFilter.Name, studioTable+".name")) query.handleCriterion(ctx, stringCriterionHandler(studioFilter.Details, studioTable+".details")) query.handleCriterion(ctx, stringCriterionHandler(studioFilter.URL, studioTable+".url")) - query.handleCriterion(ctx, intCriterionHandler(studioFilter.Rating, studioTable+".rating", nil)) + query.handleCriterion(ctx, intCriterionHandler(studioFilter.Rating100, studioTable+".rating", nil)) + // legacy rating handler + query.handleCriterion(ctx, rating5CriterionHandler(studioFilter.Rating, studioTable+".rating", nil)) query.handleCriterion(ctx, boolCriterionHandler(studioFilter.IgnoreAutoTag, studioTable+".ignore_auto_tag", nil)) query.handleCriterion(ctx, criterionHandlerFunc(func(ctx context.Context, f *filterBuilder) { @@ -243,6 +245,12 @@ func (qb *studioQueryBuilder) makeFilter(ctx context.Context, studioFilter *mode stringCriterionHandler(studioFilter.StashID, "studio_stash_ids.stash_id")(ctx, f) } })) + query.handleCriterion(ctx, &stashIDCriterionHandler{ + c: studioFilter.StashIDEndpoint, + stashIDRepository: qb.stashIDRepository(), + stashIDTableAs: "studio_stash_ids", + parentIDCol: "studios.id", + }) query.handleCriterion(ctx, studioIsMissingCriterionHandler(qb, studioFilter.IsMissing)) query.handleCriterion(ctx, studioSceneCountCriterionHandler(qb, studioFilter.SceneCount)) @@ -250,6 +258,8 @@ func (qb *studioQueryBuilder) makeFilter(ctx context.Context, studioFilter *mode query.handleCriterion(ctx, studioGalleryCountCriterionHandler(qb, studioFilter.GalleryCount)) query.handleCriterion(ctx, studioParentCriterionHandler(qb, studioFilter.Parents)) query.handleCriterion(ctx, studioAliasCriterionHandler(qb, studioFilter.Aliases)) + query.handleCriterion(ctx, timestampCriterionHandler(studioFilter.CreatedAt, "studios.created_at")) + query.handleCriterion(ctx, timestampCriterionHandler(studioFilter.UpdatedAt, "studios.updated_at")) return query } diff --git a/pkg/sqlite/table.go b/pkg/sqlite/table.go index fbb3bbb89..31cdefcf9 100644 --- a/pkg/sqlite/table.go +++ b/pkg/sqlite/table.go @@ -529,6 +529,17 @@ func (t *relatedFilesTable) replaceJoins(ctx context.Context, id int, fileIDs [] return t.insertJoins(ctx, id, firstPrimary, fileIDs) } +// destroyJoins destroys all entries in the table with the provided fileIDs +func (t *relatedFilesTable) destroyJoins(ctx context.Context, fileIDs []file.ID) error { + q := dialect.Delete(t.table.table).Where(t.table.table.Col("file_id").In(fileIDs)) + + if _, err := exec(ctx, q); err != nil { + return fmt.Errorf("destroying file joins in %s: %w", t.table.table.GetTable(), err) + } + + return nil +} + func (t *relatedFilesTable) setPrimary(ctx context.Context, id int, fileID file.ID) error { table := t.table.table diff --git a/pkg/sqlite/tables.go b/pkg/sqlite/tables.go index 6acc985e7..99301d046 100644 --- a/pkg/sqlite/tables.go +++ b/pkg/sqlite/tables.go @@ -24,6 +24,9 @@ var ( scenesPerformersJoinTable = goqu.T(performersScenesTable) scenesStashIDsJoinTable = goqu.T("scene_stash_ids") scenesMoviesJoinTable = goqu.T(moviesScenesTable) + + performersTagsJoinTable = goqu.T(performersTagsTable) + performersStashIDsJoinTable = goqu.T("performer_stash_ids") ) var ( diff --git a/pkg/sqlite/tag.go b/pkg/sqlite/tag.go index 922c8f41d..9521e8a79 100644 --- a/pkg/sqlite/tag.go +++ b/pkg/sqlite/tag.go @@ -324,6 +324,8 @@ func (qb *tagQueryBuilder) makeFilter(ctx context.Context, tagFilter *models.Tag query.handleCriterion(ctx, stringCriterionHandler(tagFilter.Name, tagTable+".name")) query.handleCriterion(ctx, tagAliasCriterionHandler(qb, tagFilter.Aliases)) + + query.handleCriterion(ctx, stringCriterionHandler(tagFilter.Description, tagTable+".description")) query.handleCriterion(ctx, boolCriterionHandler(tagFilter.IgnoreAutoTag, tagTable+".ignore_auto_tag", nil)) query.handleCriterion(ctx, tagIsMissingCriterionHandler(qb, tagFilter.IsMissing)) @@ -336,6 +338,8 @@ func (qb *tagQueryBuilder) makeFilter(ctx context.Context, tagFilter *models.Tag query.handleCriterion(ctx, tagChildrenCriterionHandler(qb, tagFilter.Children)) query.handleCriterion(ctx, tagParentCountCriterionHandler(qb, tagFilter.ParentCount)) query.handleCriterion(ctx, tagChildCountCriterionHandler(qb, tagFilter.ChildCount)) + query.handleCriterion(ctx, timestampCriterionHandler(tagFilter.CreatedAt, "tags.created_at")) + query.handleCriterion(ctx, timestampCriterionHandler(tagFilter.UpdatedAt, "tags.updated_at")) return query } diff --git a/pkg/sqlite/tag_test.go b/pkg/sqlite/tag_test.go index 9309a826c..a351f28f4 100644 --- a/pkg/sqlite/tag_test.go +++ b/pkg/sqlite/tag_test.go @@ -1011,7 +1011,7 @@ func TestTagMerge(t *testing.T) { assert.Contains(g.TagIDs.List(), destID) // ensure performer points to new tag - performerTagIDs, err := sqlite.PerformerReaderWriter.GetTagIDs(ctx, performerIDs[performerIdxWithTwoTags]) + performerTagIDs, err := db.Performer.GetTagIDs(ctx, performerIDs[performerIdxWithTwoTags]) if err != nil { return err } diff --git a/pkg/sqlite/transaction.go b/pkg/sqlite/transaction.go index 42b65ad7b..c56b2fcd9 100644 --- a/pkg/sqlite/transaction.go +++ b/pkg/sqlite/transaction.go @@ -17,6 +17,7 @@ type key int const ( txnKey key = iota + 1 dbKey + exclusiveKey ) func (db *Database) WithDatabase(ctx context.Context) (context.Context, error) { @@ -28,7 +29,7 @@ func (db *Database) WithDatabase(ctx context.Context) (context.Context, error) { return context.WithValue(ctx, dbKey, db.db), nil } -func (db *Database) Begin(ctx context.Context) (context.Context, error) { +func (db *Database) Begin(ctx context.Context, exclusive bool) (context.Context, error) { if tx, _ := getTx(ctx); tx != nil { // log the stack trace so we can see logger.Error(string(debug.Stack())) @@ -36,11 +37,23 @@ func (db *Database) Begin(ctx context.Context) (context.Context, error) { return nil, fmt.Errorf("already in transaction") } + if exclusive { + if err := db.lock(ctx); err != nil { + return nil, err + } + } + tx, err := db.db.BeginTxx(ctx, nil) if err != nil { + // begin failed, unlock + if exclusive { + db.unlock() + } return nil, fmt.Errorf("beginning transaction: %w", err) } + ctx = context.WithValue(ctx, exclusiveKey, exclusive) + return context.WithValue(ctx, txnKey, tx), nil } @@ -50,6 +63,8 @@ func (db *Database) Commit(ctx context.Context) error { return err } + defer db.txnComplete(ctx) + if err := tx.Commit(); err != nil { return err } @@ -63,6 +78,8 @@ func (db *Database) Rollback(ctx context.Context) error { return err } + defer db.txnComplete(ctx) + if err := tx.Rollback(); err != nil { return err } @@ -70,6 +87,12 @@ func (db *Database) Rollback(ctx context.Context) error { return nil } +func (db *Database) txnComplete(ctx context.Context) { + if exclusive := ctx.Value(exclusiveKey).(bool); exclusive { + db.unlock() + } +} + func getTx(ctx context.Context) (*sqlx.Tx, error) { tx, ok := ctx.Value(txnKey).(*sqlx.Tx) if !ok || tx == nil { @@ -108,7 +131,7 @@ func (db *Database) TxnRepository() models.Repository { Gallery: db.Gallery, Image: db.Image, Movie: MovieReaderWriter, - Performer: PerformerReaderWriter, + Performer: db.Performer, Scene: db.Scene, SceneMarker: SceneMarkerReaderWriter, ScrapedItem: ScrapedItemReaderWriter, diff --git a/pkg/sqlite/transaction_test.go b/pkg/sqlite/transaction_test.go new file mode 100644 index 000000000..00aa9c2de --- /dev/null +++ b/pkg/sqlite/transaction_test.go @@ -0,0 +1,284 @@ +//go:build integration +// +build integration + +package sqlite_test + +import ( + "context" + "errors" + "sync" + "testing" + "time" + + "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/txn" +) + +// this test is left commented out as it is not deterministic. +// func TestConcurrentExclusiveTxn(t *testing.T) { +// const ( +// workers = 8 +// loops = 100 +// innerLoops = 10 +// sleepTime = 2 * time.Millisecond +// ) +// ctx := context.Background() + +// var wg sync.WaitGroup +// for k := 0; k < workers; k++ { +// wg.Add(1) +// go func(wk int) { +// for l := 0; l < loops; l++ { +// // change this to WithReadTxn to see locked database error +// if err := txn.WithTxn(ctx, db, func(ctx context.Context) error { +// for ll := 0; ll < innerLoops; ll++ { +// scene := &models.Scene{ +// Title: "test", +// } + +// if err := db.Scene.Create(ctx, scene, nil); err != nil { +// return err +// } + +// if err := db.Scene.Destroy(ctx, scene.ID); err != nil { +// return err +// } +// } +// time.Sleep(sleepTime) + +// return nil +// }); err != nil { +// t.Errorf("worker %d loop %d: %v", wk, l, err) +// } +// } + +// wg.Done() +// }(k) +// } + +// wg.Wait() +// } + +func signalOtherThread(c chan struct{}) error { + select { + case c <- struct{}{}: + return nil + case <-time.After(10 * time.Second): + return errors.New("timed out signalling other thread") + } +} + +func waitForOtherThread(c chan struct{}) error { + select { + case <-c: + return nil + case <-time.After(10 * time.Second): + return errors.New("timed out waiting for other thread") + } +} + +func TestConcurrentReadTxn(t *testing.T) { + var wg sync.WaitGroup + ctx := context.Background() + c := make(chan struct{}) + + // first thread + wg.Add(2) + go func() { + defer wg.Done() + if err := txn.WithReadTxn(ctx, db, func(ctx context.Context) error { + scene := &models.Scene{ + Title: "test", + } + + if err := db.Scene.Create(ctx, scene, nil); err != nil { + return err + } + + // wait for other thread to start + if err := signalOtherThread(c); err != nil { + return err + } + if err := waitForOtherThread(c); err != nil { + return err + } + + if err := db.Scene.Destroy(ctx, scene.ID); err != nil { + return err + } + + return nil + }); err != nil { + t.Errorf("unexpected error in first thread: %v", err) + } + }() + + // second thread + go func() { + defer wg.Done() + _ = txn.WithReadTxn(ctx, db, func(ctx context.Context) error { + // wait for first thread + if err := waitForOtherThread(c); err != nil { + t.Errorf(err.Error()) + return err + } + + defer func() { + if err := signalOtherThread(c); err != nil { + t.Errorf(err.Error()) + } + }() + + scene := &models.Scene{ + Title: "test", + } + + // expect error when we try to do this, as the other thread has already + // modified this table + // this takes time to fail, so we need to wait for it + if err := db.Scene.Create(ctx, scene, nil); err != nil { + if !db.IsLocked(err) { + t.Errorf("unexpected error: %v", err) + } + return err + } else { + t.Errorf("expected locked error in second thread") + } + + return nil + }) + }() + + wg.Wait() +} + +func TestConcurrentExclusiveAndReadTxn(t *testing.T) { + var wg sync.WaitGroup + ctx := context.Background() + c := make(chan struct{}) + + // first thread + wg.Add(2) + go func() { + defer wg.Done() + if err := txn.WithTxn(ctx, db, func(ctx context.Context) error { + scene := &models.Scene{ + Title: "test", + } + + if err := db.Scene.Create(ctx, scene, nil); err != nil { + return err + } + + // wait for other thread to start + if err := signalOtherThread(c); err != nil { + return err + } + if err := waitForOtherThread(c); err != nil { + return err + } + + if err := db.Scene.Destroy(ctx, scene.ID); err != nil { + return err + } + + return nil + }); err != nil { + t.Errorf("unexpected error in first thread: %v", err) + } + }() + + // second thread + go func() { + defer wg.Done() + _ = txn.WithReadTxn(ctx, db, func(ctx context.Context) error { + // wait for first thread + if err := waitForOtherThread(c); err != nil { + t.Errorf(err.Error()) + return err + } + + defer func() { + if err := signalOtherThread(c); err != nil { + t.Errorf(err.Error()) + } + }() + + if _, err := db.Scene.Find(ctx, sceneIDs[sceneIdx1WithPerformer]); err != nil { + t.Errorf("unexpected error: %v", err) + return err + } + + return nil + }) + }() + + wg.Wait() +} + +// this test is left commented out as it is not deterministic. +// func TestConcurrentExclusiveAndReadTxns(t *testing.T) { +// const ( +// writeWorkers = 4 +// readWorkers = 4 +// loops = 200 +// innerLoops = 10 +// sleepTime = 1 * time.Millisecond +// ) +// ctx := context.Background() + +// var wg sync.WaitGroup +// for k := 0; k < writeWorkers; k++ { +// wg.Add(1) +// go func(wk int) { +// for l := 0; l < loops; l++ { +// if err := txn.WithTxn(ctx, db, func(ctx context.Context) error { +// for ll := 0; ll < innerLoops; ll++ { +// scene := &models.Scene{ +// Title: "test", +// } + +// if err := db.Scene.Create(ctx, scene, nil); err != nil { +// return err +// } + +// if err := db.Scene.Destroy(ctx, scene.ID); err != nil { +// return err +// } +// } +// time.Sleep(sleepTime) + +// return nil +// }); err != nil { +// t.Errorf("write worker %d loop %d: %v", wk, l, err) +// } +// } + +// wg.Done() +// }(k) +// } + +// for k := 0; k < readWorkers; k++ { +// wg.Add(1) +// go func(wk int) { +// for l := 0; l < loops; l++ { +// if err := txn.WithReadTxn(ctx, db, func(ctx context.Context) error { +// for ll := 0; ll < innerLoops; ll++ { +// if _, err := db.Scene.Find(ctx, sceneIDs[ll%totalScenes]); err != nil { +// return err +// } +// } +// time.Sleep(sleepTime) + +// return nil +// }); err != nil { +// t.Errorf("read worker %d loop %d: %v", wk, l, err) +// } +// } + +// wg.Done() +// }(k) +// } + +// wg.Wait() +// } diff --git a/pkg/txn/transaction.go b/pkg/txn/transaction.go index 0a0390382..2a78da721 100644 --- a/pkg/txn/transaction.go +++ b/pkg/txn/transaction.go @@ -6,7 +6,7 @@ import ( ) type Manager interface { - Begin(ctx context.Context) (context.Context, error) + Begin(ctx context.Context, exclusive bool) (context.Context, error) Commit(ctx context.Context) error Rollback(ctx context.Context) error @@ -17,18 +17,43 @@ type DatabaseProvider interface { WithDatabase(ctx context.Context) (context.Context, error) } +type DatabaseProviderManager interface { + DatabaseProvider + Manager +} + type TxnFunc func(ctx context.Context) error // WithTxn executes fn in a transaction. If fn returns an error then // the transaction is rolled back. Otherwise it is committed. +// Transaction is exclusive. Only one thread may run a transaction +// using this function at a time. This function will wait until the +// lock is available before executing. +// This function should be used for making changes to the database. func WithTxn(ctx context.Context, m Manager, fn TxnFunc) error { - const execComplete = true - return withTxn(ctx, m, fn, execComplete) + const ( + execComplete = true + exclusive = true + ) + return withTxn(ctx, m, fn, exclusive, execComplete) } -func withTxn(ctx context.Context, m Manager, fn TxnFunc, execCompleteOnLocked bool) error { +// WithReadTxn executes fn in a transaction. If fn returns an error then +// the transaction is rolled back. Otherwise it is committed. +// Transaction is not exclusive and does not enforce read-only restrictions. +// Multiple threads can run transactions using this function concurrently, +// but concurrent writes may result in locked database error. +func WithReadTxn(ctx context.Context, m Manager, fn TxnFunc) error { + const ( + execComplete = true + exclusive = false + ) + return withTxn(ctx, m, fn, exclusive, execComplete) +} + +func withTxn(ctx context.Context, m Manager, fn TxnFunc, exclusive bool, execCompleteOnLocked bool) error { var err error - ctx, err = begin(ctx, m) + ctx, err = begin(ctx, m, exclusive) if err != nil { return err } @@ -59,9 +84,9 @@ func withTxn(ctx context.Context, m Manager, fn TxnFunc, execCompleteOnLocked bo return err } -func begin(ctx context.Context, m Manager) (context.Context, error) { +func begin(ctx context.Context, m Manager, exclusive bool) (context.Context, error) { var err error - ctx, err = m.Begin(ctx) + ctx, err = m.Begin(ctx, exclusive) if err != nil { return nil, err } @@ -102,6 +127,9 @@ func WithDatabase(ctx context.Context, p DatabaseProvider, fn TxnFunc) error { return fn(ctx) } +// Retryer is a provides WithTxn function that retries the transaction +// if it fails with a locked database error. +// Transactions are run in exclusive mode. type Retryer struct { Manager Manager // use value < 0 to retry forever @@ -113,8 +141,11 @@ func (r Retryer) WithTxn(ctx context.Context, fn TxnFunc) error { var attempt int var err error for attempt = 1; attempt <= r.Retries || r.Retries < 0; attempt++ { - const execComplete = false - err = withTxn(ctx, r.Manager, fn, execComplete) + const ( + execComplete = false + exclusive = true + ) + err = withTxn(ctx, r.Manager, fn, exclusive, execComplete) if err == nil { return nil diff --git a/scripts/cross-compile.sh b/scripts/cross-compile.sh index 3640551ae..07d187587 100755 --- a/scripts/cross-compile.sh +++ b/scripts/cross-compile.sh @@ -1,6 +1,6 @@ #!/bin/bash -COMPILER_CONTAINER="stashapp/compiler:6" +COMPILER_CONTAINER="stashapp/compiler:7" BUILD_DATE=`go run -mod=vendor scripts/getDate.go` GITHASH=`git rev-parse --short HEAD` diff --git a/scripts/test_db_generator/makeTestDB.go b/scripts/test_db_generator/makeTestDB.go index 075c809ee..347e85873 100644 --- a/scripts/test_db_generator/makeTestDB.go +++ b/scripts/test_db_generator/makeTestDB.go @@ -227,20 +227,16 @@ func makePerformers(n int) { if err := retry(100, func() error { return withTxn(func(ctx context.Context) error { name := generatePerformerName() - performer := models.Performer{ - Name: sql.NullString{String: name, Valid: true}, + performer := &models.Performer{ + Name: name, Checksum: md5.FromString(name), - Favorite: sql.NullBool{ - Bool: false, - Valid: true, - }, } // TODO - set tags - _, err := repo.Performer.Create(ctx, performer) + err := repo.Performer.Create(ctx, performer) if err != nil { - err = fmt.Errorf("error creating performer with name: %s: %s", performer.Name.String, err.Error()) + err = fmt.Errorf("error creating performer with name: %s: %s", performer.Name, err.Error()) } return err }) @@ -444,8 +440,8 @@ func makeGalleries(n int) { for ; i < batch && i < n; i++ { gallery := generateGallery(i) gallery.StudioID = getRandomStudioID(ctx) - gallery.TagIDs = getRandomTags(ctx, 0, 15) - gallery.PerformerIDs = getRandomPerformers(ctx) + gallery.TagIDs = models.NewRelatedIDs(getRandomTags(ctx, 0, 15)) + gallery.PerformerIDs = models.NewRelatedIDs(getRandomPerformers(ctx)) path := md5.FromString("gallery/" + strconv.Itoa(i)) f, err := makeZipFile(ctx, path) @@ -564,10 +560,10 @@ func getRandomStudioID(ctx context.Context) *int { func makeSceneRelationships(ctx context.Context, s *models.Scene) { // add tags - s.TagIDs = getRandomTags(ctx, 0, 15) + s.TagIDs = models.NewRelatedIDs(getRandomTags(ctx, 0, 15)) // add performers - s.PerformerIDs = getRandomPerformers(ctx) + s.PerformerIDs = models.NewRelatedIDs(getRandomPerformers(ctx)) } func makeImageRelationships(ctx context.Context, i *models.Image) { @@ -576,12 +572,12 @@ func makeImageRelationships(ctx context.Context, i *models.Image) { // add tags if rand.Intn(100) == 0 { - i.TagIDs = getRandomTags(ctx, 1, 15) + i.TagIDs = models.NewRelatedIDs(getRandomTags(ctx, 1, 15)) } // add performers if rand.Intn(100) <= 1 { - i.PerformerIDs = getRandomPerformers(ctx) + i.PerformerIDs = models.NewRelatedIDs(getRandomPerformers(ctx)) } } diff --git a/ui/v2.5/.stylelintrc b/ui/v2.5/.stylelintrc index 430e9a879..1357ed90e 100644 --- a/ui/v2.5/.stylelintrc +++ b/ui/v2.5/.stylelintrc @@ -61,7 +61,7 @@ "no-descending-specificity": null, "no-invalid-double-slash-comments": true, "no-missing-end-of-source-newline": true, - "number-max-precision": 2, + "number-max-precision": 3, "number-no-trailing-zeros": true, "order/order": [ "custom-properties", diff --git a/ui/v2.5/package.json b/ui/v2.5/package.json index 8b6a982bd..21c25ab58 100644 --- a/ui/v2.5/package.json +++ b/ui/v2.5/package.json @@ -8,7 +8,7 @@ "start": "vite", "build": "vite build", "build-ci": "yarn validate && yarn build", - "validate": "yarn lint && yarn format-check && tsc --noEmit", + "validate": "yarn lint && tsc --noEmit && yarn format-check", "lint": "yarn lint:css && yarn lint:js", "lint:js": "eslint --cache src/**/*.{ts,tsx}", "lint:css": "stylelint \"src/**/*.scss\"", @@ -36,7 +36,7 @@ "@types/react-select": "^4.0.8", "ansi-regex": "^5.0.1", "apollo-upload-client": "^14.1.3", - "axios": "0.24.0", + "axios": "^1.1.3", "base64-blob": "^1.4.1", "bootstrap": "^4.6.0", "classnames": "^2.2.6", @@ -47,7 +47,7 @@ "graphql-tag": "^2.11.0", "i18n-iso-countries": "^6.4.0", "intersection-observer": "^0.12.0", - "localforage": "1.9.0", + "localforage": "^1.9.0", "lodash-es": "^4.17.21", "mousetrap": "^1.6.5", "mousetrap-pause": "^1.0.0", @@ -66,16 +66,17 @@ "react-select": "^4.0.2", "react-slick": "^0.29.0", "remark-gfm": "^1.0.0", + "resize-observer-polyfill": "^1.5.1", "sass": "^1.32.5", "slick-carousel": "^1.8.1", "string.prototype.replaceall": "^1.0.4", "subscriptions-transport-ws": "^0.9.18", "thehandy": "^1.0.3", "universal-cookie": "^4.0.4", - "video.js": "^7.17.0", - "videojs-landscape-fullscreen": "^11.33.0", - "videojs-seek-buttons": "^2.2.0", - "videojs-vtt-thumbnails-freetube": "^0.0.15", + "video.js": "^7.20.3", + "videojs-mobile-ui": "^0.8.0", + "videojs-seek-buttons": "^3.0.1", + "videojs-vtt.js": "^0.15.4", "vite": "^2.9.13", "vite-plugin-compression": "^0.3.5", "vite-tsconfig-paths": "^3.3.17", @@ -102,7 +103,8 @@ "@types/react-router-dom": "5.1.7", "@types/react-router-hash-link": "^1.2.1", "@types/react-slick": "^0.23.8", - "@types/video.js": "^7.3.28", + "@types/video.js": "^7.3.49", + "@types/videojs-mobile-ui": "^0.5.0", "@types/videojs-seek-buttons": "^2.1.0", "@typescript-eslint/eslint-plugin": "^4.33.0", "@typescript-eslint/parser": "^4.33.0", diff --git a/ui/v2.5/src/@types/videojs-vtt.d.ts b/ui/v2.5/src/@types/videojs-vtt.d.ts new file mode 100644 index 000000000..7140e5b6e --- /dev/null +++ b/ui/v2.5/src/@types/videojs-vtt.d.ts @@ -0,0 +1,111 @@ +/* eslint-disable @typescript-eslint/naming-convention */ + +declare module "videojs-vtt.js" { + namespace vttjs { + /** + * A custom JS error object that is reported through the parser's `onparsingerror` callback. + * It has a name, code, and message property, along with all the regular properties that come with a JavaScript error object. + * + * There are two error codes that can be reported back currently: + * * 0 BadSignature + * * 1 BadTimeStamp + * + * Note: Exceptions other then ParsingError will be thrown and not reported. + */ + class ParsingError extends Error { + readonly name: string; + readonly code: number; + readonly message: string; + } + + namespace WebVTT { + /** + * A parser for the WebVTT spec in JavaScript. + */ + class Parser { + /** + * The Parser constructor is passed a window object with which it will create new `VTTCues` and `VTTRegions` + * as well as an optional `StringDecoder` object which it will use to decode the data that the `parse()` function receives. + * For ease of use, a `StringDecoder` is provided via `WebVTT.StringDecoder()`. + * If a custom `StringDecoder` object is passed in it must support the API specified by the #whatwg string encoding spec. + * + * @param window the window object to use + * @param vttjs the vtt.js module + * @param decoder the decoder to decode `parse()` data with + */ + constructor(window: Window); + constructor(window: Window, decoder: TextDecoder); + constructor(window: Window, vttjs: vttjs, decoder: TextDecoder); + + /** + * Callback that is invoked for every region that is correctly parsed. Is passed a `VTTRegion` object. + */ + onregion?: (cue: VTTRegion) => void; + + /** + * Callback that is invoked for every cue that is fully parsed. In case of streaming parsing, + * `oncue` is delayed until the cue has been completely received. Is passed a `VTTCue` object. + */ + oncue?: (cue: VTTCue) => void; + + /** + * Is invoked in response to `flush()` and after the content was parsed completely. + */ + onflush?: () => void; + + /** + * Is invoked when a parsing error has occurred. This means that some part of the WebVTT file markup is badly formed. + * Is passed a `ParsingError` object. + */ + onparsingerror?: (e: ParsingError) => void; + + /** + * Hands data in some format to the parser for parsing. The passed data format is expected to be decodable by the + * StringDecoder object that it has. The parser decodes the data and reassembles partial data (streaming), even across line breaks. + * + * @param data data to be parsed + */ + parse(data: string): this; + + /** + * Indicates that no more data is expected and will force the parser to parse any unparsed data that it may have. + * Will also trigger `onflush`. + */ + flush(): this; + } + + /** + * Helper to allow strings to be decoded instead of the default binary utf8 data. + */ + function StringDecoder(): TextDecoder; + + /** + * Parses the cue text handed to it into a tree of DOM nodes that mirrors the internal WebVTT node structure of the cue text. + * It uses the window object handed to it to construct new HTMLElements and returns a tree of DOM nodes attached to a top level div. + * + * @param window window object to use + * @param cuetext cue text to parse + */ + function convertCueToDOMTree( + window: Window, + cuetext: string + ): HTMLDivElement | null; + + /** + * Converts the cuetext of the cues passed to it to DOM trees - by calling convertCueToDOMTree - and then runs the + * processing model steps of the WebVTT specification on the divs. The processing model applies the necessary CSS styles + * to the cue divs to prepare them for display on the web page. During this process the cue divs get added to a block level element (overlay). + * The overlay should be a part of the live DOM as the algorithm will use the computed styles (only of the divs) to do overlap avoidance. + * + * @param overlay A block level element (usually a div) that the computed cues and regions will be placed into. + */ + function processCues( + window: Window, + cues: VTTCue[], + overlay: Element + ): void; + } + } + + export = vttjs; +} diff --git a/ui/v2.5/src/App.tsx b/ui/v2.5/src/App.tsx old mode 100755 new mode 100644 index 2ab84b1a1..71a0755e8 --- a/ui/v2.5/src/App.tsx +++ b/ui/v2.5/src/App.tsx @@ -8,7 +8,7 @@ import { ToastProvider } from "src/hooks/Toast"; import LightboxProvider from "src/hooks/Lightbox/context"; import { initPolyfills } from "src/polyfills"; -import locales from "src/locales"; +import locales, { registerCountry } from "src/locales"; import { useConfiguration, useConfigureUI, @@ -85,6 +85,9 @@ export const App: React.FC = () => { const defaultMessageLanguage = languageMessageString(defaultLocale); const messageLanguage = languageMessageString(language); + // register countries for the chosen language + await registerCountry(language); + const defaultMessages = (await locales[defaultMessageLanguage]()).default; const mergedMessages = cloneDeep(Object.assign({}, defaultMessages)); const chosenMessages = (await locales[messageLanguage]()).default; diff --git a/ui/v2.5/src/components/Changelog/Changelog.tsx b/ui/v2.5/src/components/Changelog/Changelog.tsx index 24e5cee5f..22d6ed640 100644 --- a/ui/v2.5/src/components/Changelog/Changelog.tsx +++ b/ui/v2.5/src/components/Changelog/Changelog.tsx @@ -22,6 +22,7 @@ import V0150 from "src/docs/en/Changelog/v0150.md"; import V0160 from "src/docs/en/Changelog/v0160.md"; import V0161 from "src/docs/en/Changelog/v0161.md"; import V0170 from "src/docs/en/Changelog/v0170.md"; +import V0180 from "src/docs/en/Changelog/v0180.md"; import { MarkdownPage } from "../Shared/MarkdownPage"; // to avoid use of explicit any @@ -60,9 +61,9 @@ const Changelog: React.FC = () => { // after new release: // add entry to releases, using the current* fields // then update the current fields. - const currentVersion = stashVersion || "v0.17.0"; + const currentVersion = stashVersion || "v0.18.0"; const currentDate = buildDate; - const currentPage = V0170; + const currentPage = V0180; const releases: IStashRelease[] = [ { @@ -71,6 +72,11 @@ const Changelog: React.FC = () => { page: currentPage, defaultOpen: true, }, + { + version: "v0.17.2", + date: "2022-10-25", + page: V0170, + }, { version: "v0.16.1", date: "2022-07-26", diff --git a/ui/v2.5/src/components/Dialogs/IdentifyDialog/FieldOptions.tsx b/ui/v2.5/src/components/Dialogs/IdentifyDialog/FieldOptions.tsx index 8b04da91b..f8c146fbb 100644 --- a/ui/v2.5/src/components/Dialogs/IdentifyDialog/FieldOptions.tsx +++ b/ui/v2.5/src/components/Dialogs/IdentifyDialog/FieldOptions.tsx @@ -3,7 +3,12 @@ import { Form, Button, Table } from "react-bootstrap"; import { Icon } from "src/components/Shared"; import * as GQL from "src/core/generated-graphql"; import { FormattedMessage, useIntl } from "react-intl"; -import { multiValueSceneFields, SceneField, sceneFields } from "./constants"; +import { + multiValueSceneFields, + SceneField, + sceneFieldMessageID, + sceneFields, +} from "./constants"; import { ThreeStateBoolean } from "./ThreeStateBoolean"; import { faCheck, @@ -13,7 +18,7 @@ import { interface IFieldOptionsEditor { options: GQL.IdentifyFieldOptions | undefined; - field: string; + field: SceneField; editField: () => void; editOptions: (o?: GQL.IdentifyFieldOptions | null) => void; editing: boolean; @@ -64,7 +69,7 @@ const FieldOptionsEditor: React.FC = ({ }, [resetOptions]); function renderField() { - return intl.formatMessage({ id: field }); + return intl.formatMessage({ id: sceneFieldMessageID(field) }); } function renderStrategy() { diff --git a/ui/v2.5/src/components/Dialogs/IdentifyDialog/constants.ts b/ui/v2.5/src/components/Dialogs/IdentifyDialog/constants.ts index 11c7fe6e8..46ed88854 100644 --- a/ui/v2.5/src/components/Dialogs/IdentifyDialog/constants.ts +++ b/ui/v2.5/src/components/Dialogs/IdentifyDialog/constants.ts @@ -10,7 +10,9 @@ export interface IScraperSource { export const sceneFields = [ "title", + "code", "date", + "director", "details", "url", "studio", @@ -25,3 +27,11 @@ export const multiValueSceneFields: SceneField[] = [ "performers", "tags", ]; + +export function sceneFieldMessageID(field: SceneField) { + if (field === "code") { + return "scene_code"; + } + + return field; +} diff --git a/ui/v2.5/src/components/FrontPage/Control.tsx b/ui/v2.5/src/components/FrontPage/Control.tsx index 4175d30d5..c655d9c3e 100644 --- a/ui/v2.5/src/components/FrontPage/Control.tsx +++ b/ui/v2.5/src/components/FrontPage/Control.tsx @@ -1,4 +1,4 @@ -import React, { useMemo } from "react"; +import React, { useContext, useMemo } from "react"; import { useIntl } from "react-intl"; import { FrontPageContent, @@ -7,6 +7,7 @@ import { } from "src/core/config"; import * as GQL from "src/core/generated-graphql"; import { useFindSavedFilter } from "src/core/StashService"; +import { ConfigurationContext } from "src/hooks/Config"; import { ListFilterModel } from "src/models/list-filter/filter"; import { GalleryRecommendationRow } from "../Galleries/GalleryRecommendationRow"; import { ImageRecommendationRow } from "../Images/ImageRecommendationRow"; @@ -98,6 +99,7 @@ interface ISavedFilterResults { const SavedFilterResults: React.FC = ({ savedFilterID, }) => { + const { configuration: config } = useContext(ConfigurationContext); const { loading, data } = useFindSavedFilter(savedFilterID.toString()); const filter = useMemo(() => { @@ -105,12 +107,12 @@ const SavedFilterResults: React.FC = ({ const { mode, filter: filterJSON } = data.findSavedFilter; - const ret = new ListFilterModel(mode); + const ret = new ListFilterModel(mode, config); ret.currentPage = 1; ret.configureFromJSON(filterJSON); ret.randomSeed = -1; return ret; - }, [data?.findSavedFilter]); + }, [data?.findSavedFilter, config]); if (loading || !data?.findSavedFilter || !filter) { return <>; @@ -128,18 +130,19 @@ interface ICustomFilterProps { const CustomFilterResults: React.FC = ({ customFilter, }) => { + const { configuration: config } = useContext(ConfigurationContext); const intl = useIntl(); const filter = useMemo(() => { const itemsPerPage = 25; - const ret = new ListFilterModel(customFilter.mode); + const ret = new ListFilterModel(customFilter.mode, config); ret.sortBy = customFilter.sortBy; ret.sortDirection = customFilter.direction; ret.itemsPerPage = itemsPerPage; ret.currentPage = 1; ret.randomSeed = -1; return ret; - }, [customFilter]); + }, [customFilter, config]); const header = customFilter.message ? intl.formatMessage( @@ -164,6 +167,10 @@ interface IProps { export const Control: React.FC = ({ content }) => { switch (content.__typename) { case "SavedFilter": + if (!(content as ISavedFilterRow).savedFilterId) { + return
Error: missing savedFilterId
; + } + return ( = ( ) => { const intl = useIntl(); const Toast = useToast(); - const [rating, setRating] = useState(); + const [rating100, setRating] = useState(); const [studioId, setStudioId] = useState(); const [ performerMode, @@ -64,7 +64,7 @@ export const EditGalleriesDialog: React.FC = ( }), }; - galleryInput.rating = getAggregateInputValue(rating, aggregateRating); + galleryInput.rating100 = getAggregateInputValue(rating100, aggregateRating); galleryInput.studio_id = getAggregateInputValue( studioId, aggregateStudioId @@ -121,7 +121,7 @@ export const EditGalleriesDialog: React.FC = ( let first = true; state.forEach((gallery: GQL.SlimGalleryDataFragment) => { - const galleryRating = gallery.rating; + const galleryRating = gallery.rating100; const GalleriestudioID = gallery?.studio?.id; const galleryPerformerIDs = (gallery.performers ?? []) .map((p) => p.id) @@ -256,14 +256,13 @@ export const EditGalleriesDialog: React.FC = ( title: intl.formatMessage({ id: "rating" }), })} - setRating(value)} disabled={isUpdating} /> - {FormUtils.renderLabel({ title: intl.formatMessage({ id: "studio" }), diff --git a/ui/v2.5/src/components/Galleries/GalleryCard.tsx b/ui/v2.5/src/components/Galleries/GalleryCard.tsx index 1b7b81c51..76673d74e 100644 --- a/ui/v2.5/src/components/Galleries/GalleryCard.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryCard.tsx @@ -160,7 +160,7 @@ export const GalleryCard: React.FC = (props) => { src={`${props.gallery.cover.paths.thumbnail}`} /> ) : undefined} - + } overlays={maybeRenderSceneStudioOverlay()} diff --git a/ui/v2.5/src/components/Galleries/GalleryDetails/Gallery.tsx b/ui/v2.5/src/components/Galleries/GalleryDetails/Gallery.tsx index 1cf4f6c46..422e45d4e 100644 --- a/ui/v2.5/src/components/Galleries/GalleryDetails/Gallery.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryDetails/Gallery.tsx @@ -9,7 +9,12 @@ import { useFindGallery, useGalleryUpdate, } from "src/core/StashService"; -import { ErrorMessage, LoadingIndicator, Icon } from "src/components/Shared"; +import { + ErrorMessage, + LoadingIndicator, + Icon, + Counter, +} from "src/components/Shared"; import Mousetrap from "mousetrap"; import { useToast } from "src/hooks"; import { OrganizedButton } from "src/components/Scenes/SceneDetails/OrganizedButton"; @@ -174,6 +179,9 @@ export const GalleryPage: React.FC = ({ gallery }) => { + {gallery.files.length > 1 && ( + + )} ) : undefined} diff --git a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryDetailPanel.tsx b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryDetailPanel.tsx index 049490b35..c2cfece58 100644 --- a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryDetailPanel.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryDetailPanel.tsx @@ -5,7 +5,7 @@ import * as GQL from "src/core/generated-graphql"; import { TextUtils } from "src/utils"; import { TagLink, TruncatedText } from "src/components/Shared"; import { PerformerCard } from "src/components/Performers/PerformerCard"; -import { RatingStars } from "src/components/Scenes/SceneDetails/RatingStars"; +import { RatingSystem } from "src/components/Shared/Rating/RatingSystem"; import { sortPerformers } from "src/core/performers"; import { galleryTitle } from "src/core/galleries"; @@ -94,10 +94,10 @@ export const GalleryDetailPanel: React.FC = ({ /> ) : undefined} - {gallery.rating ? ( + {gallery.rating100 ? (
:{" "} - +
) : ( "" diff --git a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryEditPanel.tsx b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryEditPanel.tsx index 20b32dc1b..ed85d9309 100644 --- a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryEditPanel.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryEditPanel.tsx @@ -32,7 +32,7 @@ import { import { useToast } from "src/hooks"; import { useFormik } from "formik"; import { FormUtils } from "src/utils"; -import { RatingStars } from "src/components/Scenes/SceneDetails/RatingStars"; +import { RatingSystem } from "src/components/Shared/Rating/RatingSystem"; import { GalleryScrapeDialog } from "./GalleryScrapeDialog"; import { faSyncAlt } from "@fortawesome/free-solid-svg-icons"; import { galleryTitle } from "src/core/galleries"; @@ -79,12 +79,17 @@ export const GalleryEditPanel: React.FC< const [createGallery] = useGalleryCreate(); const [updateGallery] = useGalleryUpdate(); + const titleRequired = + isNew || (gallery?.files?.length === 0 && !gallery?.folder); + const schema = yup.object({ - title: yup.string().required(), + title: titleRequired + ? yup.string().required() + : yup.string().optional().nullable(), details: yup.string().optional().nullable(), url: yup.string().optional().nullable(), date: yup.string().optional().nullable(), - rating: yup.number().optional().nullable(), + rating100: yup.number().optional().nullable(), studio_id: yup.string().optional().nullable(), performer_ids: yup.array(yup.string().required()).optional().nullable(), tag_ids: yup.array(yup.string().required()).optional().nullable(), @@ -96,7 +101,7 @@ export const GalleryEditPanel: React.FC< details: gallery?.details ?? "", url: gallery?.url ?? "", date: gallery?.date ?? "", - rating: gallery?.rating ?? null, + rating100: gallery?.rating100 ?? null, studio_id: gallery?.studio?.id, performer_ids: (gallery?.performers ?? []).map((p) => p.id), tag_ids: (gallery?.tags ?? []).map((t) => t.id), @@ -112,7 +117,7 @@ export const GalleryEditPanel: React.FC< }); function setRating(v: number) { - formik.setFieldValue("rating", v); + formik.setFieldValue("rating100", v); } interface ISceneSelectValue { @@ -145,11 +150,11 @@ export const GalleryEditPanel: React.FC< } Mousetrap.bind("0", () => setRating(NaN)); - Mousetrap.bind("1", () => setRating(1)); - Mousetrap.bind("2", () => setRating(2)); - Mousetrap.bind("3", () => setRating(3)); - Mousetrap.bind("4", () => setRating(4)); - Mousetrap.bind("5", () => setRating(5)); + Mousetrap.bind("1", () => setRating(20)); + Mousetrap.bind("2", () => setRating(40)); + Mousetrap.bind("3", () => setRating(60)); + Mousetrap.bind("4", () => setRating(80)); + Mousetrap.bind("5", () => setRating(100)); setTimeout(() => { Mousetrap.unbind("0"); @@ -478,15 +483,14 @@ export const GalleryEditPanel: React.FC< title: intl.formatMessage({ id: "rating" }), })} - - formik.setFieldValue("rating", value ?? null) + formik.setFieldValue("rating100", value ?? null) } />
- {FormUtils.renderLabel({ title: intl.formatMessage({ id: "studio" }), @@ -561,8 +565,9 @@ export const GalleryEditPanel: React.FC< })} onSetScenes(items)} + isMulti /> diff --git a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryFileInfoPanel.tsx b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryFileInfoPanel.tsx index 94456aba4..5fd71c1ac 100644 --- a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryFileInfoPanel.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryFileInfoPanel.tsx @@ -1,6 +1,6 @@ import React, { useMemo, useState } from "react"; import { Accordion, Button, Card } from "react-bootstrap"; -import { FormattedMessage } from "react-intl"; +import { FormattedMessage, FormattedTime } from "react-intl"; import { TruncatedText } from "src/components/Shared"; import DeleteFilesDialog from "src/components/Shared/DeleteFilesDialog"; import * as GQL from "src/core/generated-graphql"; @@ -44,6 +44,15 @@ const FileInfoPanel: React.FC = ( value={`file://${path}`} truncate /> + {props.file && ( + + + + )} {props.ofMany && props.onSetPrimaryFile && !props.primary && (
diff --git a/ui/v2.5/src/components/Galleries/GalleryViewer.tsx b/ui/v2.5/src/components/Galleries/GalleryViewer.tsx index 3c3006310..868f63dc1 100644 --- a/ui/v2.5/src/components/Galleries/GalleryViewer.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryViewer.tsx @@ -1,17 +1,49 @@ -import React from "react"; -import { useFindGallery } from "src/core/StashService"; +import React, { useMemo } from "react"; import { useLightbox } from "src/hooks"; import { LoadingIndicator } from "src/components/Shared"; import "flexbin/flexbin.css"; +import { + CriterionModifier, + useFindImagesQuery, +} from "src/core/generated-graphql"; interface IProps { galleryId: string; } export const GalleryViewer: React.FC = ({ galleryId }) => { - const { data, loading } = useFindGallery(galleryId); - const images = data?.findGallery?.images ?? []; - const showLightbox = useLightbox({ images, showNavigation: false }); + // TODO - add paging - don't load all images at once + const pageSize = -1; + + const currentFilter = useMemo(() => { + return { + per_page: pageSize, + sort: "path", + }; + }, [pageSize]); + + const { data, loading } = useFindImagesQuery({ + variables: { + filter: currentFilter, + image_filter: { + galleries: { + modifier: CriterionModifier.Includes, + value: [galleryId], + }, + }, + }, + }); + + const images = useMemo(() => data?.findImages?.images ?? [], [data]); + + const lightboxState = useMemo(() => { + return { + images, + showNavigation: false, + }; + }, [images]); + + const showLightbox = useLightbox(lightboxState); if (loading) return ; diff --git a/ui/v2.5/src/components/Galleries/GalleryWallCard.tsx b/ui/v2.5/src/components/Galleries/GalleryWallCard.tsx index 33f6a2dd5..a357b6722 100644 --- a/ui/v2.5/src/components/Galleries/GalleryWallCard.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryWallCard.tsx @@ -2,10 +2,11 @@ import React from "react"; import { useIntl } from "react-intl"; import { Link } from "react-router-dom"; import * as GQL from "src/core/generated-graphql"; -import { RatingStars, TruncatedText } from "src/components/Shared"; +import { TruncatedText } from "src/components/Shared"; import { TextUtils } from "src/utils"; import { useGalleryLightbox } from "src/hooks"; import { galleryTitle } from "src/core/galleries"; +import { RatingSystem } from "../Shared/Rating/RatingSystem"; const CLASSNAME = "GalleryWallCard"; const CLASSNAME_FOOTER = `${CLASSNAME}-footer`; @@ -45,7 +46,7 @@ const GalleryWallCard: React.FC = ({ gallery }) => { role="button" tabIndex={0} > - +
= ( ) => { const intl = useIntl(); const Toast = useToast(); - const [rating, setRating] = useState(); + const [rating100, setRating] = useState(); const [studioId, setStudioId] = useState(); const [ performerMode, @@ -64,7 +64,7 @@ export const EditImagesDialog: React.FC = ( }), }; - imageInput.rating = getAggregateInputValue(rating, aggregateRating); + imageInput.rating100 = getAggregateInputValue(rating100, aggregateRating); imageInput.studio_id = getAggregateInputValue(studioId, aggregateStudioId); imageInput.performer_ids = getAggregateInputIDs( @@ -112,7 +112,7 @@ export const EditImagesDialog: React.FC = ( let first = true; state.forEach((image: GQL.SlimImageDataFragment) => { - const imageRating = image.rating; + const imageRating = image.rating100; const imageStudioID = image?.studio?.id; const imagePerformerIDs = (image.performers ?? []) .map((p) => p.id) @@ -246,14 +246,13 @@ export const EditImagesDialog: React.FC = ( title: intl.formatMessage({ id: "rating" }), })} - setRating(value)} disabled={isUpdating} /> - {FormUtils.renderLabel({ title: intl.formatMessage({ id: "studio" }), diff --git a/ui/v2.5/src/components/Images/ImageCard.tsx b/ui/v2.5/src/components/Images/ImageCard.tsx index 6f012f163..bbaa14134 100644 --- a/ui/v2.5/src/components/Images/ImageCard.tsx +++ b/ui/v2.5/src/components/Images/ImageCard.tsx @@ -157,7 +157,7 @@ export const ImageCard: React.FC = (
) : undefined} - + } popovers={maybeRenderPopoverButtonGroup()} diff --git a/ui/v2.5/src/components/Images/ImageDetails/Image.tsx b/ui/v2.5/src/components/Images/ImageDetails/Image.tsx index b394f3af6..72d88bad9 100644 --- a/ui/v2.5/src/components/Images/ImageDetails/Image.tsx +++ b/ui/v2.5/src/components/Images/ImageDetails/Image.tsx @@ -11,7 +11,12 @@ import { useImageUpdate, mutateMetadataScan, } from "src/core/StashService"; -import { ErrorMessage, LoadingIndicator, Icon } from "src/components/Shared"; +import { + ErrorMessage, + LoadingIndicator, + Icon, + Counter, +} from "src/components/Shared"; import { useToast } from "src/hooks"; import * as Mousetrap from "mousetrap"; import { OCounterButton } from "src/components/Scenes/SceneDetails/OCounterButton"; @@ -178,6 +183,9 @@ export const Image: React.FC = () => { + {image.files.length > 1 && ( + + )} diff --git a/ui/v2.5/src/components/Images/ImageDetails/ImageDetailPanel.tsx b/ui/v2.5/src/components/Images/ImageDetails/ImageDetailPanel.tsx index 69202a3d2..7094e778c 100644 --- a/ui/v2.5/src/components/Images/ImageDetails/ImageDetailPanel.tsx +++ b/ui/v2.5/src/components/Images/ImageDetails/ImageDetailPanel.tsx @@ -4,7 +4,7 @@ import * as GQL from "src/core/generated-graphql"; import { TextUtils } from "src/utils"; import { TagLink, TruncatedText } from "src/components/Shared"; import { PerformerCard } from "src/components/Performers/PerformerCard"; -import { RatingStars } from "src/components/Scenes/SceneDetails/RatingStars"; +import { RatingSystem } from "src/components/Shared/Rating/RatingSystem"; import { sortPerformers } from "src/core/performers"; import { FormattedMessage, useIntl } from "react-intl"; import { objectTitle } from "src/core/files"; @@ -91,10 +91,10 @@ export const ImageDetailPanel: React.FC = (props) => { - {props.image.rating ? ( + {props.image.rating100 ? (
:{" "} - +
) : ( "" diff --git a/ui/v2.5/src/components/Images/ImageDetails/ImageEditPanel.tsx b/ui/v2.5/src/components/Images/ImageDetails/ImageEditPanel.tsx index 55d2fe42d..b16da38b5 100644 --- a/ui/v2.5/src/components/Images/ImageDetails/ImageEditPanel.tsx +++ b/ui/v2.5/src/components/Images/ImageDetails/ImageEditPanel.tsx @@ -15,7 +15,7 @@ import { useToast } from "src/hooks"; import { FormUtils } from "src/utils"; import { useFormik } from "formik"; import { Prompt } from "react-router-dom"; -import { RatingStars } from "src/components/Scenes/SceneDetails/RatingStars"; +import { RatingSystem } from "src/components/Shared/Rating/RatingSystem"; interface IProps { image: GQL.ImageDataFragment; @@ -38,7 +38,7 @@ export const ImageEditPanel: React.FC = ({ const schema = yup.object({ title: yup.string().optional().nullable(), - rating: yup.number().optional().nullable(), + rating100: yup.number().optional().nullable(), studio_id: yup.string().optional().nullable(), performer_ids: yup.array(yup.string().required()).optional().nullable(), tag_ids: yup.array(yup.string().required()).optional().nullable(), @@ -46,7 +46,7 @@ export const ImageEditPanel: React.FC = ({ const initialValues = { title: image.title ?? "", - rating: image.rating ?? null, + rating100: image.rating100 ?? null, studio_id: image.studio?.id, performer_ids: (image.performers ?? []).map((p) => p.id), tag_ids: (image.tags ?? []).map((t) => t.id), @@ -61,7 +61,7 @@ export const ImageEditPanel: React.FC = ({ }); function setRating(v: number) { - formik.setFieldValue("rating", v); + formik.setFieldValue("rating100", v); } useEffect(() => { @@ -81,11 +81,11 @@ export const ImageEditPanel: React.FC = ({ } Mousetrap.bind("0", () => setRating(NaN)); - Mousetrap.bind("1", () => setRating(1)); - Mousetrap.bind("2", () => setRating(2)); - Mousetrap.bind("3", () => setRating(3)); - Mousetrap.bind("4", () => setRating(4)); - Mousetrap.bind("5", () => setRating(5)); + Mousetrap.bind("1", () => setRating(20)); + Mousetrap.bind("2", () => setRating(40)); + Mousetrap.bind("3", () => setRating(60)); + Mousetrap.bind("4", () => setRating(80)); + Mousetrap.bind("5", () => setRating(100)); setTimeout(() => { Mousetrap.unbind("0"); @@ -194,15 +194,14 @@ export const ImageEditPanel: React.FC = ({ title: intl.formatMessage({ id: "rating" }), })} - - formik.setFieldValue("rating", value ?? null) + formik.setFieldValue("rating100", value ?? null) } /> - {FormUtils.renderLabel({ title: intl.formatMessage({ id: "studio" }), diff --git a/ui/v2.5/src/components/Images/ImageDetails/ImageFileInfoPanel.tsx b/ui/v2.5/src/components/Images/ImageDetails/ImageFileInfoPanel.tsx index a8377f12c..9d0991775 100644 --- a/ui/v2.5/src/components/Images/ImageDetails/ImageFileInfoPanel.tsx +++ b/ui/v2.5/src/components/Images/ImageDetails/ImageFileInfoPanel.tsx @@ -1,6 +1,6 @@ import React, { useState } from "react"; import { Accordion, Button, Card } from "react-bootstrap"; -import { FormattedMessage, FormattedNumber } from "react-intl"; +import { FormattedMessage, FormattedNumber, FormattedTime } from "react-intl"; import { TruncatedText } from "src/components/Shared"; import DeleteFilesDialog from "src/components/Shared/DeleteFilesDialog"; import * as GQL from "src/core/generated-graphql"; @@ -65,6 +65,13 @@ const FileInfoPanel: React.FC = ( truncate /> {renderFileSize()} + + + = ({ const { options, modifierOptions } = criterion.criterionOption; const valueStage = useRef(criterion.value); + const { configuration: config } = useContext(ConfigurationContext); const intl = useIntl(); // Configure if we are editing an existing criterion useEffect(() => { if (!editingCriterion) { - setCriterion(makeCriteria()); + setCriterion(makeCriteria(config)); } else { setCriterion(editingCriterion); } - }, [editingCriterion]); + }, [config, editingCriterion]); useEffect(() => { valueStage.current = criterion.value; @@ -73,7 +88,7 @@ export const AddFilterDialog: React.FC = ({ function onChangedCriteriaType(event: React.ChangeEvent) { const newCriterionType = event.target.value as CriterionType; - const newCriterion = makeCriteria(newCriterionType); + const newCriterion = makeCriteria(config, newCriterionType); setCriterion(newCriterion); } @@ -122,6 +137,16 @@ export const AddFilterDialog: React.FC = ({ } function renderSelect() { + // always show stashID filter + if (criterion instanceof StashIDCriterion) { + return ( + + ); + } + // Hide the value select if the modifier is "IsNull" or "NotNull" if ( criterion.modifier === CriterionModifier.IsNull || @@ -150,6 +175,9 @@ export const AddFilterDialog: React.FC = ({ options && !criterionIsHierarchicalLabelValue(criterion.value) && !criterionIsNumberValue(criterion.value) && + !criterionIsStashIDValue(criterion.value) && + !criterionIsDateValue(criterion.value) && + !criterionIsTimestampValue(criterion.value) && !Array.isArray(criterion.value) ) { defaultValue.current = criterion.value; @@ -168,11 +196,41 @@ export const AddFilterDialog: React.FC = ({ /> ); } + if (criterion instanceof DateCriterion) { + return ( + + ); + } + if (criterion instanceof TimestampCriterion) { + return ( + + ); + } if (criterion instanceof NumberCriterion) { return ( ); } + if (criterion instanceof RatingCriterion) { + return ( + + ); + } + if ( + criterion instanceof CountryCriterion && + (criterion.modifier === CriterionModifier.Equals || + criterion.modifier === CriterionModifier.NotEquals) + ) { + return ( + onValueChanged(v)} + /> + ); + } return ( ); @@ -245,12 +303,32 @@ export const AddFilterDialog: React.FC = ({ ); } + function isValid() { + if (criterion.criterionOption.type === "none") { + return false; + } + + if (criterion instanceof RatingCriterion) { + switch (criterion.modifier) { + case CriterionModifier.Equals: + case CriterionModifier.NotEquals: + case CriterionModifier.LessThan: + return !!criterion.value.value; + case CriterionModifier.Between: + case CriterionModifier.NotBetween: + return criterion.value.value < (criterion.value.value2 ?? 0); + } + } + + return true; + } + const title = !editingCriterion ? intl.formatMessage({ id: "search_filter.add_filter" }) : intl.formatMessage({ id: "search_filter.update_filter" }); return ( <> - onCancel()}> + onCancel()} className="add-filter-dialog"> {title}
@@ -260,10 +338,7 @@ export const AddFilterDialog: React.FC = ({
- diff --git a/ui/v2.5/src/components/List/Filters/DateFilter.tsx b/ui/v2.5/src/components/List/Filters/DateFilter.tsx new file mode 100644 index 000000000..9235b8f04 --- /dev/null +++ b/ui/v2.5/src/components/List/Filters/DateFilter.tsx @@ -0,0 +1,121 @@ +import React, { useRef } from "react"; +import { Form } from "react-bootstrap"; +import { useIntl } from "react-intl"; +import { CriterionModifier } from "../../../core/generated-graphql"; +import { IDateValue } from "../../../models/list-filter/types"; +import { Criterion } from "../../../models/list-filter/criteria/criterion"; + +interface IDateFilterProps { + criterion: Criterion; + onValueChanged: (value: IDateValue) => void; +} + +export const DateFilter: React.FC = ({ + criterion, + onValueChanged, +}) => { + const intl = useIntl(); + + const valueStage = useRef(criterion.value); + + function onChanged( + event: React.ChangeEvent, + property: "value" | "value2" + ) { + const { value } = event.target; + valueStage.current[property] = value; + } + + function onBlurInput() { + onValueChanged(valueStage.current); + } + + let equalsControl: JSX.Element | null = null; + if ( + criterion.modifier === CriterionModifier.Equals || + criterion.modifier === CriterionModifier.NotEquals + ) { + equalsControl = ( + + ) => + onChanged(e, "value") + } + onBlur={onBlurInput} + defaultValue={criterion.value?.value ?? ""} + placeholder={ + intl.formatMessage({ id: "criterion.value" }) + " (YYYY-MM-DD)" + } + /> + + ); + } + + let lowerControl: JSX.Element | null = null; + if ( + criterion.modifier === CriterionModifier.GreaterThan || + criterion.modifier === CriterionModifier.Between || + criterion.modifier === CriterionModifier.NotBetween + ) { + lowerControl = ( + + ) => + onChanged(e, "value") + } + onBlur={onBlurInput} + defaultValue={criterion.value?.value ?? ""} + placeholder={ + intl.formatMessage({ id: "criterion.greater_than" }) + + " (YYYY-MM-DD)" + } + /> + + ); + } + + let upperControl: JSX.Element | null = null; + if ( + criterion.modifier === CriterionModifier.LessThan || + criterion.modifier === CriterionModifier.Between || + criterion.modifier === CriterionModifier.NotBetween + ) { + upperControl = ( + + ) => + onChanged( + e, + criterion.modifier === CriterionModifier.LessThan + ? "value" + : "value2" + ) + } + onBlur={onBlurInput} + defaultValue={ + (criterion.modifier === CriterionModifier.LessThan + ? criterion.value?.value + : criterion.value?.value2) ?? "" + } + placeholder={ + intl.formatMessage({ id: "criterion.less_than" }) + " (YYYY-MM-DD)" + } + /> + + ); + } + + return ( + <> + {equalsControl} + {lowerControl} + {upperControl} + + ); +}; diff --git a/ui/v2.5/src/components/List/Filters/NumberFilter.tsx b/ui/v2.5/src/components/List/Filters/NumberFilter.tsx index d636b5fbd..c25ff9814 100644 --- a/ui/v2.5/src/components/List/Filters/NumberFilter.tsx +++ b/ui/v2.5/src/components/List/Filters/NumberFilter.tsx @@ -1,4 +1,4 @@ -import React, { useRef } from "react"; +import React from "react"; import { Form } from "react-bootstrap"; import { useIntl } from "react-intl"; import { CriterionModifier } from "../../../core/generated-graphql"; @@ -16,18 +16,15 @@ export const NumberFilter: React.FC = ({ }) => { const intl = useIntl(); - const valueStage = useRef(criterion.value); - function onChanged( event: React.ChangeEvent, property: "value" | "value2" ) { - const value = parseInt(event.target.value, 10); - valueStage.current[property] = !Number.isNaN(value) ? value : 0; - } + const numericValue = parseInt(event.target.value, 10); + const valueCopy = { ...criterion.value }; - function onBlurInput() { - onValueChanged(valueStage.current); + valueCopy[property] = !Number.isNaN(numericValue) ? numericValue : 0; + onValueChanged(valueCopy); } let equalsControl: JSX.Element | null = null; @@ -43,8 +40,7 @@ export const NumberFilter: React.FC = ({ onChange={(e: React.ChangeEvent) => onChanged(e, "value") } - onBlur={onBlurInput} - defaultValue={criterion.value?.value ?? ""} + value={criterion.value?.value ?? ""} placeholder={intl.formatMessage({ id: "criterion.value" })} />
@@ -65,8 +61,7 @@ export const NumberFilter: React.FC = ({ onChange={(e: React.ChangeEvent) => onChanged(e, "value") } - onBlur={onBlurInput} - defaultValue={criterion.value?.value ?? ""} + value={criterion.value?.value ?? ""} placeholder={intl.formatMessage({ id: "criterion.greater_than" })} /> @@ -92,8 +87,7 @@ export const NumberFilter: React.FC = ({ : "value2" ) } - onBlur={onBlurInput} - defaultValue={ + value={ (criterion.modifier === CriterionModifier.LessThan ? criterion.value?.value : criterion.value?.value2) ?? "" diff --git a/ui/v2.5/src/components/List/Filters/RatingFilter.tsx b/ui/v2.5/src/components/List/Filters/RatingFilter.tsx new file mode 100644 index 000000000..73bc7b402 --- /dev/null +++ b/ui/v2.5/src/components/List/Filters/RatingFilter.tsx @@ -0,0 +1,61 @@ +import React from "react"; +import { FormattedMessage } from "react-intl"; +import { CriterionModifier } from "../../../core/generated-graphql"; +import { INumberValue } from "../../../models/list-filter/types"; +import { Criterion } from "../../../models/list-filter/criteria/criterion"; +import { RatingSystem } from "src/components/Shared/Rating/RatingSystem"; + +interface IRatingFilterProps { + criterion: Criterion; + onValueChanged: (value: INumberValue) => void; +} + +export const RatingFilter: React.FC = ({ + criterion, + onValueChanged, +}) => { + function getRatingSystem(field: "value" | "value2") { + const defaultValue = field === "value" ? 0 : undefined; + + return ( +
+ { + onValueChanged({ + ...criterion.value, + [field]: value ?? defaultValue, + }); + }} + valueRequired + /> +
+ ); + } + + if ( + criterion.modifier === CriterionModifier.Equals || + criterion.modifier === CriterionModifier.NotEquals || + criterion.modifier === CriterionModifier.GreaterThan || + criterion.modifier === CriterionModifier.LessThan + ) { + return getRatingSystem("value"); + } + + if ( + criterion.modifier === CriterionModifier.Between || + criterion.modifier === CriterionModifier.NotBetween + ) { + return ( +
+ {getRatingSystem("value")} + + + + {getRatingSystem("value2")} +
+ ); + } + + return <>; +}; diff --git a/ui/v2.5/src/components/List/Filters/StashIDFilter.tsx b/ui/v2.5/src/components/List/Filters/StashIDFilter.tsx new file mode 100644 index 000000000..096f573b7 --- /dev/null +++ b/ui/v2.5/src/components/List/Filters/StashIDFilter.tsx @@ -0,0 +1,56 @@ +import React from "react"; +import { Form } from "react-bootstrap"; +import { useIntl } from "react-intl"; +import { IStashIDValue } from "../../../models/list-filter/types"; +import { Criterion } from "../../../models/list-filter/criteria/criterion"; +import { CriterionModifier } from "src/core/generated-graphql"; + +interface IStashIDFilterProps { + criterion: Criterion; + onValueChanged: (value: IStashIDValue) => void; +} + +export const StashIDFilter: React.FC = ({ + criterion, + onValueChanged, +}) => { + const intl = useIntl(); + + function onEndpointChanged(event: React.ChangeEvent) { + onValueChanged({ + endpoint: event.target.value, + stashID: criterion.value.stashID, + }); + } + + function onStashIDChanged(event: React.ChangeEvent) { + onValueChanged({ + stashID: event.target.value, + endpoint: criterion.value.endpoint, + }); + } + + return ( +
+ + + + {criterion.modifier !== CriterionModifier.IsNull && + criterion.modifier !== CriterionModifier.NotNull && ( + + + + )} +
+ ); +}; diff --git a/ui/v2.5/src/components/List/Filters/TimestampFilter.tsx b/ui/v2.5/src/components/List/Filters/TimestampFilter.tsx new file mode 100644 index 000000000..de6eefb72 --- /dev/null +++ b/ui/v2.5/src/components/List/Filters/TimestampFilter.tsx @@ -0,0 +1,123 @@ +import React, { useRef } from "react"; +import { Form } from "react-bootstrap"; +import { useIntl } from "react-intl"; +import { CriterionModifier } from "../../../core/generated-graphql"; +import { ITimestampValue } from "../../../models/list-filter/types"; +import { Criterion } from "../../../models/list-filter/criteria/criterion"; + +interface ITimestampFilterProps { + criterion: Criterion; + onValueChanged: (value: ITimestampValue) => void; +} + +export const TimestampFilter: React.FC = ({ + criterion, + onValueChanged, +}) => { + const intl = useIntl(); + + const valueStage = useRef(criterion.value); + + function onChanged( + event: React.ChangeEvent, + property: "value" | "value2" + ) { + const { value } = event.target; + valueStage.current[property] = value; + } + + function onBlurInput() { + onValueChanged(valueStage.current); + } + + let equalsControl: JSX.Element | null = null; + if ( + criterion.modifier === CriterionModifier.Equals || + criterion.modifier === CriterionModifier.NotEquals + ) { + equalsControl = ( + + ) => + onChanged(e, "value") + } + onBlur={onBlurInput} + defaultValue={criterion.value?.value ?? ""} + placeholder={ + intl.formatMessage({ id: "criterion.value" }) + + " (YYYY-MM-DD HH-MM)" + } + /> + + ); + } + + let lowerControl: JSX.Element | null = null; + if ( + criterion.modifier === CriterionModifier.GreaterThan || + criterion.modifier === CriterionModifier.Between || + criterion.modifier === CriterionModifier.NotBetween + ) { + lowerControl = ( + + ) => + onChanged(e, "value") + } + onBlur={onBlurInput} + defaultValue={criterion.value?.value ?? ""} + placeholder={ + intl.formatMessage({ id: "criterion.greater_than" }) + + " (YYYY-MM-DD HH-MM)" + } + /> + + ); + } + + let upperControl: JSX.Element | null = null; + if ( + criterion.modifier === CriterionModifier.LessThan || + criterion.modifier === CriterionModifier.Between || + criterion.modifier === CriterionModifier.NotBetween + ) { + upperControl = ( + + ) => + onChanged( + e, + criterion.modifier === CriterionModifier.LessThan + ? "value" + : "value2" + ) + } + onBlur={onBlurInput} + defaultValue={ + (criterion.modifier === CriterionModifier.LessThan + ? criterion.value?.value + : criterion.value?.value2) ?? "" + } + placeholder={ + intl.formatMessage({ id: "criterion.less_than" }) + + " (YYYY-MM-DD HH-MM)" + } + /> + + ); + } + + return ( + <> + {equalsControl} + {lowerControl} + {upperControl} + + ); +}; diff --git a/ui/v2.5/src/components/List/styles.scss b/ui/v2.5/src/components/List/styles.scss index 4c494c017..99933638b 100644 --- a/ui/v2.5/src/components/List/styles.scss +++ b/ui/v2.5/src/components/List/styles.scss @@ -107,3 +107,12 @@ input[type="range"].zoom-slider { } } } + +.add-filter-dialog .rating-stars { + font-size: 1.3em; + margin-left: 0.25em; +} + +.rating-filter .and-divider { + margin-left: 0.5em; +} diff --git a/ui/v2.5/src/components/MainNavbar.tsx b/ui/v2.5/src/components/MainNavbar.tsx index d92808d98..d48fb015a 100644 --- a/ui/v2.5/src/components/MainNavbar.tsx +++ b/ui/v2.5/src/components/MainNavbar.tsx @@ -95,6 +95,7 @@ const allMenuItems: IMenuItem[] = [ href: "/scenes", icon: faPlayCircle, hotkey: "g s", + userCreatable: true, }, { name: "images", diff --git a/ui/v2.5/src/components/Movies/EditMoviesDialog.tsx b/ui/v2.5/src/components/Movies/EditMoviesDialog.tsx index b8b6ed196..dae4634af 100644 --- a/ui/v2.5/src/components/Movies/EditMoviesDialog.tsx +++ b/ui/v2.5/src/components/Movies/EditMoviesDialog.tsx @@ -6,7 +6,7 @@ import * as GQL from "src/core/generated-graphql"; import { Modal, StudioSelect } from "src/components/Shared"; import { useToast } from "src/hooks"; import { FormUtils } from "src/utils"; -import { RatingStars } from "../Scenes/SceneDetails/RatingStars"; +import { RatingSystem } from "../Shared/Rating/RatingSystem"; import { getAggregateInputValue, getAggregateRating, @@ -24,7 +24,7 @@ export const EditMoviesDialog: React.FC = ( ) => { const intl = useIntl(); const Toast = useToast(); - const [rating, setRating] = useState(); + const [rating100, setRating] = useState(); const [studioId, setStudioId] = useState(); const [director, setDirector] = useState(); @@ -42,7 +42,7 @@ export const EditMoviesDialog: React.FC = ( }; // if rating is undefined - movieInput.rating = getAggregateInputValue(rating, aggregateRating); + movieInput.rating100 = getAggregateInputValue(rating100, aggregateRating); movieInput.studio_id = getAggregateInputValue(studioId, aggregateStudioId); return movieInput; @@ -77,11 +77,11 @@ export const EditMoviesDialog: React.FC = ( state.forEach((movie: GQL.MovieDataFragment) => { if (first) { first = false; - updateRating = movie.rating ?? undefined; + updateRating = movie.rating100 ?? undefined; updateStudioId = movie.studio?.id ?? undefined; updateDirector = movie.director ?? undefined; } else { - if (movie.rating !== updateRating) { + if (movie.rating100 !== updateRating) { updateRating = undefined; } if (movie.studio?.id !== updateStudioId) { @@ -124,8 +124,8 @@ export const EditMoviesDialog: React.FC = ( title: intl.formatMessage({ id: "rating" }), })} - setRating(value)} disabled={isUpdating} /> diff --git a/ui/v2.5/src/components/Movies/MovieCard.tsx b/ui/v2.5/src/components/Movies/MovieCard.tsx index b8945cbd0..5c766c97b 100644 --- a/ui/v2.5/src/components/Movies/MovieCard.tsx +++ b/ui/v2.5/src/components/Movies/MovieCard.tsx @@ -82,7 +82,7 @@ export const MovieCard: FunctionComponent = (props: IProps) => { alt={props.movie.name ?? ""} src={props.movie.front_image_path ?? ""} /> - + } details={ diff --git a/ui/v2.5/src/components/Movies/MovieDetails/MovieDetailsPanel.tsx b/ui/v2.5/src/components/Movies/MovieDetails/MovieDetailsPanel.tsx index 1542c79b8..2c4851bf6 100644 --- a/ui/v2.5/src/components/Movies/MovieDetails/MovieDetailsPanel.tsx +++ b/ui/v2.5/src/components/Movies/MovieDetails/MovieDetailsPanel.tsx @@ -2,7 +2,7 @@ import React from "react"; import { useIntl } from "react-intl"; import * as GQL from "src/core/generated-graphql"; import { DurationUtils, TextUtils } from "src/utils"; -import { RatingStars } from "src/components/Scenes/SceneDetails/RatingStars"; +import { RatingSystem } from "src/components/Shared/Rating/RatingSystem"; import { TextField, URLField } from "src/utils/field"; interface IMovieDetailsPanel { @@ -27,7 +27,7 @@ export const MovieDetailsPanel: React.FC = ({ movie }) => { } function renderRatingField() { - if (!movie.rating) { + if (!movie.rating100) { return; } @@ -35,7 +35,7 @@ export const MovieDetailsPanel: React.FC = ({ movie }) => { <>
{intl.formatMessage({ id: "rating" })}
- +
); diff --git a/ui/v2.5/src/components/Movies/MovieDetails/MovieEditPanel.tsx b/ui/v2.5/src/components/Movies/MovieDetails/MovieEditPanel.tsx index 64d70b3e1..07eb87648 100644 --- a/ui/v2.5/src/components/Movies/MovieDetails/MovieEditPanel.tsx +++ b/ui/v2.5/src/components/Movies/MovieDetails/MovieEditPanel.tsx @@ -17,7 +17,7 @@ import { import { useToast } from "src/hooks"; import { Modal as BSModal, Form, Button, Col, Row } from "react-bootstrap"; import { DurationUtils, FormUtils, ImageUtils } from "src/utils"; -import { RatingStars } from "src/components/Scenes/SceneDetails/RatingStars"; +import { RatingSystem } from "src/components/Shared/Rating/RatingSystem"; import { useFormik } from "formik"; import { Prompt } from "react-router-dom"; import { MovieScrapeDialog } from "./MovieScrapeDialog"; @@ -69,7 +69,7 @@ export const MovieEditPanel: React.FC = ({ .optional() .nullable() .matches(/^\d{4}-\d{2}-\d{2}$/), - rating: yup.number().optional().nullable(), + rating100: yup.number().optional().nullable(), studio_id: yup.string().optional().nullable(), director: yup.string().optional().nullable(), synopsis: yup.string().optional().nullable(), @@ -83,7 +83,7 @@ export const MovieEditPanel: React.FC = ({ aliases: movie?.aliases, duration: movie?.duration, date: movie?.date, - rating: movie?.rating ?? null, + rating100: movie?.rating100 ?? null, studio_id: movie?.studio?.id, director: movie?.director, synopsis: movie?.synopsis, @@ -116,17 +116,17 @@ export const MovieEditPanel: React.FC = ({ ]); function setRating(v: number) { - formik.setFieldValue("rating", v); + formik.setFieldValue("rating100", v); } // set up hotkeys useEffect(() => { Mousetrap.bind("r 0", () => setRating(NaN)); - Mousetrap.bind("r 1", () => setRating(1)); - Mousetrap.bind("r 2", () => setRating(2)); - Mousetrap.bind("r 3", () => setRating(3)); - Mousetrap.bind("r 4", () => setRating(4)); - Mousetrap.bind("r 5", () => setRating(5)); + Mousetrap.bind("r 1", () => setRating(20)); + Mousetrap.bind("r 2", () => setRating(40)); + Mousetrap.bind("r 3", () => setRating(60)); + Mousetrap.bind("r 4", () => setRating(80)); + Mousetrap.bind("r 5", () => setRating(100)); // Mousetrap.bind("u", (e) => { // setStudioFocus() // e.preventDefault(); @@ -164,7 +164,7 @@ export const MovieEditPanel: React.FC = ({ function getMovieInput(values: InputValues) { const input: Partial = { ...values, - rating: values.rating ?? null, + rating100: values.rating100 ?? null, studio_id: values.studio_id ?? null, }; @@ -432,15 +432,14 @@ export const MovieEditPanel: React.FC = ({ title: intl.formatMessage({ id: "rating" }), })} - - formik.setFieldValue("rating", value ?? null) + formik.setFieldValue("rating100", value ?? null) } /> - {FormUtils.renderLabel({ title: intl.formatMessage({ id: "url" }), diff --git a/ui/v2.5/src/components/Performers/EditPerformersDialog.tsx b/ui/v2.5/src/components/Performers/EditPerformersDialog.tsx index 634b055c2..42453a1a6 100644 --- a/ui/v2.5/src/components/Performers/EditPerformersDialog.tsx +++ b/ui/v2.5/src/components/Performers/EditPerformersDialog.tsx @@ -1,13 +1,12 @@ import React, { useEffect, useState } from "react"; -import { Form, Col, Row } from "react-bootstrap"; +import { Col, Form, Row } from "react-bootstrap"; import { FormattedMessage, useIntl } from "react-intl"; import { useBulkPerformerUpdate } from "src/core/StashService"; import * as GQL from "src/core/generated-graphql"; import { Modal } from "src/components/Shared"; import { useToast } from "src/hooks"; -import { FormUtils } from "src/utils"; import MultiSet from "../Shared/MultiSet"; -import { RatingStars } from "../Scenes/SceneDetails/RatingStars"; +import { RatingSystem } from "../Shared/Rating/RatingSystem"; import { getAggregateInputValue, getAggregateState, @@ -21,6 +20,7 @@ import { import { IndeterminateCheckbox } from "../Shared/IndeterminateCheckbox"; import { BulkUpdateTextInput } from "../Shared/BulkUpdateTextInput"; import { faPencilAlt } from "@fortawesome/free-solid-svg-icons"; +import { FormUtils } from "../../utils"; interface IListOperationProps { selected: GQL.SlimPerformerDataFragment[]; @@ -32,7 +32,7 @@ const performerFields = [ "url", "instagram", "twitter", - "rating", + "rating100", "gender", "birthdate", "death_date", @@ -90,9 +90,9 @@ export const EditPerformersDialog: React.FC = ( // we don't have unset functionality for the rating star control // so need to determine if we are setting a rating or not - performerInput.rating = getAggregateInputValue( - updateInput.rating, - aggregateState.rating + performerInput.rating100 = getAggregateInputValue( + updateInput.rating100, + aggregateState.rating100 ); // gender dropdown doesn't have unset functionality @@ -205,9 +205,9 @@ export const EditPerformersDialog: React.FC = ( title: intl.formatMessage({ id: "rating" }), })} - setUpdateField({ rating: value })} + setUpdateField({ rating100: value })} disabled={isUpdating} /> diff --git a/ui/v2.5/src/components/Performers/PerformerCard.tsx b/ui/v2.5/src/components/Performers/PerformerCard.tsx index 0f025998a..26baa8f7a 100644 --- a/ui/v2.5/src/components/Performers/PerformerCard.tsx +++ b/ui/v2.5/src/components/Performers/PerformerCard.tsx @@ -1,6 +1,6 @@ import React from "react"; import { Link } from "react-router-dom"; -import { FormattedMessage, useIntl } from "react-intl"; +import { useIntl } from "react-intl"; import * as GQL from "src/core/generated-graphql"; import { NavUtils, TextUtils } from "src/utils"; import { @@ -18,6 +18,7 @@ import { import { PopoverCountButton } from "../Shared/PopoverCountButton"; import GenderIcon from "./GenderIcon"; import { faHeart, faTag } from "@fortawesome/free-solid-svg-icons"; +import { RatingBanner } from "../Shared/RatingBanner"; export interface IPerformerCardExtraCriteria { scenes: Criterion[]; @@ -167,18 +168,10 @@ export const PerformerCard: React.FC = ({ } function maybeRenderRatingBanner() { - if (!performer.rating) { + if (!performer.rating100) { return; } - return ( -
- : {performer.rating} -
- ); + return ; } function maybeRenderFlag() { diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx index 0c9a58a0b..d3032762f 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx @@ -23,7 +23,7 @@ import { import { useLightbox, useToast } from "src/hooks"; import { ConfigurationContext } from "src/hooks/Config"; import { TextUtils } from "src/utils"; -import { RatingStars } from "src/components/Scenes/SceneDetails/RatingStars"; +import { RatingSystem } from "src/components/Shared/Rating/RatingSystem"; import { PerformerDetailsPanel } from "./PerformerDetailsPanel"; import { PerformerScenesPanel } from "./PerformerScenesPanel"; import { PerformerGalleriesPanel } from "./PerformerGalleriesPanel"; @@ -127,11 +127,11 @@ const PerformerPage: React.FC = ({ performer }) => { } Mousetrap.bind("0", () => setRating(NaN)); - Mousetrap.bind("1", () => setRating(1)); - Mousetrap.bind("2", () => setRating(2)); - Mousetrap.bind("3", () => setRating(3)); - Mousetrap.bind("4", () => setRating(4)); - Mousetrap.bind("5", () => setRating(5)); + Mousetrap.bind("1", () => setRating(20)); + Mousetrap.bind("2", () => setRating(40)); + Mousetrap.bind("3", () => setRating(60)); + Mousetrap.bind("4", () => setRating(80)); + Mousetrap.bind("5", () => setRating(100)); setTimeout(() => { Mousetrap.unbind("0"); @@ -327,7 +327,7 @@ const PerformerPage: React.FC = ({ performer }) => { variables: { input: { id: performer.id, - rating: v, + rating100: v, }, }, }); @@ -428,8 +428,8 @@ const PerformerPage: React.FC = ({ performer }) => { {performer.name} {renderClickableIcons()} - setRating(value ?? null)} /> {maybeRenderAliases()} diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerDetailsPanel.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerDetailsPanel.tsx index 63c279eca..e8c4ffc3f 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerDetailsPanel.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerDetailsPanel.tsx @@ -2,8 +2,9 @@ import React from "react"; import { FormattedMessage, useIntl } from "react-intl"; import { TagLink } from "src/components/Shared"; import * as GQL from "src/core/generated-graphql"; -import { TextUtils, getStashboxBase } from "src/utils"; +import { TextUtils, getStashboxBase, getCountryByISO } from "src/utils"; import { TextField, URLField } from "src/utils/field"; +import { cmToImperial, kgToLbs } from "src/utils/units"; interface IPerformerDetails { performer: GQL.PerformerDataFragment; @@ -71,26 +72,63 @@ export const PerformerDetailsPanel: React.FC = ({ ); } - const formatHeight = (height?: string | null) => { + const formatHeight = (height?: number | null) => { if (!height) { return ""; } - return intl.formatNumber(Number.parseInt(height, 10), { - style: "unit", - unit: "centimeter", - unitDisplay: "narrow", - }); + + const [feet, inches] = cmToImperial(height); + + return ( + + + {intl.formatNumber(height, { + style: "unit", + unit: "centimeter", + unitDisplay: "short", + })} + + + {intl.formatNumber(feet, { + style: "unit", + unit: "foot", + unitDisplay: "narrow", + })} + {intl.formatNumber(inches, { + style: "unit", + unit: "inch", + unitDisplay: "narrow", + })} + + + ); }; const formatWeight = (weight?: number | null) => { if (!weight) { return ""; } - return intl.formatNumber(weight, { - style: "unit", - unit: "kilogram", - unitDisplay: "narrow", - }); + + const lbs = kgToLbs(weight); + + return ( + + + {intl.formatNumber(weight, { + style: "unit", + unit: "kilogram", + unitDisplay: "short", + })} + + + {intl.formatNumber(lbs, { + style: "unit", + unit: "pound", + unitDisplay: "short", + })} + + + ); }; return ( @@ -114,9 +152,31 @@ export const PerformerDetailsPanel: React.FC = ({ - - - + + + {!!performer.height_cm && ( + <> +
+ +
+
{formatHeight(performer.height_cm)}
+ + )} + + {!!performer.weight && ( + <> +
+ +
+
{formatWeight(performer.weight)}
+ + )} + diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx index 3be7d87e9..d9370d9ad 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx @@ -20,9 +20,9 @@ import { CollapseButton, TagSelect, URLField, + CountrySelect, } from "src/components/Shared"; import { ImageUtils, getStashIDs } from "src/utils"; -import { getCountryByISO } from "src/utils/country"; import { useToast } from "src/hooks"; import { Prompt, useHistory } from "react-router-dom"; import { useFormik } from "formik"; @@ -106,7 +106,7 @@ export const PerformerEditPanel: React.FC = ({ ethnicity: yup.string().optional(), eye_color: yup.string().optional(), country: yup.string().optional(), - height: yup.string().optional(), + height_cm: yup.number().optional(), measurements: yup.string().optional(), fake_tits: yup.string().optional(), career_length: yup.string().optional(), @@ -133,7 +133,7 @@ export const PerformerEditPanel: React.FC = ({ ethnicity: performer.ethnicity ?? "", eye_color: performer.eye_color ?? "", country: performer.country ?? "", - height: performer.height ?? "", + height_cm: performer.height_cm ?? undefined, measurements: performer.measurements ?? "", fake_tits: performer.fake_tits ?? "", career_length: performer.career_length ?? "", @@ -279,7 +279,7 @@ export const PerformerEditPanel: React.FC = ({ formik.setFieldValue("eye_color", state.eye_color); } if (state.height) { - formik.setFieldValue("height", state.height); + formik.setFieldValue("height_cm", parseInt(state.height, 10)); } if (state.measurements) { formik.setFieldValue("measurements", state.measurements); @@ -445,7 +445,8 @@ export const PerformerEditPanel: React.FC = ({ return { ...values, gender: stringToGender(values.gender) ?? null, - weight: Number(values.weight), + height_cm: values.height_cm ? Number(values.height_cm) : null, + weight: values.weight ? Number(values.weight) : null, id: performer.id ?? "", }; } @@ -454,7 +455,8 @@ export const PerformerEditPanel: React.FC = ({ return { ...values, gender: stringToGender(values.gender), - weight: Number(values.weight), + height_cm: values.height_cm ? Number(values.height_cm) : null, + weight: values.weight ? Number(values.weight) : null, }; } @@ -545,7 +547,6 @@ export const PerformerEditPanel: React.FC = ({ const result: GQL.ScrapedPerformerDataFragment = { ...performerResult, images: performerResult.images ?? undefined, - country: getCountryByISO(performerResult.country), __typename: "ScrapedPerformer", }; @@ -636,7 +637,6 @@ export const PerformerEditPanel: React.FC = ({ ...formik.values, gender: stringToGender(formik.values.gender), image: formik.values.image ?? performer.image_path, - weight: Number(formik.values.weight), }; return ( @@ -798,16 +798,26 @@ export const PerformerEditPanel: React.FC = ({ ); } - function renderTextField(field: string, title: string, placeholder?: string) { + function renderField( + field: string, + props?: { + messageID?: string; + placeholder?: string; + type?: string; + } + ) { + const title = intl.formatMessage({ id: props?.messageID ?? field }); + return ( - + {title} @@ -878,16 +888,33 @@ export const PerformerEditPanel: React.FC = ({ - {renderTextField("birthdate", "Birthdate", "YYYY-MM-DD")} - {renderTextField("death_date", "Death Date", "YYYY-MM-DD")} - {renderTextField("country", "Country")} - {renderTextField("ethnicity", "Ethnicity")} - {renderTextField("hair_color", "Hair Color")} - {renderTextField("eye_color", "Eye Color")} - {renderTextField("height", "Height (cm)")} - {renderTextField("weight", "Weight (kg)")} - {renderTextField("measurements", "Measurements")} - {renderTextField("fake_tits", "Fake Tits")} + {renderField("birthdate", { placeholder: "YYYY-MM-DD" })} + {renderField("death_date", { placeholder: "YYYY-MM-DD" })} + + + + + + + formik.setFieldValue("country", value)} + /> + + + + {renderField("ethnicity")} + {renderField("hair_color")} + {renderField("eye_color")} + {renderField("height_cm", { + type: "number", + })} + {renderField("weight", { + type: "number", + messageID: "weight_kg", + })} + {renderField("measurements")} + {renderField("fake_tits")} @@ -917,7 +944,7 @@ export const PerformerEditPanel: React.FC = ({ - {renderTextField("career_length", "Career Length")} + {renderField("career_length")} @@ -932,8 +959,8 @@ export const PerformerEditPanel: React.FC = ({ - {renderTextField("twitter", "Twitter")} - {renderTextField("instagram", "Instagram")} + {renderField("twitter")} + {renderField("instagram")} diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerGalleriesPanel.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerGalleriesPanel.tsx index e7f4f020c..94c00051c 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerGalleriesPanel.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerGalleriesPanel.tsx @@ -1,7 +1,7 @@ import React from "react"; import * as GQL from "src/core/generated-graphql"; import { GalleryList } from "src/components/Galleries/GalleryList"; -import { performerFilterHook } from "src/core/performers"; +import { usePerformerFilterHook } from "src/core/performers"; interface IPerformerDetailsProps { performer: GQL.PerformerDataFragment; @@ -10,5 +10,6 @@ interface IPerformerDetailsProps { export const PerformerGalleriesPanel: React.FC = ({ performer, }) => { - return ; + const filterHook = usePerformerFilterHook(performer); + return ; }; diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerImagesPanel.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerImagesPanel.tsx index 6e22700ad..e78035bbb 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerImagesPanel.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerImagesPanel.tsx @@ -1,7 +1,7 @@ import React from "react"; import * as GQL from "src/core/generated-graphql"; import { ImageList } from "src/components/Images/ImageList"; -import { performerFilterHook } from "src/core/performers"; +import { usePerformerFilterHook } from "src/core/performers"; interface IPerformerImagesPanel { performer: GQL.PerformerDataFragment; @@ -10,5 +10,6 @@ interface IPerformerImagesPanel { export const PerformerImagesPanel: React.FC = ({ performer, }) => { - return ; + const filterHook = usePerformerFilterHook(performer); + return ; }; diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerMoviesPanel.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerMoviesPanel.tsx index f3facc01b..6e2609511 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerMoviesPanel.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerMoviesPanel.tsx @@ -1,7 +1,7 @@ import React from "react"; import * as GQL from "src/core/generated-graphql"; import { MovieList } from "src/components/Movies/MovieList"; -import { performerFilterHook } from "src/core/performers"; +import { usePerformerFilterHook } from "src/core/performers"; interface IPerformerDetailsProps { performer: GQL.PerformerDataFragment; @@ -10,5 +10,6 @@ interface IPerformerDetailsProps { export const PerformerMoviesPanel: React.FC = ({ performer, }) => { - return ; + const filterHook = usePerformerFilterHook(performer); + return ; }; diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScenesPanel.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScenesPanel.tsx index 6cc07b390..991908e8b 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScenesPanel.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScenesPanel.tsx @@ -1,7 +1,7 @@ import React from "react"; import * as GQL from "src/core/generated-graphql"; import { SceneList } from "src/components/Scenes/SceneList"; -import { performerFilterHook } from "src/core/performers"; +import { usePerformerFilterHook } from "src/core/performers"; interface IPerformerDetailsProps { performer: GQL.PerformerDataFragment; @@ -10,5 +10,6 @@ interface IPerformerDetailsProps { export const PerformerScenesPanel: React.FC = ({ performer, }) => { - return ; + const filterHook = usePerformerFilterHook(performer); + return ; }; diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScrapeDialog.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScrapeDialog.tsx index d18844b9e..8cca0c4b6 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScrapeDialog.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScrapeDialog.tsx @@ -8,6 +8,7 @@ import { ScrapedImageRow, ScrapeDialogRow, ScrapedTextAreaRow, + ScrapedCountryRow, } from "src/components/Shared/ScrapeDialog"; import { useTagCreate } from "src/core/StashService"; import { Form } from "react-bootstrap"; @@ -195,7 +196,10 @@ export const PerformerScrapeDialog: React.FC = ( new ScrapeResult(props.performer.eye_color, props.scraped.eye_color) ); const [height, setHeight] = useState>( - new ScrapeResult(props.performer.height, props.scraped.height) + new ScrapeResult( + props.performer.height_cm?.toString(), + props.scraped.height + ) ); const [weight, setWeight] = useState>( new ScrapeResult( @@ -448,7 +452,7 @@ export const PerformerScrapeDialog: React.FC = ( result={ethnicity} onChange={(value) => setEthnicity(value)} /> - setCountry(value)} diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerStashBoxModal.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerStashBoxModal.tsx index acdca66a7..b9c7f4316 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerStashBoxModal.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerStashBoxModal.tsx @@ -78,7 +78,7 @@ const PerformerStashBoxModal: React.FC = ({ ) : performers.length > 0 ? (
    {performers.map((p) => ( -
  • +
  • diff --git a/ui/v2.5/src/components/Performers/PerformerListTable.tsx b/ui/v2.5/src/components/Performers/PerformerListTable.tsx index 36af0e841..74a5fabc1 100644 --- a/ui/v2.5/src/components/Performers/PerformerListTable.tsx +++ b/ui/v2.5/src/components/Performers/PerformerListTable.tsx @@ -8,6 +8,7 @@ import * as GQL from "src/core/generated-graphql"; import { Icon } from "src/components/Shared"; import { NavUtils } from "src/utils"; import { faHeart } from "@fortawesome/free-solid-svg-icons"; +import { cmToImperial } from "src/utils/units"; interface IPerformerListTableProps { performers: GQL.PerformerDataFragment[]; @@ -18,6 +19,38 @@ export const PerformerListTable: React.FC = ( ) => { const intl = useIntl(); + const formatHeight = (height?: number | null) => { + if (!height) { + return ""; + } + + const [feet, inches] = cmToImperial(height); + + return ( + + + {intl.formatNumber(height, { + style: "unit", + unit: "centimeter", + unitDisplay: "short", + })} + + + {intl.formatNumber(feet, { + style: "unit", + unit: "foot", + unitDisplay: "narrow", + })} + {intl.formatNumber(inches, { + style: "unit", + unit: "inch", + unitDisplay: "narrow", + })} + + + ); + }; + const renderPerformerRow = (performer: GQL.PerformerDataFragment) => ( @@ -58,7 +91,7 @@ export const PerformerListTable: React.FC = ( {performer.birthdate} - {performer.height} + {!!performer.height_cm && formatHeight(performer.height_cm)} ); diff --git a/ui/v2.5/src/components/Performers/styles.scss b/ui/v2.5/src/components/Performers/styles.scss index a36e9263a..a0e913aea 100644 --- a/ui/v2.5/src/components/Performers/styles.scss +++ b/ui/v2.5/src/components/Performers/styles.scss @@ -41,6 +41,10 @@ } } } + + .rating-number .form-control { + width: inherit; + } } .alias { @@ -146,3 +150,27 @@ .fa-transgender-alt { color: #c8a2c8; } + +.performer-height { + .height-imperial { + &::before { + content: " ("; + } + + &::after { + content: ")"; + } + } +} + +.performer-weight { + .weight-imperial { + &::before { + content: " ("; + } + + &::after { + content: ")"; + } + } +} diff --git a/ui/v2.5/src/components/SceneDuplicateChecker/SceneDuplicateChecker.tsx b/ui/v2.5/src/components/SceneDuplicateChecker/SceneDuplicateChecker.tsx index 6ef3c2579..521ec41c6 100644 --- a/ui/v2.5/src/components/SceneDuplicateChecker/SceneDuplicateChecker.tsx +++ b/ui/v2.5/src/components/SceneDuplicateChecker/SceneDuplicateChecker.tsx @@ -31,6 +31,7 @@ import { PerformerPopoverButton } from "../Shared/PerformerPopoverButton"; import { faBox, faExclamationTriangle, + faFileAlt, faFilm, faImages, faMapMarkerAlt, @@ -38,6 +39,8 @@ import { faTag, faTrash, } from "@fortawesome/free-solid-svg-icons"; +import { SceneMergeModal } from "../Scenes/SceneMergeDialog"; +import { objectTitle } from "src/core/files"; const CLASSNAME = "duplicate-checker"; @@ -75,6 +78,10 @@ export const SceneDuplicateChecker: React.FC = () => { }, scene_filter: { is_missing: "phash", + file_count: { + modifier: GQL.CriterionModifier.GreaterThan, + value: 0, + }, }, }, }); @@ -83,6 +90,10 @@ export const SceneDuplicateChecker: React.FC = () => { GQL.SlimSceneDataFragment[] | null >(null); + const [mergeScenes, setMergeScenes] = useState< + { id: string; title: string }[] | undefined + >(undefined); + if (loading) return ; if (!data) return ; @@ -283,6 +294,26 @@ export const SceneDuplicateChecker: React.FC = () => { ); } + function maybeRenderFileCount(scene: GQL.SlimSceneDataFragment) { + if (scene.files.length <= 1) return; + + const popoverContent = ( + + ); + + return ( + + + + ); + } + function maybeRenderOrganized(scene: GQL.SlimSceneDataFragment) { if (scene.organized) { return ( @@ -303,6 +334,7 @@ export const SceneDuplicateChecker: React.FC = () => { scene.scene_markers.length > 0 || scene?.o_counter || scene.galleries.length > 0 || + scene.files.length > 1 || scene.organized ) { return ( @@ -314,6 +346,7 @@ export const SceneDuplicateChecker: React.FC = () => { {maybeRenderSceneMarkerPopoverButton(scene)} {maybeRenderOCounter(scene)} {maybeRenderGallery(scene)} + {maybeRenderFileCount(scene)} {maybeRenderOrganized(scene)} @@ -390,8 +423,58 @@ export const SceneDuplicateChecker: React.FC = () => { ); } + function renderMergeDialog() { + if (mergeScenes) { + return ( + { + setMergeScenes(undefined); + if (mergedID) { + // refresh + refetch(); + } + }} + show + /> + ); + } + } + + function onMergeClicked( + sceneGroup: GQL.SlimSceneDataFragment[], + scene: GQL.SlimSceneDataFragment + ) { + const selected = scenes.flat().filter((s) => checkedScenes[s.id]); + + // if scenes in this group other than this scene are selected, then only + // the selected scenes will be selected as source. Otherwise all other + // scenes will be source + let srcScenes = + selected.filter((s) => { + if (s === scene) return false; + return sceneGroup.includes(s); + }) ?? []; + + if (!srcScenes.length) { + srcScenes = sceneGroup.filter((s) => s !== scene); + } + + // insert subject scene to the front so that it is considered the destination + srcScenes.unshift(scene); + + setMergeScenes( + srcScenes.map((s) => { + return { + id: s.id, + title: objectTitle(s), + }; + }) + ); + } + return ( - +
    {deletingScenes && selectedScenes && ( { onClose={onDeleteDialogClosed} /> )} + {renderMergeDialog()} {maybeRenderEdit()}

    @@ -548,6 +632,12 @@ export const SceneDuplicateChecker: React.FC = () => { > + diff --git a/ui/v2.5/src/components/SceneFilenameParser/ParserField.ts b/ui/v2.5/src/components/SceneFilenameParser/ParserField.ts index 0a7fc378e..6894c6b82 100644 --- a/ui/v2.5/src/components/SceneFilenameParser/ParserField.ts +++ b/ui/v2.5/src/components/SceneFilenameParser/ParserField.ts @@ -13,7 +13,7 @@ export class ParserField { static Title = new ParserField("title"); static Ext = new ParserField("ext", "File extension"); - static Rating = new ParserField("rating"); + static Rating = new ParserField("rating100"); static I = new ParserField("i", "Matches any ignored word"); static D = new ParserField("d", "Matches any delimiter (.-_)"); diff --git a/ui/v2.5/src/components/SceneFilenameParser/SceneFilenameParser.tsx b/ui/v2.5/src/components/SceneFilenameParser/SceneFilenameParser.tsx index 6f1464af1..0074435eb 100644 --- a/ui/v2.5/src/components/SceneFilenameParser/SceneFilenameParser.tsx +++ b/ui/v2.5/src/components/SceneFilenameParser/SceneFilenameParser.tsx @@ -77,7 +77,7 @@ export const SceneFilenameParser: React.FC = () => { ParserField.fullDateFields.some((f) => { return pattern.includes(`{${f.field}}`); }); - const ratingSet = pattern.includes("{rating}"); + const ratingSet = pattern.includes("{rating100}"); const performerSet = pattern.includes("{performer}"); const tagSet = pattern.includes("{tag}"); const studioSet = pattern.includes("{studio}"); diff --git a/ui/v2.5/src/components/SceneFilenameParser/SceneParserRow.tsx b/ui/v2.5/src/components/SceneFilenameParser/SceneParserRow.tsx index bd92ec29d..446e085b2 100644 --- a/ui/v2.5/src/components/SceneFilenameParser/SceneParserRow.tsx +++ b/ui/v2.5/src/components/SceneFilenameParser/SceneParserRow.tsx @@ -54,7 +54,7 @@ export class SceneParserResult { this.filename = objectTitle(this.scene); this.title.setOriginalValue(this.scene.title ?? undefined); this.date.setOriginalValue(this.scene.date ?? undefined); - this.rating.setOriginalValue(this.scene.rating ?? undefined); + this.rating.setOriginalValue(this.scene.rating100 ?? undefined); this.performers.setOriginalValue(this.scene.performers.map((p) => p.id)); this.tags.setOriginalValue(this.scene.tags.map((t) => t.id)); this.studio.setOriginalValue(this.scene.studio?.id); diff --git a/ui/v2.5/src/components/ScenePlayer/PlaylistButtons.ts b/ui/v2.5/src/components/ScenePlayer/PlaylistButtons.ts index 0b306220c..7d2101187 100644 --- a/ui/v2.5/src/components/ScenePlayer/PlaylistButtons.ts +++ b/ui/v2.5/src/components/ScenePlayer/PlaylistButtons.ts @@ -1,28 +1,14 @@ /* eslint-disable @typescript-eslint/naming-convention */ -import VideoJS, { VideoJsPlayer } from "video.js"; +import videojs, { VideoJsPlayer } from "video.js"; -const Button = VideoJS.getComponent("Button"); - -interface ControlOptions extends VideoJS.ComponentOptions { +interface ControlOptions extends videojs.ComponentOptions { direction: "forward" | "back"; parent: SkipButtonPlugin; } -/** - * A video.js plugin. - * - * In the plugin function, the value of `this` is a video.js `Player` - * instance. You cannot rely on the player being in a "ready" state here, - * depending on how the plugin is invoked. This may or may not be important - * to you; if not, remove the wait for "ready"! - * - * @function skipButtons - * @param {Object} [options={}] - * An object of options left to the plugin author to define. - */ -class SkipButtonPlugin extends VideoJS.getPlugin("plugin") { - onNext?: () => void | undefined; - onPrevious?: () => void | undefined; +class SkipButtonPlugin extends videojs.getPlugin("plugin") { + onNext?: () => void; + onPrevious?: () => void; constructor(player: VideoJsPlayer) { super(player); @@ -74,7 +60,7 @@ class SkipButtonPlugin extends VideoJS.getPlugin("plugin") { } } -class SkipButton extends Button { +class SkipButton extends videojs.getComponent("button") { private parentPlugin: SkipButtonPlugin; private direction: "forward" | "back"; @@ -107,12 +93,15 @@ class SkipButton extends Button { } } -VideoJS.registerComponent("SkipButton", SkipButton); -VideoJS.registerPlugin("skipButtons", SkipButtonPlugin); +videojs.registerComponent("SkipButton", SkipButton); +videojs.registerPlugin("skipButtons", SkipButtonPlugin); declare module "video.js" { interface VideoJsPlayer { - skipButtons: () => void | SkipButtonPlugin; + skipButtons: () => SkipButtonPlugin; + } + interface VideoJsPlayerPluginOptions { + skipButtons?: {}; } } diff --git a/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx b/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx index b288471f5..032b41efc 100644 --- a/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx +++ b/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx @@ -1,23 +1,26 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ import React, { - useCallback, + KeyboardEvent, useContext, useEffect, useMemo, useRef, useState, } from "react"; -import VideoJS, { VideoJsPlayer, VideoJsPlayerOptions } from "video.js"; -import "videojs-vtt-thumbnails-freetube"; +import videojs, { VideoJsPlayer, VideoJsPlayerOptions } from "video.js"; +import "videojs-mobile-ui"; import "videojs-seek-buttons"; -import "videojs-landscape-fullscreen"; import "./live"; import "./PlaylistButtons"; import "./source-selector"; import "./persist-volume"; import "./markers"; -import "./big-buttons"; +import "./vtt-thumbnails"; +import "./track-activity"; import cx from "classnames"; +import { + useSceneSaveActivity, + useSceneIncrementPlayCount, +} from "src/core/StashService"; import * as GQL from "src/core/generated-graphql"; import { ScenePlayerScrubber } from "./ScenePlayerScrubber"; @@ -29,8 +32,21 @@ import { import { SceneInteractiveStatus } from "src/hooks/Interactive/status"; import { languageMap } from "src/utils/caption"; import { VIDEO_PLAYER_ID } from "./util"; +import { IUIConfig } from "src/core/config"; + +function handleHotkeys(player: VideoJsPlayer, event: videojs.KeyboardEvent) { + function seekStep(step: number) { + const time = player.currentTime() + step; + const duration = player.duration(); + if (time < 0) { + player.currentTime(0); + } else if (time < duration) { + player.currentTime(time); + } else { + player.currentTime(duration); + } + } -function handleHotkeys(player: VideoJsPlayer, event: VideoJS.KeyboardEvent) { function seekPercent(percent: number) { const duration = player.duration(); const time = duration * percent; @@ -45,6 +61,21 @@ function handleHotkeys(player: VideoJsPlayer, event: VideoJS.KeyboardEvent) { player.currentTime(time); } + let seekFactor = 10; + if (event.shiftKey) { + seekFactor = 5; + } else if (event.ctrlKey || event.altKey) { + seekFactor = 60; + } + switch (event.which) { + case 39: // right arrow + seekStep(seekFactor); + break; + case 37: // left arrow + seekStep(-seekFactor); + break; + } + if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) { return; } @@ -62,12 +93,6 @@ function handleHotkeys(player: VideoJsPlayer, event: VideoJS.KeyboardEvent) { if (player.isFullscreen()) player.exitFullscreen(); else player.requestFullscreen(); break; - case 39: // right arrow - player.currentTime(Math.min(player.duration(), player.currentTime() + 5)); - break; - case 37: // left arrow - player.currentTime(Math.max(0, player.currentTime() - 5)); - break; case 38: // up arrow player.volume(player.volume() + 0.1); break; @@ -113,35 +138,61 @@ function handleHotkeys(player: VideoJsPlayer, event: VideoJS.KeyboardEvent) { } } +type MarkerFragment = Pick & { + primary_tag: Pick; + tags: Array>; +}; + +function getMarkerTitle(marker: MarkerFragment) { + if (marker.title) { + return marker.title; + } + + let ret = marker.primary_tag.name; + if (marker.tags.length) { + ret += `, ${marker.tags.map((t) => t.name).join(", ")}`; + } + + return ret; +} + interface IScenePlayerProps { className?: string; scene: GQL.SceneDataFragment | undefined | null; - timestamp: number; + hideScrubberOverride: boolean; autoplay?: boolean; permitLoop?: boolean; - onComplete?: () => void; - onNext?: () => void; - onPrevious?: () => void; + initialTimestamp: number; + sendSetTimestamp: (setTimestamp: (value: number) => void) => void; + onComplete: () => void; + onNext: () => void; + onPrevious: () => void; } export const ScenePlayer: React.FC = ({ className, - autoplay, scene, - timestamp, + hideScrubberOverride, + autoplay, permitLoop = true, + initialTimestamp: _initialTimestamp, + sendSetTimestamp, onComplete, onNext, onPrevious, }) => { const { configuration } = useContext(ConfigurationContext); - const config = configuration?.interface; + const interfaceConfig = configuration?.interface; + const uiConfig = configuration?.ui as IUIConfig | undefined; const videoRef = useRef(null); - const playerRef = useRef(); - const sceneId = useRef(); - const skipButtonsRef = useRef(); + const playerRef = useRef(); + const sceneId = useRef(); + const [sceneSaveActivity] = useSceneSaveActivity(); + const [sceneIncrementPlayCount] = useSceneIncrementPlayCount(); const [time, setTime] = useState(0); + const [ready, setReady] = useState(false); + const [sessionInitialised, setSessionInitialised] = useState(false); // tracks play session. This is reset whenever ScenePlayer page is exited const { interactive: interactiveClient, @@ -151,22 +202,26 @@ export const ScenePlayer: React.FC = ({ state: interactiveState, } = React.useContext(InteractiveContext); - const [initialTimestamp] = useState(timestamp); - const [ready, setReady] = useState(false); + const [fullscreen, setFullscreen] = useState(false); + const [showScrubber, setShowScrubber] = useState(false); + + const initialTimestamp = useRef(-1); const started = useRef(false); + const auto = useRef(false); const interactiveReady = useRef(false); + const minimumPlayPercent = uiConfig?.minimumPlayPercent ?? 0; + const trackActivity = uiConfig?.trackActivity ?? false; + const file = useMemo( () => ((scene?.files.length ?? 0) > 0 ? scene?.files[0] : undefined), [scene] ); - const maxLoopDuration = config?.maximumLoopDuration ?? 0; - + const maxLoopDuration = interfaceConfig?.maximumLoopDuration ?? 0; const looping = useMemo( () => - !!file && - !!file.duration && + !!file?.duration && permitLoop && maxLoopDuration !== 0 && file.duration < maxLoopDuration, @@ -174,26 +229,35 @@ export const ScenePlayer: React.FC = ({ ); useEffect(() => { - if (playerRef.current && timestamp >= 0) { - const player = playerRef.current; - player.play()?.then(() => { - player.currentTime(timestamp); - }); + if (hideScrubberOverride || fullscreen) { + setShowScrubber(false); + return; } - }, [timestamp]); + + const onResize = () => { + const show = window.innerHeight >= 450 && window.innerWidth >= 576; + setShowScrubber(show); + }; + onResize(); + + window.addEventListener("resize", onResize); + + return () => window.removeEventListener("resize", onResize); + }, [hideScrubberOverride, fullscreen]); useEffect(() => { - if (playerRef.current) { + sendSetTimestamp((value: number) => { const player = playerRef.current; - player.loop(looping); - interactiveClient.setLooping(looping); - } - }, [looping, interactiveClient]); + if (player && value >= 0) { + player.play()?.then(() => { + player.currentTime(value); + }); + } + }); + }, [sendSetTimestamp]); + // Initialize VideoJS player useEffect(() => { - const videoElement = videoRef.current; - if (!videoElement) return; - const options: VideoJsPlayerOptions = { controls: true, controlBar: { @@ -208,15 +272,29 @@ export const ScenePlayer: React.FC = ({ inactivityTimeout: 2000, preload: "none", userActions: { - hotkeys: function (event) { - const player = this as VideoJsPlayer; - handleHotkeys(player, event); + hotkeys: function (this: VideoJsPlayer, event) { + handleHotkeys(this, event); }, }, + plugins: { + vttThumbnails: { + showTimestamp: true, + }, + markers: {}, + sourceSelector: {}, + persistVolume: {}, + seekButtons: { + forward: 10, + back: 10, + }, + skipButtons: {}, + trackActivity: {}, + }, }; - const player = VideoJS(videoElement, options); + const player = videojs(videoRef.current!, options); + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ const settings = (player as any).textTrackSettings; settings.setValues({ backgroundColor: "#000", @@ -224,16 +302,24 @@ export const ScenePlayer: React.FC = ({ }); settings.updateDisplay(); - (player as any).markers(); - (player as any).offset(); - (player as any).sourceSelector(); - (player as any).persistVolume(); - (player as any).bigButtons(); - player.focus(); playerRef.current = player; + + // Video player destructor + return () => { + playerRef.current = undefined; + player.dispose(); + }; }, []); + useEffect(() => { + const player = playerRef.current; + if (!player) return; + const skipButtons = player.skipButtons(); + skipButtons.setForwardHandler(onNext); + skipButtons.setBackwardHandler(onPrevious); + }, [onNext, onPrevious]); + useEffect(() => { if (scene?.interactive && interactiveInitialised) { interactiveReady.current = false; @@ -248,156 +334,149 @@ export const ScenePlayer: React.FC = ({ scene?.paths.funscript, ]); + // Player event handlers useEffect(() => { - if (skipButtonsRef.current) { - skipButtonsRef.current.setForwardHandler(onNext); - skipButtonsRef.current.setBackwardHandler(onPrevious); - } - }, [onNext, onPrevious]); - - useEffect(() => { - const player = playerRef.current; - if (player) { - player.seekButtons({ - forward: 10, - back: 10, - }); - - skipButtonsRef.current = player.skipButtons() ?? undefined; - - player.focus(); - } - - // Video player destructor - return () => { - if (playerRef.current) { - playerRef.current.dispose(); - playerRef.current = undefined; + function canplay(this: VideoJsPlayer) { + if (initialTimestamp.current !== -1) { + this.currentTime(initialTimestamp.current); + initialTimestamp.current = -1; } + } + + function playing(this: VideoJsPlayer) { + // This still runs even if autoplay failed on Safari, + // only set flag if actually playing + if (!started.current && !this.paused()) { + started.current = true; + } + } + + function loadstart(this: VideoJsPlayer) { + setReady(true); + } + + function fullscreenchange(this: VideoJsPlayer) { + setFullscreen(this.isFullscreen()); + } + + const player = playerRef.current; + if (!player) return; + + player.on("canplay", canplay); + player.on("playing", playing); + player.on("loadstart", loadstart); + player.on("fullscreenchange", fullscreenchange); + + return () => { + player.off("canplay", canplay); + player.off("playing", playing); + player.off("loadstart", loadstart); + player.off("fullscreenchange", fullscreenchange); }; }, []); - const start = useCallback(() => { - const player = playerRef.current; - if (player && scene) { - started.current = true; - - player - .play() - ?.then(() => { - if (initialTimestamp > 0) { - player.currentTime(initialTimestamp); - } - }) - .catch(() => { - if (scene.paths.screenshot) player.poster(scene.paths.screenshot); - }); + useEffect(() => { + function onplay(this: VideoJsPlayer) { + this.persistVolume().enabled = true; + if (scene?.interactive && interactiveReady.current) { + interactiveClient.play(this.currentTime()); + } } - }, [scene, initialTimestamp]); + + function pause(this: VideoJsPlayer) { + interactiveClient.pause(); + } + + function seeking(this: VideoJsPlayer) { + if (this.paused()) return; + if (scene?.interactive && interactiveReady.current) { + interactiveClient.play(this.currentTime()); + } + } + + function timeupdate(this: VideoJsPlayer) { + if (this.paused()) return; + if (scene?.interactive && interactiveReady.current) { + interactiveClient.ensurePlaying(this.currentTime()); + } + setTime(this.currentTime()); + } + + const player = playerRef.current; + if (!player) return; + + player.on("play", onplay); + player.on("pause", pause); + player.on("seeking", seeking); + player.on("timeupdate", timeupdate); + + return () => { + player.off("play", onplay); + player.off("pause", pause); + player.off("seeking", seeking); + player.off("timeupdate", timeupdate); + }; + }, [interactiveClient, scene]); useEffect(() => { - let prevCaptionOffset = 0; + const player = playerRef.current; + if (!player) return; - function addCaptionOffset(player: VideoJsPlayer, offset: number) { - const tracks = player.remoteTextTracks(); - for (let i = 0; i < tracks.length; i++) { - const track = tracks[i]; - const { cues } = track; - if (cues) { - for (let j = 0; j < cues.length; j++) { - const cue = cues[j]; - cue.startTime = cue.startTime + offset; - cue.endTime = cue.endTime + offset; - } - } + // don't re-initialise the player unless the scene has changed + if (!scene || !file || scene.id === sceneId.current) return; + + // if new scene was picked from playlist + if (playerRef.current && sceneId.current) { + if (trackActivity) { + playerRef.current.trackActivity().reset(); } } - function removeCaptionOffset(player: VideoJsPlayer, offset: number) { - const tracks = player.remoteTextTracks(); - for (let i = 0; i < tracks.length; i++) { - const track = tracks[i]; - const { cues } = track; - if (cues) { - for (let j = 0; j < cues.length; j++) { - const cue = cues[j]; - cue.startTime = cue.startTime + prevCaptionOffset - offset; - cue.endTime = cue.endTime + prevCaptionOffset - offset; - } - } - } - } + sceneId.current = scene.id; - function handleOffset(player: VideoJsPlayer) { - if (!scene || !file) return; + setReady(false); - const currentSrc = new URL(player.currentSrc()); + // always stop the interactive client on initialisation + interactiveClient.pause(); + interactiveReady.current = false; - const isDirect = - currentSrc.pathname.endsWith("/stream") || - currentSrc.pathname.endsWith("/stream.m3u8"); + const isLandscape = file.height && file.width && file.width > file.height; + const mobileUiOptions = { + fullscreen: { + enterOnRotate: true, + exitOnRotate: true, + lockOnRotate: true, + lockToLandscapeOnEnter: isLandscape, + }, + touchControls: { + seekSeconds: 10, + tapTimeout: 500, + disableOnEnd: false, + }, + }; + player.mobileUi(mobileUiOptions); - const curTime = player.currentTime(); - if (!isDirect) { - (player as any).setOffsetDuration(file.duration); - } else { - (player as any).clearOffsetDuration(); - } + const { duration } = file; + const sourceSelector = player.sourceSelector(); + sourceSelector.setSources( + scene.sceneStreams.map((stream) => { + const src = new URL(stream.url); + const isDirect = + src.pathname.endsWith("/stream") || + src.pathname.endsWith("/stream.m3u8"); - if (curTime != prevCaptionOffset) { - if (!isDirect) { - removeCaptionOffset(player, curTime); - prevCaptionOffset = curTime; - } else { - if (prevCaptionOffset != 0) { - addCaptionOffset(player, prevCaptionOffset); - prevCaptionOffset = 0; - } - } - } - } - - function handleError(play: boolean) { - const player = playerRef.current; - if (!player) return; - - const currentFile = player.currentSource(); - if (currentFile) { - // eslint-disable-next-line no-console - console.log(`Source failed: ${currentFile.src}`); - player.focus(); - } - - if (tryNextStream()) { - // eslint-disable-next-line no-console - console.log(`Trying next source in playlist: ${player.currentSrc()}`); - player.load(); - if (play) { - player.play(); - } - } else { - // eslint-disable-next-line no-console - console.log("No more sources in playlist."); - } - } - - function tryNextStream() { - const player = playerRef.current; - if (!player) return; - - const sources = player.currentSources(); - - if (sources.length > 1) { - sources.shift(); - player.src(sources); - return true; - } - - return false; - } + return { + src: stream.url, + type: stream.mime_type ?? undefined, + label: stream.label ?? undefined, + offset: !isDirect, + duration, + }; + }) + ); function getDefaultLanguageCode() { - var languageCode = window.navigator.language; + let languageCode = window.navigator.language; if (languageCode.indexOf("-") !== -1) { languageCode = languageCode.split("-")[0]; @@ -410,270 +489,226 @@ export const ScenePlayer: React.FC = ({ return languageCode; } - function loadCaptions(player: VideoJsPlayer) { - if (!scene) return; + if (scene.captions && scene.captions.length > 0) { + const languageCode = getDefaultLanguageCode(); + let hasDefault = false; - if (scene.captions) { - var languageCode = getDefaultLanguageCode(); - var hasDefault = false; - - for (let caption of scene.captions) { - var lang = caption.language_code; - var label = lang; - if (languageMap.has(lang)) { - label = languageMap.get(lang)!; - } - - label = label + " (" + caption.caption_type + ")"; - var setAsDefault = !hasDefault && languageCode == lang; - if (!hasDefault && setAsDefault) { - hasDefault = true; - } - player.addRemoteTextTrack( - { - src: - scene.paths.caption + - "?lang=" + - lang + - "&type=" + - caption.caption_type, - kind: "captions", - srclang: lang, - label: label, - default: setAsDefault, - }, - true - ); + for (let caption of scene.captions) { + const lang = caption.language_code; + let label = lang; + if (languageMap.has(lang)) { + label = languageMap.get(lang)!; } + + label = label + " (" + caption.caption_type + ")"; + const setAsDefault = !hasDefault && languageCode == lang; + if (setAsDefault) { + hasDefault = true; + } + sourceSelector.addTextTrack( + { + src: `${scene.paths.caption}?lang=${lang}&type=${caption.caption_type}`, + kind: "captions", + srclang: lang, + label: label, + default: setAsDefault, + }, + false + ); } } - function loadstart(this: VideoJsPlayer) { - // handle offset after loading so that we get the correct current source - handleOffset(this); + auto.current = + autoplay || + (interfaceConfig?.autostartVideo ?? false) || + _initialTimestamp > 0; + + const alwaysStartFromBeginning = + uiConfig?.alwaysStartFromBeginning ?? false; + + let startPosition = _initialTimestamp; + if ( + !startPosition && + !(alwaysStartFromBeginning || sessionInitialised) && + file.duration > scene.resume_time! + ) { + startPosition = scene.resume_time!; } - function onPlay(this: VideoJsPlayer) { - this.poster(""); - if (scene?.interactive && interactiveReady.current) { - interactiveClient.play(this.currentTime()); - } - } + initialTimestamp.current = startPosition; + setTime(startPosition); + setSessionInitialised(true); - function pause() { + player.load(); + player.focus(); + + player.ready(() => { + player.vttThumbnails().src(scene.paths.vtt ?? null); + }); + + started.current = false; + + return () => { + // stop the interactive client interactiveClient.pause(); + }; + }, [ + file, + scene, + trackActivity, + interactiveClient, + sessionInitialised, + autoplay, + interfaceConfig?.autostartVideo, + uiConfig?.alwaysStartFromBeginning, + _initialTimestamp, + ]); + + useEffect(() => { + const player = playerRef.current; + if (!player || !scene) return; + + const markers = player.markers(); + markers.clearMarkers(); + for (const marker of scene.scene_markers) { + markers.addMarker({ + title: getMarkerTitle(marker), + time: marker.seconds, + }); } - function timeupdate(this: VideoJsPlayer) { - if (scene?.interactive && interactiveReady.current) { - interactiveClient.ensurePlaying(this.currentTime()); - } - setTime(this.currentTime()); + if (scene.paths.screenshot) { + player.poster(scene.paths.screenshot); + } else { + player.poster(""); + } + }, [scene]); + + useEffect(() => { + const player = playerRef.current; + if (!player) return; + + async function saveActivity(resumeTime: number, playDuration: number) { + if (!scene?.id) return; + + await sceneSaveActivity({ + variables: { + id: scene.id, + playDuration, + resume_time: resumeTime, + }, + }); } - function seeking(this: VideoJsPlayer) { - this.play(); + async function incrementPlayCount() { + if (!scene?.id) return; + + await sceneIncrementPlayCount({ + variables: { + id: scene.id, + }, + }); } - function error() { - handleError(true); + const activity = player.trackActivity(); + activity.saveActivity = saveActivity; + activity.incrementPlayCount = incrementPlayCount; + activity.minimumPlayPercent = minimumPlayPercent; + activity.setEnabled(trackActivity); + }, [ + scene, + trackActivity, + minimumPlayPercent, + sceneIncrementPlayCount, + sceneSaveActivity, + ]); + + useEffect(() => { + const player = playerRef.current; + if (!player) return; + + player.loop(looping); + interactiveClient.setLooping(looping); + }, [interactiveClient, looping]); + + useEffect(() => { + if (!scene || !ready || !auto.current) { + return; } - // changing source (eg when seeking) resets the playback rate - // so set the default in addition to the current rate - function ratechange(this: VideoJsPlayer) { - this.defaultPlaybackRate(this.playbackRate()); - } - - function loadedmetadata(this: VideoJsPlayer) { - if (!this.videoWidth() && !this.videoHeight()) { - // Occurs during preload when videos with supported audio/unsupported video are preloaded. - // Treat this as a decoding error and try the next source without playing. - // However on Safari we get an media event when m3u8 is loaded which needs to be ignored. - const currentFile = this.currentSrc(); - if (currentFile != null && !currentFile.includes("m3u8")) { - // const play = !player.paused(); - // handleError(play); - this.error(MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED); - } - } + // check if we're waiting for the interactive client + if ( + scene.interactive && + interactiveClient.handyKey && + currentScript !== scene.paths.funscript + ) { + return; } const player = playerRef.current; if (!player) return; - // always initialise event handlers since these are destroyed when the - // component is destroyed - player.on("loadstart", loadstart); - player.on("play", onPlay); - player.on("pause", pause); - player.on("timeupdate", timeupdate); - player.on("seeking", seeking); - player.on("error", error); - player.on("ratechange", ratechange); - player.on("loadedmetadata", loadedmetadata); + player.play()?.catch(() => { + // Browser probably blocking non-muted autoplay, so mute and try again + player.persistVolume().enabled = false; + player.muted(true); - // don't re-initialise the player unless the scene has changed - if (!scene || !file || scene.id === sceneId.current) return; - sceneId.current = scene.id; - - // always stop the interactive client on initialisation - interactiveClient.pause(); - interactiveReady.current = false; - - const auto = - autoplay || (config?.autostartVideo ?? false) || initialTimestamp > 0; - if (!auto && scene.paths?.screenshot) player.poster(scene.paths.screenshot); - else player.poster(""); - - const isLandscape = file.height && file.width && file.width > file.height; - - if (isLandscape) { - (player as any).landscapeFullscreen({ - fullscreen: { - enterOnRotate: true, - exitOnRotate: true, - alwaysInLandscapeMode: true, - iOS: false, - }, - }); - } - - // clear the offset before loading anything new. - // otherwise, the offset will be applied to the next file when - // currentTime is called. - (player as any).clearOffsetDuration(); - - const tracks = player.remoteTextTracks(); - for (let i = 0; i < tracks.length; i++) { - player.removeRemoteTextTrack(tracks[i] as any); - } - - player.src( - scene.sceneStreams.map((stream) => ({ - src: stream.url, - type: stream.mime_type ?? undefined, - label: stream.label ?? undefined, - })) - ); - - if (scene.paths.chapters_vtt) { - player.addRemoteTextTrack( - { - src: scene.paths.chapters_vtt, - kind: "chapters", - default: true, - }, - true - ); - } - - if (scene.captions?.length! > 0) { - loadCaptions(player); - } - - player.currentTime(0); - - player.loop(looping); - interactiveClient.setLooping(looping); - - player.load(); - player.focus(); - - if ((player as any).vttThumbnails?.src) - (player as any).vttThumbnails?.src(scene?.paths.vtt); - else - (player as any).vttThumbnails({ - src: scene?.paths.vtt, - showTimestamp: true, - }); - - setReady(true); - started.current = false; - - return () => { - setReady(false); - - // stop the interactive client - interactiveClient.pause(); - - player.off("loadstart", loadstart); - player.off("play", onPlay); - player.off("pause", pause); - player.off("timeupdate", timeupdate); - player.off("seeking", seeking); - player.off("error", error); - player.off("ratechange", ratechange); - player.off("loadedmetadata", loadedmetadata); - }; - }, [ - scene, - file, - config?.autostartVideo, - looping, - initialTimestamp, - autoplay, - interactiveClient, - start, - ]); - - useEffect(() => { - if (!ready || started.current) { - return; - } - - const auto = - autoplay || (config?.autostartVideo ?? false) || initialTimestamp > 0; - - // check if we're waiting for the interactive client - const interactiveWaiting = - scene?.interactive && - interactiveClient.handyKey && - currentScript !== scene.paths.funscript; - - if (scene && auto && !interactiveWaiting) { - start(); - } - }, [ - config?.autostartVideo, - initialTimestamp, - scene, - ready, - interactiveClient, - currentScript, - autoplay, - start, - ]); + player.play(); + }); + auto.current = false; + }, [scene, ready, interactiveClient, currentScript]); useEffect(() => { // Attach handler for onComplete event const player = playerRef.current; if (!player) return; - player.on("ended", () => { - onComplete?.(); - }); + player.on("ended", onComplete); return () => player.off("ended"); }, [onComplete]); - const onScrubberScrolled = () => { - playerRef.current?.pause(); - }; - const onScrubberSeek = (seconds: number) => { - const player = playerRef.current; - if (player) { - player.play()?.then(() => { - player.currentTime(seconds); - }); + const onScrubberScroll = () => { + if (started.current) { + playerRef.current?.pause(); } }; + const onScrubberSeek = (seconds: number) => { + if (started.current) { + playerRef.current?.currentTime(seconds); + } else { + initialTimestamp.current = seconds; + setTime(seconds); + } + }; + + // Override spacebar to always pause/play + function onKeyDown(this: HTMLDivElement, event: KeyboardEvent) { + const player = playerRef.current; + if (!player) return; + + if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) { + return; + } + if (event.key == " ") { + event.preventDefault(); + event.stopPropagation(); + if (player.paused()) { + player.play(); + } else { + player.pause(); + } + } + } const isPortrait = scene && file && file.height && file.width && file.height > file.width; return ( -
    +
    diff --git a/ui/v2.5/src/components/ScenePlayer/ScenePlayerScrubber.tsx b/ui/v2.5/src/components/ScenePlayer/ScenePlayerScrubber.tsx index 7f81c3b9b..07a5417a8 100644 --- a/ui/v2.5/src/components/ScenePlayer/ScenePlayerScrubber.tsx +++ b/ui/v2.5/src/components/ScenePlayer/ScenePlayerScrubber.tsx @@ -1,5 +1,3 @@ -/* eslint-disable react/no-array-index-key */ - import React, { CSSProperties, useEffect, @@ -11,16 +9,23 @@ import { Button } from "react-bootstrap"; import axios from "axios"; import * as GQL from "src/core/generated-graphql"; import { TextUtils } from "src/utils"; +import { WebVTT } from "videojs-vtt.js"; interface IScenePlayerScrubberProps { file: GQL.VideoFileDataFragment; scene: GQL.SceneDataFragment; - position: number; + time: number; onSeek: (seconds: number) => void; - onScrolled: () => void; + onScroll: () => void; } interface ISceneSpriteItem { + style: CSSProperties; + time: string; +} + +interface ISceneSpriteInfo { + url: string; start: number; end: number; x: number; @@ -32,284 +37,284 @@ interface ISceneSpriteItem { async function fetchSpriteInfo(vttPath: string) { const response = await axios.get(vttPath, { responseType: "text" }); - // TODO: This is gnarly - const lines = response.data.split("\n"); - if (lines.shift() !== "WEBVTT") { - return; - } - if (lines.shift() !== "") { - return; - } - let item: ISceneSpriteItem = { start: 0, end: 0, x: 0, y: 0, w: 0, h: 0 }; - const newSpriteItems: ISceneSpriteItem[] = []; - while (lines.length) { - const line = lines.shift(); - if (line !== undefined) { - if (line.includes("#") && line.includes("=") && line.includes(",")) { - const size = line.split("#")[1].split("=")[1].split(","); - item.x = Number(size[0]); - item.y = Number(size[1]); - item.w = Number(size[2]); - item.h = Number(size[3]); + const sprites: ISceneSpriteInfo[] = []; - newSpriteItems.push(item); - item = { start: 0, end: 0, x: 0, y: 0, w: 0, h: 0 }; - } else if (line.includes(" --> ")) { - const times = line.split(" --> "); + const parser = new WebVTT.Parser(window, WebVTT.StringDecoder()); + parser.oncue = (cue: VTTCue) => { + const match = cue.text.match(/^([^#]*)#xywh=(\d+),(\d+),(\d+),(\d+)$/i); + if (!match) return; - const start = times[0].split(":"); - item.start = +start[0] * 60 * 60 + +start[1] * 60 + +start[2]; + sprites.push({ + url: new URL(match[1], vttPath).href, + start: cue.startTime, + end: cue.endTime, + x: Number(match[2]), + y: Number(match[3]), + w: Number(match[4]), + h: Number(match[5]), + }); + }; + parser.parse(response.data); + parser.flush(); - const end = times[1].split(":"); - item.end = +end[0] * 60 * 60 + +end[1] * 60 + +end[2]; - } - } - } - - return newSpriteItems; + return sprites; } -export const ScenePlayerScrubber: React.FC = ( - props: IScenePlayerScrubberProps -) => { +export const ScenePlayerScrubber: React.FC = ({ + file, + scene, + time, + onSeek, + onScroll, +}) => { const contentEl = useRef(null); - const positionIndicatorEl = useRef(null); - const scrubberSliderEl = useRef(null); + const indicatorEl = useRef(null); + const sliderEl = useRef(null); const mouseDown = useRef(false); const lastMouseEvent = useRef(null); const startMouseEvent = useRef(null); const velocity = useRef(0); - const _position = useRef(0); - const getPosition = useCallback(() => _position.current, []); + const prevTime = useRef(NaN); + const _width = useRef(0); + const [width, setWidth] = useState(0); + const [scrubWidth, setScrubWidth] = useState(0); + const position = useRef(0); const setPosition = useCallback( - (newPostion: number, shouldEmit: boolean = true) => { - if (!scrubberSliderEl.current || !positionIndicatorEl.current) { - return; - } - if (shouldEmit) { - props.onScrolled(); - } + (value: number, seek: boolean) => { + if (!scrubWidth) return; - const midpointOffset = scrubberSliderEl.current.clientWidth / 2; + const slider = sliderEl.current!; + const indicator = indicatorEl.current!; - const bounds = getBounds() * -1; - if (newPostion > midpointOffset) { - _position.current = midpointOffset; - } else if (newPostion < bounds - midpointOffset) { - _position.current = bounds - midpointOffset; + const midpointOffset = slider.clientWidth / 2; + + let newPosition: number; + let percentage: number; + if (value >= midpointOffset) { + percentage = 0; + newPosition = midpointOffset; + } else if (value <= midpointOffset - scrubWidth) { + percentage = 1; + newPosition = midpointOffset - scrubWidth; } else { - _position.current = newPostion; + percentage = (midpointOffset - value) / scrubWidth; + newPosition = value; } - scrubberSliderEl.current.style.transform = `translateX(${_position.current}px)`; + slider.style.transform = `translateX(${newPosition}px)`; + indicator.style.transform = `translateX(${percentage * 100}%)`; - const indicatorPosition = - ((newPostion - midpointOffset) / (bounds - midpointOffset * 2)) * - scrubberSliderEl.current.clientWidth; - positionIndicatorEl.current.style.transform = `translateX(${indicatorPosition}px)`; + position.current = newPosition; + + if (seek) { + onSeek(percentage * (file.duration || 0)); + } }, - [props] + [onSeek, file.duration, scrubWidth] ); - const [spriteItems, setSpriteItems] = useState([]); + const [spriteItems, setSpriteItems] = useState(); useEffect(() => { - if (!scrubberSliderEl.current) { - return; - } - scrubberSliderEl.current.style.transform = `translateX(${ - scrubberSliderEl.current.clientWidth / 2 - }px)`; - }, [scrubberSliderEl]); - - useEffect(() => { - if (!props.scene.paths.vtt) return; - fetchSpriteInfo(props.scene.paths.vtt).then((sprites) => { - if (sprites) setSpriteItems(sprites); + if (!scene.paths.vtt) return; + fetchSpriteInfo(scene.paths.vtt).then((sprites) => { + if (!sprites) return; + let totalWidth = 0; + const newSprites = sprites?.map((sprite, index) => { + totalWidth += sprite.w; + const left = sprite.w * index; + const style = { + width: `${sprite.w}px`, + height: `${sprite.h}px`, + backgroundPosition: `${-sprite.x}px ${-sprite.y}px`, + backgroundImage: `url(${sprite.url})`, + left: `${left}px`, + }; + const start = TextUtils.secondsToTimestamp(sprite.start); + const end = TextUtils.secondsToTimestamp(sprite.end); + return { + style, + time: `${start} - ${end}`, + }; + }); + setScrubWidth(totalWidth); + setSpriteItems(newSprites); }); - }, [props.scene]); + }, [scene]); useEffect(() => { - if (!scrubberSliderEl.current) { - return; - } - const duration = Number(props.file.duration); - const percentage = props.position / duration; - const position = - (scrubberSliderEl.current.scrollWidth * percentage - - scrubberSliderEl.current.clientWidth / 2) * - -1; - setPosition(position, false); - }, [props.position, props.file.duration, setPosition]); - - useEffect(() => { - window.addEventListener("mouseup", onMouseUp, false); - return () => { - window.removeEventListener("mouseup", onMouseUp); + const onResize = (entries: ResizeObserverEntry[]) => { + const newWidth = entries[0].target.clientWidth; + if (_width.current != newWidth) { + // set prevTime to NaN to not use a transition when updating the slider position + prevTime.current = NaN; + _width.current = newWidth; + setWidth(newWidth); + } }; - }); - useEffect(() => { - if (!contentEl.current) { - return; - } - const el = contentEl.current; - el.addEventListener("mousedown", onMouseDown, false); + const content = contentEl.current!; + const resizeObserver = new ResizeObserver(onResize); + resizeObserver.observe(content); + return () => { - if (!el) { - return; - } - el.removeEventListener("mousedown", onMouseDown); + resizeObserver.unobserve(content); }; - }); + }, []); - useEffect(() => { - if (!contentEl.current) { - return; - } - const el = contentEl.current; - el.addEventListener("mousemove", onMouseMove, false); - return () => { - if (!el) { - return; - } - el.removeEventListener("mousemove", onMouseMove); - }; - }); - - function onMouseUp(this: Window, event: MouseEvent) { - if (!startMouseEvent.current || !scrubberSliderEl.current) { - return; - } - mouseDown.current = false; - const delta = Math.abs(event.clientX - startMouseEvent.current.clientX); - if (delta < 1 && event.target instanceof HTMLDivElement) { - const { target } = event; - let seekSeconds: number | undefined; - - const spriteIdString = target.getAttribute("data-sprite-item-id"); - if (spriteIdString != null) { - const spritePercentage = event.offsetX / target.clientWidth; - const offset = - target.offsetLeft + target.clientWidth * spritePercentage; - const percentage = offset / scrubberSliderEl.current.scrollWidth; - seekSeconds = percentage * (props.file.duration || 0); - } - - const markerIdString = target.getAttribute("data-marker-id"); - if (markerIdString != null) { - const marker = props.scene.scene_markers[Number(markerIdString)]; - seekSeconds = marker.seconds; - } - - if (seekSeconds) { - props.onSeek(seekSeconds); - } - } else if (Math.abs(velocity.current) > 25) { - const newPosition = getPosition() + velocity.current * 10; - setPosition(newPosition); - velocity.current = 0; - } + function setLinearTransition() { + const slider = sliderEl.current!; + slider.style.transition = "500ms linear"; } - function onMouseDown(this: HTMLDivElement, event: MouseEvent) { + function setEaseOutTransition() { + const slider = sliderEl.current!; + slider.style.transition = "333ms ease-out"; + } + + function clearTransition() { + const slider = sliderEl.current!; + slider.style.transition = ""; + } + + // Update slider position when player time changes + useEffect(() => { + if (!scrubWidth || !width) return; + + const duration = Number(file.duration); + const percentage = time / duration; + const newPosition = width / 2 - percentage * scrubWidth; + + // Ignore position changes of < 1px + if (Math.abs(newPosition - position.current) < 1) return; + + const delta = Math.abs(time - prevTime.current); + if (isNaN(delta)) { + // Don't use a transition on initial time change or after resize + clearTransition(); + } else if (delta <= 1) { + // If time changed by < 1s, use linear transition instead of ease-out + setLinearTransition(); + } else { + setEaseOutTransition(); + } + prevTime.current = time; + + setPosition(newPosition, false); + }, [file.duration, setPosition, time, width, scrubWidth]); + + const onMouseUp = useCallback( + (event: MouseEvent) => { + if (!mouseDown.current) return; + const slider = sliderEl.current!; + + mouseDown.current = false; + + contentEl.current!.classList.remove("dragging"); + + let newPosition = position.current; + const midpointOffset = slider.clientWidth / 2; + const delta = Math.abs(event.clientX - startMouseEvent.current!.clientX); + if (delta < 1 && event.target instanceof HTMLDivElement) { + const { target } = event; + + if (target.hasAttribute("data-sprite-item-id")) { + newPosition = midpointOffset - (target.offsetLeft + event.offsetX); + } + + if (target.hasAttribute("data-marker-id")) { + newPosition = midpointOffset - target.offsetLeft; + } + } + if (Math.abs(velocity.current) > 25) { + newPosition = position.current + velocity.current * 10; + velocity.current = 0; + } + + setEaseOutTransition(); + setPosition(newPosition, true); + }, + [setPosition] + ); + + const onMouseDown = useCallback((event: MouseEvent) => { + // Only if left mouse button pressed + if (event.button !== 0) return; + event.preventDefault(); + mouseDown.current = true; lastMouseEvent.current = event; startMouseEvent.current = event; velocity.current = 0; - } + }, []); - function onMouseMove(this: HTMLDivElement, event: MouseEvent) { - if (!mouseDown.current) { - return; - } + const onMouseMove = useCallback( + (event: MouseEvent) => { + if (!mouseDown.current) return; - // negative dragging right (past), positive left (future) - const delta = event.clientX - (lastMouseEvent.current?.clientX ?? 0); + if (lastMouseEvent.current === startMouseEvent.current) { + onScroll(); + } - const movement = event.movementX; - velocity.current = movement; + contentEl.current!.classList.add("dragging"); - const newPostion = getPosition() + delta; - setPosition(newPostion); - lastMouseEvent.current = event; - } + // negative dragging right (past), positive left (future) + const delta = event.clientX - lastMouseEvent.current!.clientX; - function getBounds(): number { - if (!scrubberSliderEl.current || !positionIndicatorEl.current) { - return 0; - } - return ( - scrubberSliderEl.current.scrollWidth - - scrubberSliderEl.current.clientWidth - ); - } + const movement = event.movementX; + velocity.current = movement; + + clearTransition(); + setPosition(position.current + delta, false); + lastMouseEvent.current = event; + }, + [onScroll, setPosition] + ); + + useEffect(() => { + const content = contentEl.current!; + + content.addEventListener("mousedown", onMouseDown, false); + content.addEventListener("mousemove", onMouseMove, false); + window.addEventListener("mouseup", onMouseUp, false); + + return () => { + content.removeEventListener("mousedown", onMouseDown); + content.removeEventListener("mousemove", onMouseMove); + window.removeEventListener("mouseup", onMouseUp); + }; + }, [onMouseDown, onMouseMove, onMouseUp]); function goBack() { - if (!scrubberSliderEl.current) { - return; - } - const newPosition = getPosition() + scrubberSliderEl.current.clientWidth; - setPosition(newPosition); + const slider = sliderEl.current!; + const newPosition = position.current + slider.clientWidth; + setEaseOutTransition(); + setPosition(newPosition, true); } function goForward() { - if (!scrubberSliderEl.current) { - return; - } - const newPosition = getPosition() - scrubberSliderEl.current.clientWidth; - setPosition(newPosition); + const slider = sliderEl.current!; + const newPosition = position.current - slider.clientWidth; + setEaseOutTransition(); + setPosition(newPosition, true); } function renderTags() { - function getTagStyle(i: number): CSSProperties { - if ( - !scrubberSliderEl.current || - spriteItems.length === 0 || - getBounds() === 0 - ) { - return {}; - } + if (!spriteItems) return; - const tags = window.document.getElementsByClassName("scrubber-tag"); - if (tags.length === 0) { - return {}; - } + return scene.scene_markers.map((marker, index) => { + const { duration } = file; + const left = (scrubWidth * marker.seconds) / duration; + const style = { left: `${left}px` }; - let tag: Element | null; - for (let index = 0; index < tags.length; index++) { - tag = tags.item(index); - const id = tag?.getAttribute("data-marker-id") ?? null; - if (id === i.toString()) { - break; - } - } - - const marker = props.scene.scene_markers[i]; - const duration = Number(props.file.duration); - const percentage = marker.seconds / duration; - - const left = - scrubberSliderEl.current.scrollWidth * percentage - - tag!.clientWidth / 2; - return { - left: `${left}px`, - height: 20, - }; - } - - return props.scene.scene_markers.map((marker, index) => { - const dataAttrs = { - "data-marker-id": index, - }; return (
    {marker.title || marker.primary_tag.name}
    @@ -318,38 +323,17 @@ export const ScenePlayerScrubber: React.FC = ( } function renderSprites() { - function getStyleForSprite(index: number): CSSProperties { - if (!props.scene.paths.vtt) { - return {}; - } - const sprite = spriteItems[index]; - const left = sprite.w * index; - const path = props.scene.paths.vtt.replace("_thumbs.vtt", "_sprite.jpg"); // TODO: Gnarly - return { - width: `${sprite.w}px`, - height: `${sprite.h}px`, - margin: "0px auto", - backgroundPosition: `${-sprite.x}px ${-sprite.y}px`, - backgroundImage: `url(${path})`, - left: `${left}px`, - }; - } + if (!scene.paths.vtt) return; - return spriteItems.map((spriteItem, index) => { - const dataAttrs = { - "data-sprite-item-id": index, - }; + return spriteItems?.map((sprite, index) => { return (
    - - {TextUtils.secondsToTimestamp(spriteItem.start)} -{" "} - {TextUtils.secondsToTimestamp(spriteItem.end)} - + {sprite.time}
    ); }); @@ -358,7 +342,6 @@ export const ScenePlayerScrubber: React.FC = ( return (
    -
    +
    -
    +
    {renderTags()}
    {renderSprites()}
    diff --git a/ui/v2.5/src/components/ScenePlayer/big-buttons.ts b/ui/v2.5/src/components/ScenePlayer/big-buttons.ts deleted file mode 100644 index ac503afee..000000000 --- a/ui/v2.5/src/components/ScenePlayer/big-buttons.ts +++ /dev/null @@ -1,54 +0,0 @@ -import videojs, { VideoJsPlayer } from "video.js"; - -const BigPlayButton = videojs.getComponent("BigPlayButton"); - -class BigPlayPauseButton extends BigPlayButton { - handleClick(event: videojs.EventTarget.Event) { - if (this.player().paused()) { - // @ts-ignore for some reason handleClick isn't defined in BigPlayButton type. Not sure why - super.handleClick(event); - } else { - this.player().pause(); - } - } - - buildCSSClass() { - return "vjs-control vjs-button vjs-big-play-pause-button"; - } -} - -class BigButtonGroup extends videojs.getComponent("Component") { - /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ - constructor(player: VideoJsPlayer, options: any) { - super(player, options); - - this.addChild("seekButton", { - direction: "back", - seconds: 10, - }); - - this.addChild("BigPlayPauseButton"); - - this.addChild("seekButton", { - direction: "forward", - seconds: 10, - }); - } - - createEl() { - return super.createEl("div", { - className: "vjs-big-button-group", - }); - } -} - -const bigButtons = function (this: VideoJsPlayer) { - this.addChild("BigButtonGroup"); -}; - -// Register the plugin with video.js. -videojs.registerComponent("BigButtonGroup", BigButtonGroup); -videojs.registerComponent("BigPlayPauseButton", BigPlayPauseButton); -videojs.registerPlugin("bigButtons", bigButtons); - -export default bigButtons; diff --git a/ui/v2.5/src/components/ScenePlayer/live.ts b/ui/v2.5/src/components/ScenePlayer/live.ts index aae1e81d7..e55456cb9 100644 --- a/ui/v2.5/src/components/ScenePlayer/live.ts +++ b/ui/v2.5/src/components/ScenePlayer/live.ts @@ -1,83 +1,180 @@ import videojs, { VideoJsPlayer } from "video.js"; -const offset = function (this: VideoJsPlayer) { - /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ - const Player = this.constructor as any; +export interface ISource extends videojs.Tech.SourceObject { + offset?: boolean; + duration?: number; +} - if (!Player.__super__ || !Player.__super__.__offsetInit) { - Player.__super__ = { - __offsetInit: true, - duration: Player.prototype.duration, - currentTime: Player.prototype.currentTime, - remainingTime: Player.prototype.remainingTime, - getCache: Player.prototype.getCache, - }; +interface ICue extends TextTrackCue { + _startTime?: number; + _endTime?: number; +} - Player.prototype.clearOffsetDuration = function () { - this._offsetDuration = undefined; - this._offsetStart = undefined; - }; +function offsetMiddleware(player: VideoJsPlayer) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- allow access to private tech methods + let tech: any; + let source: ISource; + let offsetStart: number | undefined; + let seeking = 0; - Player.prototype.setOffsetDuration = function (duration: number) { - this._offsetDuration = duration; - }; + function initCues(cues: TextTrackCueList) { + const offset = offsetStart ?? 0; + for (let j = 0; j < cues.length; j++) { + const cue = cues[j] as ICue; + cue._startTime = cue.startTime; + cue.startTime = cue._startTime - offset; + cue._endTime = cue.endTime; + cue.endTime = cue._endTime - offset; + } + } - Player.prototype.duration = function () { - if (this._offsetDuration !== undefined) { - return this._offsetDuration; - } - return Player.__super__.duration.apply(this, arguments); - }; + function updateOffsetStart(offset: number | undefined) { + offsetStart = offset; - Player.prototype.currentTime = function (seconds: number) { - if (seconds !== undefined && this._offsetDuration !== undefined) { - this._offsetStart = seconds; + if (!tech) return; + offset = offset ?? 0; - const srcUrl = new URL(this.src()); - srcUrl.searchParams.delete("start"); - srcUrl.searchParams.append("start", seconds.toString()); - const currentSrc = this.currentSource(); - const newSources = this.currentSources().map( - (source: videojs.Tech.SourceObject) => { - return { - ...source, - src: - source.src === currentSrc.src ? srcUrl.toString() : source.src, - }; + const tracks = tech.remoteTextTracks(); + for (let i = 0; i < tracks.length; i++) { + const { cues } = tracks[i]; + if (cues) { + for (let j = 0; j < cues.length; j++) { + const cue = cues[j] as ICue; + if (cue._startTime === undefined || cue._endTime === undefined) { + continue; } - ); - this.src(newSources); - this.play(); + cue.startTime = cue._startTime - offset; + cue.endTime = cue._endTime - offset; + } + } + } + } + return { + setTech(newTech: videojs.Tech) { + tech = newTech; + + const _addRemoteTextTrack = tech.addRemoteTextTrack.bind(tech); + function addRemoteTextTrack( + this: VideoJsPlayer, + options: videojs.TextTrackOptions, + manualCleanup: boolean + ) { + const textTrack = _addRemoteTextTrack(options, manualCleanup); + textTrack.addEventListener("load", () => { + const { cues } = textTrack.track; + if (cues) { + initCues(cues); + } + }); + + return textTrack; + } + tech.addRemoteTextTrack = addRemoteTextTrack; + + const trackEls: HTMLTrackElement[] = tech.remoteTextTrackEls(); + for (let i = 0; i < trackEls.length; i++) { + const trackEl = trackEls[i]; + const { track } = trackEl; + if (track.cues) { + initCues(track.cues); + } else { + trackEl.addEventListener("load", () => { + if (track.cues) { + initCues(track.cues); + } + }); + } + } + }, + setSource( + srcObj: ISource, + next: (err: unknown, src: videojs.Tech.SourceObject) => void + ) { + if (srcObj.offset && srcObj.duration) { + updateOffsetStart(0); + } else { + updateOffsetStart(undefined); + } + source = srcObj; + next(null, srcObj); + }, + duration(seconds: number) { + if (source.duration) { + return source.duration; + } else { return seconds; } - return ( - (this._offsetStart ?? 0) + - Player.__super__.currentTime.apply(this, arguments) - ); - }; - - Player.prototype.getCache = function () { - const cache = Player.__super__.getCache.apply(this); - if (this._offsetDuration !== undefined) - return { - ...cache, - currentTime: - (this._offsetStart ?? 0) + Player.__super__.currentTime.apply(this), - }; - return cache; - }; - - Player.prototype.remainingTime = function () { - if (this._offsetDuration !== undefined) { - return this._offsetDuration - this.currentTime(); + }, + buffered(buffers: TimeRanges) { + if (offsetStart === undefined) { + return buffers; } - return this.duration() - this.currentTime(); - }; - } -}; -// Register the plugin with video.js. -videojs.registerPlugin("offset", offset); + const timeRanges: number[][] = []; + for (let i = 0; i < buffers.length; i++) { + const start = buffers.start(i) + offsetStart; + const end = buffers.end(i) + offsetStart; -export default offset; + timeRanges.push([start, end]); + } + + // types for createTimeRanges are incorrect, should be number[][] not TimeRange[] + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return videojs.createTimeRanges(timeRanges as any); + }, + currentTime(seconds: number) { + return (offsetStart ?? 0) + seconds; + }, + setCurrentTime(seconds: number) { + if (offsetStart === undefined) { + return seconds; + } + + const offsetSeconds = seconds - offsetStart; + const buffers = tech.buffered() as TimeRanges; + for (let i = 0; i < buffers.length; i++) { + const start = buffers.start(i); + const end = buffers.end(i); + // seek point is in buffer, just seek normally + if (start <= offsetSeconds && offsetSeconds <= end) { + return offsetSeconds; + } + } + + updateOffsetStart(seconds); + + const srcUrl = new URL(source.src); + srcUrl.searchParams.set("start", seconds.toString()); + source.src = srcUrl.toString(); + + const poster = player.poster(); + const playbackRate = tech.playbackRate(); + seeking = tech.paused() ? 1 : 2; + player.poster(""); + tech.setSource(source); + tech.setPlaybackRate(playbackRate); + tech.one("canplay", () => { + player.poster(poster); + if (seeking === 1) { + tech.pause(); + } + seeking = 0; + }); + tech.trigger("timeupdate"); + tech.trigger("pause"); + tech.trigger("seeking"); + tech.play(); + + return 0; + }, + callPlay() { + if (seeking) { + seeking = 2; + return videojs.middleware.TERMINATOR; + } + }, + }; +} + +videojs.use("*", offsetMiddleware); diff --git a/ui/v2.5/src/components/ScenePlayer/markers.ts b/ui/v2.5/src/components/ScenePlayer/markers.ts index d61ed9b0d..97eb0ff31 100644 --- a/ui/v2.5/src/components/ScenePlayer/markers.ts +++ b/ui/v2.5/src/components/ScenePlayer/markers.ts @@ -1,107 +1,153 @@ import videojs, { VideoJsPlayer } from "video.js"; -const markers = function (this: VideoJsPlayer) { - const player = this; +interface IMarker { + title: string; + time: number; +} - function getPosition(marker: VTTCue) { - return (marker.startTime / player.duration()) * 100; - } +interface IMarkersOptions { + markers?: IMarker[]; +} - function createMarkerToolTip() { - const tooltip = videojs.dom.createEl("div") as HTMLElement; - tooltip.className = "vjs-marker-tooltip"; +class MarkersPlugin extends videojs.getPlugin("plugin") { + private markers: IMarker[] = []; + private markerDivs: HTMLDivElement[] = []; + private markerTooltip: HTMLElement | null = null; + private defaultTooltip: HTMLElement | null = null; - return tooltip; - } + constructor(player: VideoJsPlayer, options?: IMarkersOptions) { + super(player); - function removeMarkerToolTip() { - const div = player - .el() - .querySelector(".vjs-progress-holder .vjs-marker-tooltip"); - if (div) div.remove(); - } - - function createMarkerDiv(marker: VTTCue) { - const markerDiv = videojs.dom.createEl( - "div", - {}, - { - "data-marker-time": marker.startTime, - } - ) as HTMLElement; - - markerDiv.className = "vjs-marker"; - markerDiv.style.left = getPosition(marker) + "%"; - - // bind click event to seek to marker time - markerDiv.addEventListener("click", function () { - const time = this.getAttribute("data-marker-time"); - player.currentTime(Number(time)); - }); - - // show tooltip on hover - markerDiv.addEventListener("mouseenter", function () { - // create and show tooltip - const tooltip = createMarkerToolTip(); - tooltip.innerText = marker.text; + player.ready(() => { + // create marker tooltip + const tooltip = videojs.dom.createEl("div") as HTMLElement; + tooltip.className = "vjs-marker-tooltip"; + tooltip.style.visibility = "hidden"; const parent = player .el() .querySelector(".vjs-progress-holder .vjs-mouse-display"); + if (parent) parent.appendChild(tooltip); + this.markerTooltip = tooltip; - parent?.appendChild(tooltip); - - // hide default tooltip - const defaultTooltip = parent?.querySelector( - ".vjs-time-tooltip" - ) as HTMLElement; - defaultTooltip.style.visibility = "hidden"; - }); - - markerDiv.addEventListener("mouseout", function () { - removeMarkerToolTip(); - - // show default tooltip - const defaultTooltip = player + // save default tooltip + this.defaultTooltip = player .el() - .querySelector( + .querySelector( ".vjs-progress-holder .vjs-mouse-display .vjs-time-tooltip" - ) as HTMLElement; - if (defaultTooltip) defaultTooltip.style.visibility = "visible"; + ); + + options?.markers?.forEach(this.addMarker, this); }); - return markerDiv; - } + player.on("loadedmetadata", () => { + const seekBar = player.el().querySelector(".vjs-progress-holder"); + const duration = this.player.duration(); - function removeMarkerDivs() { - const divs = player - .el() - .querySelectorAll(".vjs-progress-holder .vjs-marker"); - divs.forEach((div) => { - div.remove(); - }); - } + for (let i = 0; i < this.markers.length; i++) { + const marker = this.markers[i]; + const markerDiv = this.markerDivs[i]; - this.on("loadedmetadata", function () { - removeMarkerDivs(); - removeMarkerToolTip(); - - const textTracks = player.remoteTextTracks(); - const seekBar = player.el().querySelector(".vjs-progress-holder"); - - if (seekBar && textTracks.length > 0) { - const vttTrack = textTracks[0]; - if (!vttTrack || !vttTrack.cues) return; - for (let i = 0; i < vttTrack.cues.length; i++) { - const cue = vttTrack.cues[i]; - const markerDiv = createMarkerDiv(cue as VTTCue); - seekBar.appendChild(markerDiv); + if (duration) { + // marker is 6px wide - adjust by 3px to align to center not left side + markerDiv.style.left = `calc(${ + (marker.time / duration) * 100 + }% - 3px)`; + markerDiv.style.visibility = "visible"; + } + if (seekBar) seekBar.appendChild(markerDiv); } + }); + } + + private showMarkerTooltip(title: string) { + if (!this.markerTooltip) return; + + this.markerTooltip.innerText = title; + this.markerTooltip.style.right = `${-this.markerTooltip.clientWidth / 2}px`; + this.markerTooltip.style.visibility = "visible"; + + // hide default tooltip + if (this.defaultTooltip) this.defaultTooltip.style.visibility = "hidden"; + } + + private hideMarkerTooltip() { + if (this.markerTooltip) this.markerTooltip.style.visibility = "hidden"; + + // show default tooltip + if (this.defaultTooltip) this.defaultTooltip.style.visibility = "visible"; + } + + addMarker(marker: IMarker) { + const markerDiv = videojs.dom.createEl("div") as HTMLDivElement; + markerDiv.className = "vjs-marker"; + + const duration = this.player.duration(); + if (duration) { + // marker is 6px wide - adjust by 3px to align to center not left side + markerDiv.style.left = `calc(${(marker.time / duration) * 100}% - 3px)`; + markerDiv.style.visibility = "visible"; } - }); -}; + + // bind click event to seek to marker time + markerDiv.addEventListener("click", () => + this.player.currentTime(marker.time) + ); + + // show/hide tooltip on hover + markerDiv.addEventListener("mouseenter", () => { + this.showMarkerTooltip(marker.title); + markerDiv.toggleAttribute("marker-tooltip-shown", true); + }); + markerDiv.addEventListener("mouseout", () => { + this.hideMarkerTooltip(); + markerDiv.toggleAttribute("marker-tooltip-shown", false); + }); + + const seekBar = this.player.el().querySelector(".vjs-progress-holder"); + if (seekBar) seekBar.appendChild(markerDiv); + + this.markers.push(marker); + this.markerDivs.push(markerDiv); + } + + addMarkers(markers: IMarker[]) { + markers.forEach(this.addMarker, this); + } + + removeMarker(marker: IMarker) { + const i = this.markers.indexOf(marker); + if (i === -1) return; + + this.markers.splice(i, 1); + + const div = this.markerDivs.splice(i, 1)[0]; + if (div.hasAttribute("marker-tooltip-shown")) { + this.hideMarkerTooltip(); + } + div.remove(); + } + + removeMarkers(markers: IMarker[]) { + markers.forEach(this.removeMarker, this); + } + + clearMarkers() { + this.removeMarkers([...this.markers]); + } +} // Register the plugin with video.js. -videojs.registerPlugin("markers", markers); +videojs.registerPlugin("markers", MarkersPlugin); -export default markers; +/* eslint-disable @typescript-eslint/naming-convention */ +declare module "video.js" { + interface VideoJsPlayer { + markers: () => MarkersPlugin; + } + interface VideoJsPlayerPluginOptions { + markers?: IMarkersOptions; + } +} + +export default MarkersPlugin; diff --git a/ui/v2.5/src/components/ScenePlayer/persist-volume.ts b/ui/v2.5/src/components/ScenePlayer/persist-volume.ts index bbfe0503a..6c36267db 100644 --- a/ui/v2.5/src/components/ScenePlayer/persist-volume.ts +++ b/ui/v2.5/src/components/ScenePlayer/persist-volume.ts @@ -1,30 +1,59 @@ import videojs, { VideoJsPlayer } from "video.js"; import localForage from "localforage"; -const persistVolume = function (this: VideoJsPlayer) { - const player = this; - const levelKey = "volume-level"; - const mutedKey = "volume-muted"; +const levelKey = "volume-level"; +const mutedKey = "volume-muted"; - player.on("volumechange", function () { - localForage.setItem(levelKey, player.volume()); - localForage.setItem(mutedKey, player.muted()); - }); +interface IPersistVolumeOptions { + enabled?: boolean; +} - localForage.getItem(levelKey).then((value) => { - if (value !== null) { - player.volume(value as number); - } - }); +class PersistVolumePlugin extends videojs.getPlugin("plugin") { + enabled: boolean; - localForage.getItem(mutedKey).then((value) => { - if (value !== null) { - player.muted(value as boolean); - } - }); -}; + constructor(player: VideoJsPlayer, options?: IPersistVolumeOptions) { + super(player, options); + + this.enabled = options?.enabled ?? true; + + player.on("volumechange", () => { + if (this.enabled) { + localForage.setItem(levelKey, player.volume()); + localForage.setItem(mutedKey, player.muted()); + } + }); + + player.ready(() => { + this.ready(); + }); + } + + private ready() { + localForage.getItem(levelKey).then((value) => { + if (value !== null) { + this.player.volume(value); + } + }); + + localForage.getItem(mutedKey).then((value) => { + if (value !== null) { + this.player.muted(value); + } + }); + } +} // Register the plugin with video.js. -videojs.registerPlugin("persistVolume", persistVolume); +videojs.registerPlugin("persistVolume", PersistVolumePlugin); -export default persistVolume; +/* eslint-disable @typescript-eslint/naming-convention */ +declare module "video.js" { + interface VideoJsPlayer { + persistVolume: () => PersistVolumePlugin; + } + interface VideoJsPlayerPluginOptions { + persistVolume?: IPersistVolumeOptions; + } +} + +export default PersistVolumePlugin; diff --git a/ui/v2.5/src/components/ScenePlayer/source-selector.ts b/ui/v2.5/src/components/ScenePlayer/source-selector.ts index 772f21ad4..206d3fbca 100644 --- a/ui/v2.5/src/components/ScenePlayer/source-selector.ts +++ b/ui/v2.5/src/components/ScenePlayer/source-selector.ts @@ -1,42 +1,70 @@ import videojs, { VideoJsPlayer } from "video.js"; -interface ISource extends videojs.Tech.SourceObject { +export interface ISource extends videojs.Tech.SourceObject { label?: string; - selected?: boolean; - sortIndex?: number; } -const MenuButton = videojs.getComponent("MenuButton"); -const MenuItem = videojs.getComponent("MenuItem"); - -class SourceMenuItem extends MenuItem { - private parent: SourceMenuButton; +class SourceMenuItem extends videojs.getComponent("MenuItem") { public source: ISource; - public index: number; + public isSelected = false; - constructor( - parent: SourceMenuButton, - source: ISource, - index: number, - player: VideoJsPlayer, - options: videojs.MenuItemOptions - ) { + constructor(parent: SourceMenuButton, source: ISource) { + const options = {} as videojs.MenuItemOptions; options.selectable = true; options.multiSelectable = false; + options.label = source.label || source.type; - super(player, options); + super(parent.player(), options); - this.parent = parent; this.source = source; - this.index = index; + + this.addClass("vjs-source-menu-item"); + } + + selected(selected: boolean): void { + super.selected(selected); + this.isSelected = selected; } handleClick() { - this.parent.trigger("selected", this); + if (this.isSelected) return; + + this.trigger("selected"); } } -class SourceMenuButton extends MenuButton { +class SourceMenuButton extends videojs.getComponent("MenuButton") { + private items: SourceMenuItem[] = []; + private selectedSource: ISource | null = null; + + constructor(player: VideoJsPlayer) { + super(player); + + player.on("loadstart", () => { + this.update(); + }); + } + + public setSources(sources: ISource[]) { + this.selectedSource = null; + + this.items = sources.map((source, i) => { + if (i === 0) { + this.selectedSource = source; + } + + const item = new SourceMenuItem(this, source); + + item.on("selected", () => { + this.selectedSource = source; + + this.trigger("sourceselected", source); + }); + + return item; + }); + } + createEl() { return videojs.dom.createEl("div", { className: @@ -45,106 +73,154 @@ class SourceMenuButton extends MenuButton { } createItems() { - const player = this.player(); - const menuButton = this; + if (this.items === undefined) return []; - // slice so that we don't alter the order of the original array - const sources = player.currentSources().slice() as ISource[]; - - sources.sort((a, b) => (a.sortIndex ?? 0) - (b.sortIndex ?? 0)); - - const hasSelected = sources.some((source) => source.selected); - if (!hasSelected && sources.length > 0) { - sources[0].selected = true; + for (const item of this.items) { + item.selected(item.source === this.selectedSource); } - menuButton.on("selected", function (e, selectedItem) { - // don't do anything if re-selecting the same source - if (selectedItem.source.selected) { - return; - } - - // populate source sortIndex first if not present - const currentSources = (player.currentSources() as ISource[]).map( - (src, i) => { - return { - ...src, - sortIndex: src.sortIndex ?? i, - selected: false, - }; - } - ); - - // put the selected source at the top of the list - const selectedIndex = currentSources.findIndex( - (src) => src.sortIndex === selectedItem.index - ); - const selectedSrc = currentSources.splice(selectedIndex, 1)[0]; - selectedSrc.selected = true; - currentSources.unshift(selectedSrc); - - const currentTime = player.currentTime(); - - /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ - (player as any).clearOffsetDuration(); - player.src(currentSources); - player.currentTime(currentTime); - player.play(); - }); - - return sources.map((source, index) => { - const label = source.label || source.type; - const item = new SourceMenuItem( - menuButton, - source, - index, - this.player(), - { - label: label, - selected: source.selected || (!hasSelected && index === 0), - } - ); - - menuButton.on("selected", function (selectedItem) { - if (selectedItem !== item) { - item.selected(false); - } - }); - - item.addClass("vjs-source-menu-item"); - - return item; - }); + return this.items; } } -const sourceSelector = function (this: VideoJsPlayer) { - const player = this; +class SourceSelectorPlugin extends videojs.getPlugin("plugin") { + private menu: SourceMenuButton; + private sources: ISource[] = []; + private selectedIndex = -1; + private cleanupTextTracks: HTMLTrackElement[] = []; + private manualTextTracks: HTMLTrackElement[] = []; - /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ - const PlayerConstructor = this.constructor as any; - if (!PlayerConstructor.__sourceSelector) { - PlayerConstructor.__sourceSelector = { - selectSource: PlayerConstructor.prototype.selectSource, - }; + constructor(player: VideoJsPlayer) { + super(player); + + this.menu = new SourceMenuButton(player); + + this.menu.on("sourceselected", (_, source: ISource) => { + this.selectedIndex = this.sources.findIndex((src) => src === source); + if (this.selectedIndex === -1) return; + + const currentTime = player.currentTime(); + + // put the selected source at the top of the list + const loadSources = [...this.sources]; + const selectedSrc = loadSources.splice(this.selectedIndex, 1)[0]; + loadSources.unshift(selectedSrc); + + const paused = player.paused(); + player.src(loadSources); + player.one("canplay", () => { + if (paused) { + player.pause(); + } + player.currentTime(currentTime); + }); + player.play(); + }); + + player.on("ready", () => { + const { controlBar } = player; + const fullscreenToggle = controlBar.getChild("fullscreenToggle")!.el(); + controlBar.addChild(this.menu); + controlBar.el().insertBefore(this.menu.el(), fullscreenToggle); + }); + + player.on("loadedmetadata", () => { + if (!player.videoWidth() && !player.videoHeight()) { + // Occurs during preload when videos with supported audio/unsupported video are preloaded. + // Treat this as a decoding error and try the next source without playing. + // However on Safari we get an media event when m3u8 is loaded which needs to be ignored. + if (player.error() !== null) return; + const currentSrc = player.currentSrc(); + if (currentSrc !== null && !currentSrc.includes(".m3u8")) { + player.error(MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED); + return; + } + } + }); + + player.on("error", () => { + const error = player.error(); + if (!error) return; + + // Only try next source if media was unsupported + if (error.code !== MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED) return; + + const currentSource = player.currentSource() as ISource; + console.log(`Source '${currentSource.label}' is unsupported`); + + if (this.sources.length > 1) { + if (this.selectedIndex === -1) return; + + this.sources.splice(this.selectedIndex, 1); + const newSource = this.sources[0]; + console.log(`Trying next source in playlist: '${newSource.label}'`); + this.menu.setSources(this.sources); + this.selectedIndex = 0; + player.src(this.sources); + player.load(); + player.play(); + } else { + console.log("No more sources in playlist"); + } + }); } - videojs.registerComponent("SourceMenuButton", SourceMenuButton); + setSources(sources: ISource[]) { + const cleanupTracks = this.cleanupTextTracks.splice(0); + for (const track of cleanupTracks) { + this.player.removeRemoteTextTrack(track); + } - player.on("loadedmetadata", function () { - const { controlBar } = player; - const fullscreenToggle = controlBar.getChild("fullscreenToggle")!.el(); + this.menu.setSources(sources); + if (sources.length !== 0) { + this.selectedIndex = 0; + } else { + this.selectedIndex = -1; + } - const existingMenuButton = controlBar.getChild("SourceMenuButton"); - if (existingMenuButton) controlBar.removeChild(existingMenuButton); + this.sources = sources; + this.player.src(this.sources); + } - const menuButton = controlBar.addChild("SourceMenuButton"); + get textTracks(): HTMLTrackElement[] { + return [...this.cleanupTextTracks, ...this.manualTextTracks]; + } - controlBar.el().insertBefore(menuButton.el(), fullscreenToggle); - }); -}; + addTextTrack(options: videojs.TextTrackOptions, manualCleanup: boolean) { + const track = this.player.addRemoteTextTrack(options, true); + if (manualCleanup) { + this.manualTextTracks.push(track); + } else { + this.cleanupTextTracks.push(track); + } + return track; + } + + removeTextTrack(track: HTMLTrackElement) { + this.player.removeRemoteTextTrack(track); + let index = this.manualTextTracks.indexOf(track); + if (index != -1) { + this.manualTextTracks.splice(index, 1); + } + index = this.cleanupTextTracks.indexOf(track); + if (index != -1) { + this.cleanupTextTracks.splice(index, 1); + } + } +} // Register the plugin with video.js. -videojs.registerPlugin("sourceSelector", sourceSelector); +videojs.registerComponent("SourceMenuButton", SourceMenuButton); +videojs.registerPlugin("sourceSelector", SourceSelectorPlugin); -export default sourceSelector; +/* eslint-disable @typescript-eslint/naming-convention */ +declare module "video.js" { + interface VideoJsPlayer { + sourceSelector: () => SourceSelectorPlugin; + } + interface VideoJsPlayerPluginOptions { + sourceSelector?: {}; + } +} + +export default SourceSelectorPlugin; diff --git a/ui/v2.5/src/components/ScenePlayer/styles.scss b/ui/v2.5/src/components/ScenePlayer/styles.scss index d04f3a7f5..76a8ba73e 100644 --- a/ui/v2.5/src/components/ScenePlayer/styles.scss +++ b/ui/v2.5/src/components/ScenePlayer/styles.scss @@ -1,3 +1,7 @@ +@import "video.js/dist/video-js.css"; +@import "videojs-mobile-ui/dist/videojs-mobile-ui.css"; +@import "videojs-seek-buttons/dist/videojs-seek-buttons.css"; + $scrubberHeight: 120px; $menuHeight: 4rem; $sceneTabWidth: 450px; @@ -12,30 +16,24 @@ $sceneTabWidth: 450px; height: 100vh; } - .video-js { - height: 56.25vw; - overflow: hidden; - width: 100%; - - @media (min-width: 1200px) { - height: 100%; - } - } - &.portrait .video-js { height: 177.78vw; } +} + +.video-js { + height: 56.25vw; + overflow: hidden; + width: 100%; + + @media (min-width: 1200px) { + height: 100%; + } .vjs-button { outline: none; } - .vjs-vtt-thumbnail-display { - // default opacity to 0, it gets set to 1 when moused-over. - // prevents the border from showing up when initially loaded - opacity: 0; - } - .vjs-big-button-group { display: none; height: 80px; @@ -44,6 +42,7 @@ $sceneTabWidth: 450px; position: absolute; top: calc(50% - 40px); width: 100%; + z-index: 1; .vjs-button { font-size: 4em; @@ -57,8 +56,183 @@ $sceneTabWidth: 450px; } } + .vjs-touch-overlay .vjs-play-control { + z-index: 1; + } + + .vjs-control-bar { + background: none; + + /* Scales control size */ + font-size: 15px; + + &::before { + background: linear-gradient( + 0deg, + rgba(0, 0, 0, 0.4) 0%, + rgba(0, 0, 0, 0) 100% + ); + bottom: 0; + content: ""; + height: 10rem; + pointer-events: none; + position: absolute; + width: 100%; + } + } + + .vjs-time-control { + align-items: center; + display: flex; + justify-content: center; + min-width: 0; + padding: 0 4px; + pointer-events: none; + + .vjs-control-text { + display: none; + } + } + + .vjs-duration { + margin-right: auto; + } + + .vjs-remaining-time { + display: none; + } + + .vjs-progress-control { + bottom: 2.5em; + height: 3em; + position: absolute; + width: 100%; + + .vjs-progress-holder { + margin: 0 1rem; + } + } + + /* stylelint-disable declaration-no-important */ + .vjs-play-progress .vjs-time-tooltip { + display: none !important; + } + /* stylelint-enable declaration-no-important */ + + .vjs-volume-control { + z-index: 1; + } + + /* stylelint-disable declaration-no-important */ + .vjs-slider { + box-shadow: none !important; + text-shadow: none !important; + } + /* stylelint-enable declaration-no-important */ + + .vjs-vtt-thumbnail-display { + border: 2px solid white; + border-radius: 2px; + bottom: 6em; + box-shadow: 0 0 7px rgba(0, 0, 0, 0.6); + opacity: 0; + pointer-events: none; + position: absolute; + transition: opacity 0.2s; + } + + .vjs-big-play-button, + .vjs-big-play-button:hover, + .vjs-big-play-button:focus, + &:hover .vjs-big-play-button { + background: none; + border: none; + font-size: 10em; + } + + .vjs-skip-button { + &::before { + font-size: 1.8em; + line-height: 1.67; + } + } + + &.vjs-skip-buttons { + .vjs-icon-next-item, + .vjs-icon-previous-item { + display: none; + } + + &-prev .vjs-icon-previous-item, + &-next .vjs-icon-next-item { + display: inline-block; + } + } + + .vjs-source-selector { + .vjs-menu li { + font-size: 0.8em; + } + + .vjs-button > .vjs-icon-placeholder::before { + content: "\f110"; + font-family: VideoJS; + } + } + + .vjs-marker { + background-color: rgba(33, 33, 33, 0.8); + bottom: 0; + height: 100%; + left: 0; + opacity: 1; + position: absolute; + transition: opacity 0.2s ease; + visibility: hidden; + width: 6px; + z-index: 100; + + &:hover { + cursor: pointer; + transform: scale(1.3, 1.3); + } + } + + .vjs-marker-tooltip { + background-color: #fff; + background-color: rgba(255, 255, 255, 0.8); + border-radius: 0.3em; + color: #000; + float: right; + font-family: Arial, Helvetica, sans-serif; + font-size: 0.6em; + padding: 6px 8px 8px 8px; + pointer-events: none; + position: absolute; + top: -3.4em; + visibility: hidden; + white-space: nowrap; + z-index: 1; + } + + .vjs-text-track-settings select { + background: #fff; + } + + .vjs-seek-button.skip-back span.vjs-icon-placeholder::before { + -ms-transform: none; + -webkit-transform: none; + transform: none; + } + + .vjs-seek-button.skip-forward span.vjs-icon-placeholder::before { + -ms-transform: scale(-1, 1); + -webkit-transform: scale(-1, 1); + transform: scale(-1, 1); + } + @media (pointer: coarse) { - .vjs-touch-enabled { + &.vjs-touch-enabled { &.vjs-has-started .vjs-big-button-group { display: flex; opacity: 1; @@ -81,9 +255,8 @@ $sceneTabWidth: 450px; content: "\f103"; } - // hide the regular play/pause button on touch screens - .vjs-play-control { - display: none; + .vjs-vtt-thumbnail-display { + bottom: 2.8em; } // hide the regular seek buttons on touch screens @@ -96,10 +269,19 @@ $sceneTabWidth: 450px; // make controls a little more compact on smaller screens @media (max-width: 576px) { .vjs-control-bar { - .vjs-control:not(.vjs-progress-control) { + .vjs-control { width: 2.5em; } + .vjs-progress-control { + height: 2em; + width: 100%; + } + + .vjs-playback-rate { + width: 3em; + } + .vjs-button > .vjs-icon-placeholder::before, .vjs-skip-button::before { font-size: 1.5em; @@ -107,9 +289,27 @@ $sceneTabWidth: 450px; } } + .vjs-menu-button-popup .vjs-menu { + width: 8em; + + .vjs-menu-content { + max-height: 10em; + } + } + + .vjs-playback-rate .vjs-playback-rate-value { + font-size: 1em; + line-height: 2.97; + } + + .vjs-source-selector { + .vjs-menu li { + font-size: 10px; + } + } + .vjs-time-control { font-size: 12px; - line-height: 4em; } .vjs-big-button-group .vjs-button { @@ -120,83 +320,6 @@ $sceneTabWidth: 450px; .vjs-current-time { margin-left: 1em; } - - .vjs-vtt-thumbnail-display { - bottom: 40px; - } - } -} - -.video-js { - .vjs-control-bar { - background: none; - - /* Scales control size */ - font-size: 15px; - - &::before { - background: linear-gradient( - 0deg, - rgba(0, 0, 0, 0.4) 0%, - rgba(0, 0, 0, 0) 100% - ); - bottom: 0; - content: ""; - height: 10rem; - position: absolute; - width: 100%; - } - } - - .vjs-time-control { - display: block; - min-width: 0; - padding: 0 4px; - pointer-events: none; - - .vjs-control-text { - display: none; - } - } - - .vjs-duration { - margin-right: auto; - } - - .vjs-remaining-time { - display: none; - } - - .vjs-progress-control { - bottom: 3rem; - margin-left: 1%; - position: absolute; - width: 98%; - } - - .vjs-volume-control { - z-index: 1; - } - - .vjs-vtt-thumbnail-display { - border: 2px solid white; - border-radius: 2px; - bottom: 90px; - position: absolute; - } - - .vjs-big-play-button, - .vjs-big-play-button:hover, - .vjs-big-play-button:focus, - &:hover .vjs-big-play-button { - background: none; - border: none; - font-size: 10em; - } - - .vjs-progress-control .vjs-play-progress .vjs-time-tooltip, - .vjs-progress-control:hover .vjs-play-progress .vjs-time-tooltip { - visibility: hidden; } } @@ -211,6 +334,146 @@ $sceneTabWidth: 450px; padding-right: 15px; } +.scrubber-wrapper { + display: flex; + flex-shrink: 0; + margin: 5px 0; + overflow: hidden; + position: relative; +} + +#scrubber-back { + float: left; +} + +#scrubber-forward { + float: right; +} + +.scrubber-button { + background-color: transparent; + border: 1px solid #555; + color: $link-color; + cursor: pointer; + font-size: 1.3rem; + font-weight: 800; + height: 100%; + line-height: $scrubberHeight; + padding: 0; + text-align: center; + width: 1.3rem; +} + +.scrubber-content { + cursor: pointer; + display: inline-block; + flex-grow: 1; + height: $scrubberHeight; + margin: 0 7px; + overflow: hidden; + -webkit-overflow-scrolling: touch; + position: relative; + -webkit-user-select: none; + user-select: none; + + &.dragging { + cursor: grabbing; + } +} + +#scrubber-position-indicator { + background-color: #ccc; + height: 20px; + left: -100%; + position: absolute; + width: 100%; + z-index: 0; +} + +#scrubber-current-position { + background-color: #fff; + height: 30px; + left: 50%; + position: absolute; + width: 2px; + z-index: 1; +} + +.scrubber-viewport { + height: 100%; + overflow: hidden; + position: static; +} + +.scrubber-slider { + height: 100%; + left: 0; + position: absolute; + width: 100%; +} + +.scrubber-tags { + height: 20px; + margin-bottom: 10px; + position: relative; + + &-background { + background-color: #555; + height: 20px; + left: 0; + position: absolute; + right: 0; + } +} + +.scrubber-tag { + background-color: #000; + cursor: pointer; + font-size: 10px; + height: 20px; + padding: 0 10px; + position: absolute; + transform: translateX(-50%); + white-space: nowrap; + + &:hover { + background-color: #444; + z-index: 1; + } + + &:hover::after { + border-top: solid 5px #444; + z-index: 1; + } + + &::after { + border-left: solid 5px transparent; + border-right: solid 5px transparent; + border-top: solid 5px #000; + bottom: -5px; + content: ""; + left: 50%; + margin-left: -5px; + position: absolute; + } +} + +.scrubber-item { + color: white; + display: flex; + font-size: 10px; + margin: 0 auto; + position: absolute; + text-align: center; + text-shadow: 1px 1px black; + + &-time { + align-self: flex-end; + display: inline-block; + width: 100%; + } +} + @media (max-width: 1199px) { .scene-tabs { padding-right: 15px; @@ -220,6 +483,11 @@ $sceneTabWidth: 450px; padding-left: 0; padding-right: 0; } + + .scrubber-wrapper { + margin-left: 5px; + margin-right: 5px; + } } @media (min-width: 1200px) { .scene-tabs { @@ -276,240 +544,3 @@ $sceneTabWidth: 450px; } } } - -.scrubber-wrapper { - flex-shrink: 0; - margin: 5px 0; - overflow: hidden; - position: relative; -} - -.hide-scrubber .scrubber-wrapper { - display: none; -} - -/* hide scrubber when height is < 450px or width < 576 */ -@media (max-height: 449px), (max-width: 575px) { - .scrubber-wrapper { - display: none; - } -} - -#scrubber-back { - float: left; -} - -#scrubber-forward { - float: right; -} - -.scrubber-button { - background-color: transparent; - border: 1px solid #555; - color: $link-color; - cursor: pointer; - font-size: 20px; - font-weight: 800; - height: 100%; - line-height: $scrubberHeight; - padding: 0; - text-align: center; - width: 1.5%; -} - -.scrubber-content { - cursor: grab; - display: inline-block; - height: $scrubberHeight; - margin: 0 0.5%; - overflow: hidden; - -webkit-overflow-scrolling: touch; - position: relative; - -webkit-user-select: none; - user-select: none; - width: 96%; - - &.dragging { - cursor: grabbing; - } -} - -#scrubber-position-indicator { - background-color: #ccc; - height: 20px; - left: -100%; - position: absolute; - width: 100%; - z-index: 0; -} - -#scrubber-current-position { - background-color: #fff; - height: 30px; - left: 50%; - position: absolute; - width: 2px; - z-index: 1; -} - -.scrubber-viewport { - height: 100%; - overflow: hidden; - position: static; -} - -.scrubber-slider { - height: 100%; - left: 0; - position: absolute; - transition: 333ms ease-out; - width: 100%; -} - -.scrubber-tags { - height: 20px; - margin-bottom: 10px; - position: relative; - - &-background { - background-color: #555; - height: 20px; - left: 0; - position: absolute; - right: 0; - } -} - -.scrubber-tag { - background-color: #000; - cursor: pointer; - font-size: 10px; - padding: 0 10px; - position: absolute; - white-space: nowrap; - - &:hover { - background-color: #444; - z-index: 1; - } - - &::after { - border-left: solid 5px transparent; - border-right: solid 5px transparent; - border-top: solid 5px #000; - bottom: -5px; - content: ""; - left: 50%; - margin-left: -5px; - position: absolute; - } -} - -.scrubber-item { - color: white; - cursor: pointer; - display: flex; - font-size: 10px; - margin-right: 10px; - position: absolute; - text-align: center; - text-shadow: 1px 1px black; - - &-time { - align-self: flex-end; - display: inline-block; - width: 100%; - } -} - -.vjs-skip-button { - &::before { - font-size: 1.8em; - line-height: 1.67; - } -} - -.vjs-skip-buttons { - .vjs-icon-next-item, - .vjs-icon-previous-item { - display: none; - } - - &-prev .vjs-icon-previous-item, - &-next .vjs-icon-next-item { - display: inline-block; - } -} - -.vjs-source-selector { - .vjs-menu li { - font-size: 12px; - } - - .vjs-button > .vjs-icon-placeholder::before { - content: "\f110"; - font-family: VideoJS; - } -} - -.vjs-marker { - background-color: rgba(33, 33, 33, 0.8); - bottom: 0; - height: 100%; - left: 0; - opacity: 1; - position: absolute; - -webkit-transition: opacity 0.2s ease; - -moz-transition: opacity 0.2s ease; - transition: opacity 0.2s ease; - width: 6px; - z-index: 100; - - &:hover { - cursor: pointer; - -webkit-transform: scale(1.3, 1.3); - -moz-transform: scale(1.3, 1.3); - -o-transform: scale(1.3, 1.3); - -ms-transform: scale(1.3, 1.3); - transform: scale(1.3, 1.3); - } -} - -.vjs-marker-tooltip { - border-radius: 0.3em; - color: white; - display: block; - float: right; - font-family: Arial, Helvetica, sans-serif; - font-size: 10px; - height: 50px; - padding: 6px 8px 8px 8px; - pointer-events: none; - position: absolute; - right: -80px; - top: -5.4em; - width: 160px; - z-index: 1; -} - -.vjs-text-track-settings select { - background: #fff; -} - -.VideoPlayer - .video-js - .vjs-seek-button.skip-back - span.vjs-icon-placeholder::before { - -ms-transform: none; - -webkit-transform: none; - transform: none; -} - -.VideoPlayer - .video-js - .vjs-seek-button.skip-forward - span.vjs-icon-placeholder::before { - -ms-transform: scale(-1, 1); - -webkit-transform: scale(-1, 1); - transform: scale(-1, 1); -} diff --git a/ui/v2.5/src/components/ScenePlayer/track-activity.ts b/ui/v2.5/src/components/ScenePlayer/track-activity.ts new file mode 100644 index 000000000..f4ed2eb49 --- /dev/null +++ b/ui/v2.5/src/components/ScenePlayer/track-activity.ts @@ -0,0 +1,131 @@ +import videojs, { VideoJsPlayer } from "video.js"; + +const intervalSeconds = 1; // check every second +const sendInterval = 10; // send every 10 seconds + +class TrackActivityPlugin extends videojs.getPlugin("plugin") { + totalPlayDuration = 0; + currentPlayDuration = 0; + minimumPlayPercent = 0; + incrementPlayCount: () => Promise = () => { + return Promise.resolve(); + }; + saveActivity: ( + resumeTime: number, + playDuration: number + ) => Promise = () => { + return Promise.resolve(); + }; + + private enabled = false; + private playCountIncremented = false; + private intervalID: number | undefined; + + private lastResumeTime = 0; + private lastDuration = 0; + + constructor(player: VideoJsPlayer) { + super(player); + + player.on("play", () => { + this.start(); + }); + + player.on("pause", () => { + this.stop(); + }); + + player.on("dispose", () => { + this.stop(); + }); + } + + private start() { + if (this.enabled && !this.intervalID) { + this.intervalID = window.setInterval(() => { + this.intervalHandler(); + }, intervalSeconds * 1000); + this.lastResumeTime = this.player.currentTime(); + this.lastDuration = this.player.duration(); + } + } + + private stop() { + if (this.intervalID) { + window.clearInterval(this.intervalID); + this.intervalID = undefined; + this.sendActivity(); + } + } + + reset() { + this.stop(); + this.totalPlayDuration = 0; + this.currentPlayDuration = 0; + this.playCountIncremented = false; + } + + setEnabled(enabled: boolean) { + this.enabled = enabled; + if (!enabled) { + this.stop(); + } else if (!this.player.paused()) { + this.start(); + } + } + + private intervalHandler() { + if (!this.enabled || !this.player) return; + + this.lastResumeTime = this.player.currentTime(); + this.lastDuration = this.player.duration(); + + this.totalPlayDuration += intervalSeconds; + this.currentPlayDuration += intervalSeconds; + if (this.totalPlayDuration % sendInterval === 0) { + this.sendActivity(); + } + } + + private sendActivity() { + if (!this.enabled) return; + + if (this.totalPlayDuration > 0) { + let resumeTime = this.player?.currentTime() ?? this.lastResumeTime; + const videoDuration = this.player?.duration() ?? this.lastDuration; + const percentCompleted = (100 / videoDuration) * resumeTime; + const percentPlayed = (100 / videoDuration) * this.totalPlayDuration; + + if ( + !this.playCountIncremented && + percentPlayed >= this.minimumPlayPercent + ) { + this.incrementPlayCount(); + this.playCountIncremented = true; + } + + // if video is 98% or more complete then reset resume_time + if (percentCompleted >= 98) { + resumeTime = 0; + } + + this.saveActivity(resumeTime, this.currentPlayDuration); + this.currentPlayDuration = 0; + } + } +} + +// Register the plugin with video.js. +videojs.registerPlugin("trackActivity", TrackActivityPlugin); + +/* eslint-disable @typescript-eslint/naming-convention */ +declare module "video.js" { + interface VideoJsPlayer { + trackActivity: () => TrackActivityPlugin; + } + interface VideoJsPlayerPluginOptions { + trackActivity?: {}; + } +} + +export default TrackActivityPlugin; diff --git a/ui/v2.5/src/components/ScenePlayer/util.ts b/ui/v2.5/src/components/ScenePlayer/util.ts index c1d595b49..a63ab6a2e 100644 --- a/ui/v2.5/src/components/ScenePlayer/util.ts +++ b/ui/v2.5/src/components/ScenePlayer/util.ts @@ -1,6 +1,6 @@ -import VideoJS from "video.js"; +import videojs from "video.js"; export const VIDEO_PLAYER_ID = "VideoJsPlayer"; export const getPlayerPosition = () => - VideoJS.getPlayer(VIDEO_PLAYER_ID).currentTime(); + videojs.getPlayer(VIDEO_PLAYER_ID)?.currentTime(); diff --git a/ui/v2.5/src/components/ScenePlayer/vtt-thumbnails.ts b/ui/v2.5/src/components/ScenePlayer/vtt-thumbnails.ts new file mode 100644 index 000000000..33495eec7 --- /dev/null +++ b/ui/v2.5/src/components/ScenePlayer/vtt-thumbnails.ts @@ -0,0 +1,398 @@ +import videojs, { VideoJsPlayer } from "video.js"; +import { WebVTT } from "videojs-vtt.js"; + +export interface IVTTThumbnailsOptions { + /** + * Source URL to use for thumbnails. + */ + src?: string; + /** + * Whether to show the timestamp on hover. + * @default false + */ + showTimestamp?: boolean; +} + +interface IVTTData { + start: number; + end: number; + style: IVTTStyle | null; +} + +interface IVTTStyle { + background: string; + width: string; + height: string; +} + +class VTTThumbnailsPlugin extends videojs.getPlugin("plugin") { + private source: string | null; + private showTimestamp: boolean; + + private progressBar?: HTMLElement; + private thumbnailHolder?: HTMLDivElement; + + private showing = false; + + private vttData?: IVTTData[]; + private lastStyle?: IVTTStyle; + + constructor(player: VideoJsPlayer, options: IVTTThumbnailsOptions) { + super(player, options); + this.source = options.src ?? null; + this.showTimestamp = options.showTimestamp ?? false; + + player.ready(() => { + player.addClass("vjs-vtt-thumbnails"); + this.initializeThumbnails(); + }); + } + + src(source: string | null): void { + this.resetPlugin(); + this.source = source; + this.initializeThumbnails(); + } + + detach(): void { + this.resetPlugin(); + } + + private resetPlugin() { + this.showing = false; + + if (this.thumbnailHolder) { + this.thumbnailHolder.remove(); + delete this.thumbnailHolder; + } + + if (this.progressBar) { + this.progressBar.removeEventListener( + "pointerenter", + this.onBarPointerEnter + ); + this.progressBar.removeEventListener( + "pointermove", + this.onBarPointerMove + ); + this.progressBar.removeEventListener( + "pointerleave", + this.onBarPointerLeave + ); + + delete this.progressBar; + } + + delete this.vttData; + delete this.lastStyle; + } + + /** + * Bootstrap the plugin. + */ + private initializeThumbnails() { + if (!this.source) { + return; + } + + const baseUrl = this.getBaseUrl(); + const url = this.getFullyQualifiedUrl(this.source, baseUrl); + + this.getVttFile(url).then((data) => { + this.vttData = this.processVtt(data); + this.setupThumbnailElement(); + }); + } + + /** + * Builds a base URL should we require one. + */ + private getBaseUrl() { + return [ + window.location.protocol, + "//", + window.location.hostname, + window.location.port ? ":" + window.location.port : "", + window.location.pathname, + ] + .join("") + .split(/([^\/]*)$/gi)[0]; + } + + /** + * Grabs the contents of the VTT file. + */ + private getVttFile(url: string): Promise { + return new Promise((resolve, reject) => { + const req = new XMLHttpRequest(); + + req.addEventListener("load", () => { + resolve(req.responseText); + }); + req.addEventListener("error", (e) => { + reject(e); + }); + req.open("GET", url); + req.send(); + }); + } + + private setupThumbnailElement() { + const progressBar = this.player.$(".vjs-progress-control") as HTMLElement; + if (!progressBar) return; + this.progressBar = progressBar; + + const thumbHolder = document.createElement("div"); + thumbHolder.setAttribute("class", "vjs-vtt-thumbnail-display"); + progressBar.appendChild(thumbHolder); + this.thumbnailHolder = thumbHolder; + + if (!this.showTimestamp) { + this.player.$(".vjs-mouse-display")?.classList.add("vjs-hidden"); + } + + progressBar.addEventListener("pointerenter", this.onBarPointerEnter); + progressBar.addEventListener("pointerleave", this.onBarPointerLeave); + } + + private onBarPointerEnter = () => { + this.showThumbnailHolder(); + this.progressBar?.addEventListener("pointermove", this.onBarPointerMove); + }; + + private onBarPointerMove = (e: Event) => { + const { progressBar } = this; + if (!progressBar) return; + + this.showThumbnailHolder(); + this.updateThumbnailStyle( + videojs.dom.getPointerPosition(progressBar, e).x, + progressBar.offsetWidth + ); + }; + + private onBarPointerLeave = () => { + this.hideThumbnailHolder(); + this.progressBar?.removeEventListener("pointermove", this.onBarPointerMove); + }; + + private getStyleForTime(time: number) { + if (!this.vttData) return null; + + for (const element of this.vttData) { + const item = element; + + if (time >= item.start && time < item.end) { + return item.style; + } + } + + return null; + } + + private showThumbnailHolder() { + if (this.thumbnailHolder && !this.showing) { + this.showing = true; + this.thumbnailHolder.style.opacity = "1"; + } + } + + private hideThumbnailHolder() { + if (this.thumbnailHolder && this.showing) { + this.showing = false; + this.thumbnailHolder.style.opacity = "0"; + } + } + + private updateThumbnailStyle(percent: number, width: number) { + if (!this.thumbnailHolder) return; + + const duration = this.player.duration(); + const time = percent * duration; + const currentStyle = this.getStyleForTime(time); + + if (!currentStyle) { + this.hideThumbnailHolder(); + return; + } + + const xPos = percent * width; + const thumbnailWidth = parseInt(currentStyle.width, 10); + const halfThumbnailWidth = thumbnailWidth >> 1; + const marginRight = width - (xPos + halfThumbnailWidth); + const marginLeft = xPos - halfThumbnailWidth; + + if (marginLeft > 0 && marginRight > 0) { + this.thumbnailHolder.style.transform = + "translateX(" + (xPos - halfThumbnailWidth) + "px)"; + } else if (marginLeft <= 0) { + this.thumbnailHolder.style.transform = "translateX(" + 0 + "px)"; + } else if (marginRight <= 0) { + this.thumbnailHolder.style.transform = + "translateX(" + (width - thumbnailWidth) + "px)"; + } + + if (this.lastStyle && this.lastStyle === currentStyle) { + return; + } + + this.lastStyle = currentStyle; + + Object.assign(this.thumbnailHolder.style, currentStyle); + } + + private processVtt(data: string) { + const processedVtts: IVTTData[] = []; + + const parser = new WebVTT.Parser(window, WebVTT.StringDecoder()); + parser.oncue = (cue: VTTCue) => { + processedVtts.push({ + start: cue.startTime, + end: cue.endTime, + style: this.getVttStyle(cue.text), + }); + }; + parser.parse(data); + parser.flush(); + + return processedVtts; + } + + private getFullyQualifiedUrl(path: string, base: string) { + if (path.indexOf("//") >= 0) { + // We have a fully qualified path. + return path; + } + + if (base.indexOf("//") === 0) { + // We don't have a fully qualified path, but need to + // be careful with trimming. + return [base.replace(/\/$/gi, ""), this.trim(path, "/")].join("/"); + } + + if (base.indexOf("//") > 0) { + // We don't have a fully qualified path, and should + // trim both sides of base and path. + return [this.trim(base, "/"), this.trim(path, "/")].join("/"); + } + + // If all else fails. + return path; + } + + private getPropsFromDef(def: string) { + const match = def.match(/^([^#]*)#xywh=(\d+),(\d+),(\d+),(\d+)$/i); + if (!match) return null; + + return { + image: match[1], + x: match[2], + y: match[3], + w: match[4], + h: match[5], + }; + } + + private getVttStyle(vttImageDef: string) { + // If there isn't a protocol, use the VTT source URL. + let baseSplit: string; + + if (this.source === null) { + baseSplit = this.getBaseUrl(); + } else if (this.source.indexOf("//") >= 0) { + baseSplit = this.source.split(/([^\/]*)$/gi)[0]; + } else { + baseSplit = this.getBaseUrl() + this.source.split(/([^\/]*)$/gi)[0]; + } + + vttImageDef = this.getFullyQualifiedUrl(vttImageDef, baseSplit); + + const imageProps = this.getPropsFromDef(vttImageDef); + if (!imageProps) return null; + + return { + background: + 'url("' + + imageProps.image + + '") no-repeat -' + + imageProps.x + + "px -" + + imageProps.y + + "px", + width: imageProps.w + "px", + height: imageProps.h + "px", + }; + } + + /** + * trim + * + * @param str source string + * @param charlist characters to trim from text + * @return trimmed string + */ + private trim(str: string, charlist: string) { + let whitespace = [ + " ", + "\n", + "\r", + "\t", + "\f", + "\x0b", + "\xa0", + "\u2000", + "\u2001", + "\u2002", + "\u2003", + "\u2004", + "\u2005", + "\u2006", + "\u2007", + "\u2008", + "\u2009", + "\u200a", + "\u200b", + "\u2028", + "\u2029", + "\u3000", + ].join(""); + let l = 0; + + str += ""; + if (charlist) { + whitespace = (charlist + "").replace(/([[\]().?/*{}+$^:])/g, "$1"); + } + + l = str.length; + for (let i = 0; i < l; i++) { + if (whitespace.indexOf(str.charAt(i)) === -1) { + str = str.substring(i); + break; + } + } + + l = str.length; + for (let i = l - 1; i >= 0; i--) { + if (whitespace.indexOf(str.charAt(i)) === -1) { + str = str.substring(0, i + 1); + break; + } + } + return whitespace.indexOf(str.charAt(0)) === -1 ? str : ""; + } +} + +// Register the plugin with video.js. +videojs.registerPlugin("vttThumbnails", VTTThumbnailsPlugin); + +/* eslint-disable @typescript-eslint/naming-convention */ +declare module "video.js" { + interface VideoJsPlayer { + vttThumbnails: () => VTTThumbnailsPlugin; + } + interface VideoJsPlayerPluginOptions { + vttThumbnails?: IVTTThumbnailsOptions; + } +} + +export default VTTThumbnailsPlugin; diff --git a/ui/v2.5/src/components/Scenes/EditScenesDialog.tsx b/ui/v2.5/src/components/Scenes/EditScenesDialog.tsx index 7880cece9..34947060f 100644 --- a/ui/v2.5/src/components/Scenes/EditScenesDialog.tsx +++ b/ui/v2.5/src/components/Scenes/EditScenesDialog.tsx @@ -8,7 +8,7 @@ import { StudioSelect, Modal } from "src/components/Shared"; import { useToast } from "src/hooks"; import { FormUtils } from "src/utils"; import MultiSet from "../Shared/MultiSet"; -import { RatingStars } from "./SceneDetails/RatingStars"; +import { RatingSystem } from "../Shared/Rating/RatingSystem"; import { getAggregateInputIDs, getAggregateInputValue, @@ -30,7 +30,7 @@ export const EditScenesDialog: React.FC = ( ) => { const intl = useIntl(); const Toast = useToast(); - const [rating, setRating] = useState(); + const [rating100, setRating] = useState(); const [studioId, setStudioId] = useState(); const [ performerMode, @@ -71,7 +71,7 @@ export const EditScenesDialog: React.FC = ( }), }; - sceneInput.rating = getAggregateInputValue(rating, aggregateRating); + sceneInput.rating100 = getAggregateInputValue(rating100, aggregateRating); sceneInput.studio_id = getAggregateInputValue(studioId, aggregateStudioId); sceneInput.performer_ids = getAggregateInputIDs( @@ -121,7 +121,7 @@ export const EditScenesDialog: React.FC = ( let first = true; state.forEach((scene: GQL.SlimSceneDataFragment) => { - const sceneRating = scene.rating; + const sceneRating = scene.rating100; const sceneStudioID = scene?.studio?.id; const scenePerformerIDs = (scene.performers ?? []) .map((p) => p.id) @@ -271,14 +271,13 @@ export const EditScenesDialog: React.FC = ( title: intl.formatMessage({ id: "rating" }), })} - setRating(value)} disabled={isUpdating} /> - {FormUtils.renderLabel({ title: intl.formatMessage({ id: "studio" }), diff --git a/ui/v2.5/src/components/Scenes/SceneCard.tsx b/ui/v2.5/src/components/Scenes/SceneCard.tsx index 8a08d1915..b7b79c0e7 100644 --- a/ui/v2.5/src/components/Scenes/SceneCard.tsx +++ b/ui/v2.5/src/components/Scenes/SceneCard.tsx @@ -360,6 +360,16 @@ export const SceneCard: React.FC = ( if (!props.compact && props.zoomIndex !== undefined) { return `zoom-${props.zoomIndex}`; } + + return ""; + } + + function filelessClass() { + if (!props.scene.files.length) { + return "fileless"; + } + + return ""; } const cont = configuration?.interface.continuePlaylistDefault ?? false; @@ -373,11 +383,13 @@ export const SceneCard: React.FC = ( return ( = ( isPortrait={isPortrait()} soundActive={configuration?.interface?.soundOnPreview ?? false} /> - + {maybeRenderSceneSpecsOverlay()} {maybeRenderInteractiveSpeedOverlay()} diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/RatingStars.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/RatingStars.tsx deleted file mode 100644 index 010c20c96..000000000 --- a/ui/v2.5/src/components/Scenes/SceneDetails/RatingStars.tsx +++ /dev/null @@ -1,119 +0,0 @@ -import React, { useState } from "react"; -import { Button } from "react-bootstrap"; -import Icon from "src/components/Shared/Icon"; -import { faStar as fasStar } from "@fortawesome/free-solid-svg-icons"; -import { faStar as farStar } from "@fortawesome/free-regular-svg-icons"; - -export interface IRatingStarsProps { - value?: number; - onSetRating?: (value?: number) => void; - disabled?: boolean; -} - -export const RatingStars: React.FC = ( - props: IRatingStarsProps -) => { - const [hoverRating, setHoverRating] = useState(); - const disabled = props.disabled || !props.onSetRating; - - function setRating(rating: number) { - if (!props.onSetRating) { - return; - } - - let newRating: number | undefined = rating; - - // unset if we're clicking on the current rating - if (props.value === rating) { - newRating = undefined; - } - - // set the hover rating to undefined so that it doesn't immediately clear - // the stars - setHoverRating(undefined); - - props.onSetRating(newRating); - } - - function getIcon(rating: number) { - if (hoverRating && hoverRating >= rating) { - if (hoverRating === props.value) { - return farStar; - } - - return fasStar; - } - - if (!hoverRating && props.value && props.value >= rating) { - return fasStar; - } - - return farStar; - } - - function onMouseOver(rating: number) { - if (!disabled) { - setHoverRating(rating); - } - } - - function onMouseOut(rating: number) { - if (!disabled && hoverRating === rating) { - setHoverRating(undefined); - } - } - - function getClassName(rating: number) { - if (hoverRating && hoverRating >= rating) { - if (hoverRating === props.value) { - return "unsetting"; - } - - return "setting"; - } - - if (props.value && props.value >= rating) { - return "set"; - } - - return "unset"; - } - - function getTooltip(rating: number) { - if (disabled && props.value) { - // always return current rating for disabled control - return props.value.toString(); - } - - if (!disabled) { - return rating.toString(); - } - } - - const renderRatingButton = (rating: number) => ( - - ); - - const maxRating = 5; - - return ( -
    - {Array.from(Array(maxRating)).map((value, index) => - renderRatingButton(index + 1) - )} -
    - ); -}; diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx index 2bab5fc95..1e7a8163f 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx @@ -1,6 +1,13 @@ import { Tab, Nav, Dropdown, Button, ButtonGroup } from "react-bootstrap"; import queryString from "query-string"; -import React, { useEffect, useState, useMemo, useContext, lazy } from "react"; +import React, { + useEffect, + useState, + useMemo, + useContext, + lazy, + useRef, +} from "react"; import { FormattedMessage, useIntl } from "react-intl"; import { useParams, useLocation, useHistory, Link } from "react-router-dom"; import { Helmet } from "react-helmet"; @@ -51,17 +58,18 @@ const DeleteScenesDialog = lazy(() => import("../DeleteScenesDialog")); const GenerateDialog = lazy(() => import("../../Dialogs/GenerateDialog")); const SceneVideoFilterPanel = lazy(() => import("./SceneVideoFilterPanel")); import { objectPath, objectTitle } from "src/core/files"; +import { Counter } from "src/components/Shared"; interface IProps { scene: GQL.SceneDataFragment; - refetch: () => void; setTimestamp: (num: number) => void; queueScenes: QueuedScene[]; onQueueNext: () => void; onQueuePrevious: () => void; onQueueRandom: () => void; + onDelete: () => void; continuePlaylist: boolean; - playScene: (sceneID: string, page?: number) => void; + loadScene: (sceneID: string) => void; queueHasMoreScenes: () => boolean; onQueueMoreScenes: () => void; onQueueLessScenes: () => void; @@ -73,14 +81,14 @@ interface IProps { const ScenePage: React.FC = ({ scene, - refetch, setTimestamp, queueScenes, onQueueNext, onQueuePrevious, onQueueRandom, + onDelete, continuePlaylist, - playScene, + loadScene, queueHasMoreScenes, onQueueMoreScenes, onQueueLessScenes, @@ -89,7 +97,6 @@ const ScenePage: React.FC = ({ setCollapsed, setContinuePlaylist, }) => { - const history = useHistory(); const Toast = useToast(); const intl = useIntl(); const [updateScene] = useSceneUpdate(); @@ -216,7 +223,7 @@ const ScenePage: React.FC = ({ function onDeleteDialogClosed(deleted: boolean) { setIsDeleteAlertOpen(false); if (deleted) { - history.push("/scenes"); + onDelete(); } } @@ -252,13 +259,15 @@ const ScenePage: React.FC = ({ - onRescan()} - > - - + {!!scene.files.length && ( + onRescan()} + > + + + )} = ({ + {scene.files.length > 1 && ( + + )} @@ -400,14 +412,14 @@ const ScenePage: React.FC = ({ currentID={scene.id} continue={continuePlaylist} setContinue={setContinuePlaylist} - onSceneClicked={(sceneID) => playScene(sceneID)} + onSceneClicked={loadScene} onNext={onQueueNext} onPrevious={onQueuePrevious} onRandom={onQueueRandom} start={queueStart} hasMoreScenes={queueHasMoreScenes()} - onLessScenes={() => onQueueLessScenes()} - onMoreScenes={() => onQueueMoreScenes()} + onLessScenes={onQueueLessScenes} + onMoreScenes={onQueueMoreScenes} /> @@ -441,7 +453,6 @@ const ScenePage: React.FC = ({ isVisible={activeTabKey === "scene-edit-panel"} scene={scene} onDelete={() => setIsDeleteAlertOpen(true)} - onUpdate={() => refetch()} /> @@ -468,7 +479,7 @@ const ScenePage: React.FC = ({ >
    {scene.studio && ( -

    +

    = ({ {renderTabs()}

    -
    @@ -507,60 +514,65 @@ const SceneLoader: React.FC = () => { const location = useLocation(); const history = useHistory(); const { configuration } = useContext(ConfigurationContext); - const { data, loading, refetch } = useFindScene(id ?? ""); - const [timestamp, setTimestamp] = useState(getInitialTimestamp()); - const [collapsed, setCollapsed] = useState(false); - const [continuePlaylist, setContinuePlaylist] = useState(false); - const [showScrubber, setShowScrubber] = useState( - configuration?.interface.showScrubber ?? true - ); + const { data, loading } = useFindScene(id ?? ""); - const sceneQueue = useMemo( - () => SceneQueue.fromQueryParameters(location.search), + const queryParams = useMemo( + () => queryString.parse(location.search, { decode: false }), [location.search] ); + const sceneQueue = useMemo( + () => SceneQueue.fromQueryParameters(queryParams), + [queryParams] + ); + const queryContinue = useMemo(() => { + let cont = queryParams.continue; + if (cont !== undefined) { + return cont === "true"; + } else { + return !!configuration?.interface.continuePlaylistDefault; + } + }, [configuration?.interface.continuePlaylistDefault, queryParams.continue]); + const [queueScenes, setQueueScenes] = useState([]); + const [collapsed, setCollapsed] = useState(false); + const [continuePlaylist, setContinuePlaylist] = useState(queryContinue); + const [hideScrubber, setHideScrubber] = useState( + !(configuration?.interface.showScrubber ?? true) + ); + + const _setTimestamp = useRef<(value: number) => void>(); + const initialTimestamp = useMemo(() => { + const t = Array.isArray(queryParams.t) ? queryParams.t[0] : queryParams.t; + return Number.parseInt(t ?? "0", 10); + }, [queryParams]); + const [queueTotal, setQueueTotal] = useState(0); const [queueStart, setQueueStart] = useState(1); - const queryParams = useMemo(() => queryString.parse(location.search), [ - location.search, - ]); - - function getInitialTimestamp() { - const params = queryString.parse(location.search); - const initialTimestamp = params?.t ?? "0"; - return Number.parseInt( - Array.isArray(initialTimestamp) ? initialTimestamp[0] : initialTimestamp, - 10 - ); - } - - const autoplay = queryParams?.autoplay === "true"; - const autoPlayOnSelected = - configuration?.interface.autostartVideoOnPlaySelected ?? false; + const autoplay = queryParams.autoplay === "true"; const currentQueueIndex = queueScenes ? queueScenes.findIndex((s) => s.id === id) : -1; - useEffect(() => { - setContinuePlaylist(queryParams?.continue === "true"); - }, [queryParams]); + function getSetTimestamp(fn: (value: number) => void) { + _setTimestamp.current = fn; + } + + function setTimestamp(value: number) { + if (_setTimestamp.current) { + _setTimestamp.current(value); + } + } // set up hotkeys useEffect(() => { - Mousetrap.bind(".", () => setShowScrubber(!showScrubber)); + Mousetrap.bind(".", () => setHideScrubber((value) => !value)); return () => { Mousetrap.unbind("."); }; - }); - - useEffect(() => { - // reset timestamp after notifying player - if (timestamp !== -1) setTimestamp(-1); - }, [timestamp]); + }, []); async function getQueueFilterScenes(filter: ListFilterModel) { const query = await queryFindScenes(filter); @@ -624,25 +636,41 @@ const SceneLoader: React.FC = () => { // don't change queue start } - function playScene(sceneID: string, newPage?: number) { - sceneQueue.playScene(history, sceneID, { + function loadScene(sceneID: string, autoPlay?: boolean, newPage?: number) { + const sceneLink = sceneQueue.makeLink(sceneID, { newPage, - autoPlay: autoPlayOnSelected, + autoPlay, continue: continuePlaylist, }); + history.replace(sceneLink); + } + + function onDelete() { + if ( + continuePlaylist && + queueScenes && + currentQueueIndex >= 0 && + currentQueueIndex < queueScenes.length - 1 + ) { + loadScene(queueScenes[currentQueueIndex + 1].id); + } else { + history.push("/scenes"); + } } function onQueueNext() { if (!queueScenes) return; + if (currentQueueIndex >= 0 && currentQueueIndex < queueScenes.length - 1) { - playScene(queueScenes[currentQueueIndex + 1].id); + loadScene(queueScenes[currentQueueIndex + 1].id); } } function onQueuePrevious() { if (!queueScenes) return; + if (currentQueueIndex > 0) { - playScene(queueScenes[currentQueueIndex - 1].id); + loadScene(queueScenes[currentQueueIndex - 1].id); } } @@ -660,28 +688,45 @@ const SceneLoader: React.FC = () => { filterCopy.currentPage = page; const queryResults = await queryFindScenes(filterCopy); if (queryResults.data.findScenes.scenes.length > index) { - const { id: sceneID } = queryResults!.data!.findScenes!.scenes[index]; + const { id: sceneID } = queryResults.data.findScenes.scenes[index]; // navigate to the image player page - playScene(sceneID, page); + loadScene(sceneID, undefined, page); } } else { const index = Math.floor(Math.random() * queueTotal); - playScene(queueScenes[index].id); + loadScene(queueScenes[index].id); } } function onComplete() { - // load the next scene if we're autoplaying + if (!queueScenes) return; + + // load the next scene if we're continuing if (continuePlaylist) { - onQueueNext(); + if ( + currentQueueIndex >= 0 && + currentQueueIndex < queueScenes.length - 1 + ) { + loadScene(queueScenes[currentQueueIndex + 1].id, true); + } } } - /* - if (error) return ; - if (!loading && !data?.findScene) - return ; - */ + function onNext() { + if (!queueScenes) return; + + if (currentQueueIndex >= 0 && currentQueueIndex < queueScenes.length - 1) { + loadScene(queueScenes[currentQueueIndex + 1].id, true); + } + } + + function onPrevious() { + if (!queueScenes) return; + + if (currentQueueIndex > 0) { + loadScene(queueScenes[currentQueueIndex - 1].id, true); + } + } const scene = data?.findScene; @@ -690,15 +735,15 @@ const SceneLoader: React.FC = () => { {!loading && scene ? ( { ) : (
    )} -
    +
    = 0 && currentQueueIndex < queueScenes.length - 1 - ? onQueueNext - : undefined - } - onPrevious={currentQueueIndex > 0 ? onQueuePrevious : undefined} + onNext={onNext} + onPrevious={onPrevious} />
    diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneCreate.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneCreate.tsx new file mode 100644 index 000000000..535e8f7d8 --- /dev/null +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneCreate.tsx @@ -0,0 +1,74 @@ +import React, { useEffect, useMemo, useState } from "react"; +import { FormattedMessage, useIntl } from "react-intl"; +import { SceneEditPanel } from "./SceneEditPanel"; +import queryString from "query-string"; +import { useFindScene } from "src/core/StashService"; +import { ImageUtils } from "src/utils"; +import { LoadingIndicator } from "src/components/Shared"; + +const SceneCreate: React.FC = () => { + const intl = useIntl(); + + // create scene from provided scene id if applicable + const queryParams = queryString.parse(location.search); + + const fromSceneID = (queryParams?.from_scene_id ?? "") as string; + const { data, loading } = useFindScene(fromSceneID ?? ""); + const [loadingCoverImage, setLoadingCoverImage] = useState(false); + const [coverImage, setCoverImage] = useState(undefined); + + const scene = useMemo(() => { + if (data?.findScene) { + return { + ...data.findScene, + paths: undefined, + id: undefined, + }; + } + + return {}; + }, [data?.findScene]); + + useEffect(() => { + async function fetchCoverImage() { + const srcScene = data?.findScene; + if (srcScene?.paths.screenshot) { + setLoadingCoverImage(true); + const imageData = await ImageUtils.imageToDataURL( + srcScene.paths.screenshot + ); + setCoverImage(imageData); + setLoadingCoverImage(false); + } else { + setCoverImage(undefined); + } + } + + fetchCoverImage(); + }, [data?.findScene]); + + if (loading || loadingCoverImage) { + return ; + } + + return ( +
    +
    +

    + +

    + +
    +
    + ); +}; + +export default SceneCreate; diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneDetailPanel.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneDetailPanel.tsx index 1b4a18f5c..a38d79ca8 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneDetailPanel.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneDetailPanel.tsx @@ -7,7 +7,7 @@ import { TagLink } from "src/components/Shared/TagLink"; import TruncatedText from "src/components/Shared/TruncatedText"; import { PerformerCard } from "src/components/Performers/PerformerCard"; import { sortPerformers } from "src/core/performers"; -import { RatingStars } from "./RatingStars"; +import { RatingSystem } from "src/components/Shared/Rating/RatingSystem"; import { objectTitle } from "src/core/files"; interface ISceneDetailProps { @@ -27,7 +27,7 @@ export const SceneDetailPanel: React.FC = (props) => { return ( <>
    - + :{" "}

    {props.scene.details}

    @@ -99,10 +99,10 @@ export const SceneDetailPanel: React.FC = (props) => { />

    ) : undefined} - {props.scene.rating ? ( + {props.scene.rating100 ? (
    :{" "} - +
    ) : ( "" @@ -121,6 +121,16 @@ export const SceneDetailPanel: React.FC = (props) => { :{" "} {TextUtils.formatDateTime(intl, props.scene.updated_at)}{" "} + {props.scene.code && ( +
    + : {props.scene.code}{" "} +
    + )} + {props.scene.director && ( +
    + : {props.scene.director}{" "} +
    + )}
    {props.scene.studio && (
    diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx index 33344223d..d7b348ddd 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx @@ -19,6 +19,7 @@ import { useSceneUpdate, mutateReloadScrapers, queryScrapeSceneQueryFragment, + mutateCreateScene, } from "src/core/StashService"; import { PerformerSelect, @@ -34,11 +35,12 @@ import useToast from "src/hooks/Toast"; import { ImageUtils, FormUtils, getStashIDs } from "src/utils"; import { MovieSelect } from "src/components/Shared/Select"; import { useFormik } from "formik"; -import { Prompt } from "react-router-dom"; +import { Prompt, useHistory } from "react-router-dom"; +import queryString from "query-string"; import { ConfigurationContext } from "src/hooks/Config"; import { stashboxDisplayName } from "src/utils/stashbox"; import { SceneMovieTable } from "./SceneMovieTable"; -import { RatingStars } from "./RatingStars"; +import { RatingSystem } from "src/components/Shared/Rating/RatingSystem"; import { faSearch, faSyncAlt, @@ -50,19 +52,28 @@ const SceneScrapeDialog = lazy(() => import("./SceneScrapeDialog")); const SceneQueryModal = lazy(() => import("./SceneQueryModal")); interface IProps { - scene: GQL.SceneDataFragment; + scene: Partial; + initialCoverImage?: string; + isNew?: boolean; isVisible: boolean; - onDelete: () => void; - onUpdate?: () => void; + onDelete?: () => void; } export const SceneEditPanel: React.FC = ({ scene, + initialCoverImage, + isNew = false, isVisible, onDelete, }) => { const intl = useIntl(); const Toast = useToast(); + const history = useHistory(); + + const queryParams = queryString.parse(location.search); + + const fileID = (queryParams?.file_id ?? "") as string; + const [galleries, setGalleries] = useState<{ id: string; title: string }[]>( [] ); @@ -84,15 +95,17 @@ export const SceneEditPanel: React.FC = ({ >(); useEffect(() => { - setCoverImagePreview(scene.paths.screenshot ?? undefined); - }, [scene.paths.screenshot]); + setCoverImagePreview( + initialCoverImage ?? scene.paths?.screenshot ?? undefined + ); + }, [scene.paths?.screenshot, initialCoverImage]); useEffect(() => { setGalleries( - scene.galleries.map((g) => ({ + scene.galleries?.map((g) => ({ id: g.id, title: objectTitle(g), - })) + })) ?? [] ); }, [scene.galleries]); @@ -105,10 +118,12 @@ export const SceneEditPanel: React.FC = ({ const schema = yup.object({ title: yup.string().optional().nullable(), + code: yup.string().optional().nullable(), details: yup.string().optional().nullable(), + director: yup.string().optional().nullable(), url: yup.string().optional().nullable(), date: yup.string().optional().nullable(), - rating: yup.number().optional().nullable(), + rating100: yup.number().optional().nullable(), gallery_ids: yup.array(yup.string().required()).optional().nullable(), studio_id: yup.string().optional().nullable(), performer_ids: yup.array(yup.string().required()).optional().nullable(), @@ -127,10 +142,12 @@ export const SceneEditPanel: React.FC = ({ const initialValues = useMemo( () => ({ title: scene.title ?? "", + code: scene.code ?? "", details: scene.details ?? "", + director: scene.director ?? "", url: scene.url ?? "", date: scene.date ?? "", - rating: scene.rating ?? null, + rating100: scene.rating100 ?? null, gallery_ids: (scene.galleries ?? []).map((g) => g.id), studio_id: scene.studio?.id, performer_ids: (scene.performers ?? []).map((p) => p.id), @@ -138,10 +155,10 @@ export const SceneEditPanel: React.FC = ({ return { movie_id: m.movie.id, scene_index: m.scene_index }; }), tag_ids: (scene.tags ?? []).map((t) => t.id), - cover_image: undefined, + cover_image: initialCoverImage, stash_ids: getStashIDs(scene.stash_ids), }), - [scene] + [scene, initialCoverImage] ); type InputValues = typeof initialValues; @@ -150,11 +167,11 @@ export const SceneEditPanel: React.FC = ({ initialValues, enableReinitialize: true, validationSchema: schema, - onSubmit: (values) => onSave(getSceneInput(values)), + onSubmit: (values) => onSave(values), }); function setRating(v: number) { - formik.setFieldValue("rating", v); + formik.setFieldValue("rating100", v); } interface IGallerySelectValue { @@ -176,7 +193,9 @@ export const SceneEditPanel: React.FC = ({ formik.handleSubmit(); }); Mousetrap.bind("d d", () => { - onDelete(); + if (onDelete) { + onDelete(); + } }); // numeric keypresses get caught by jwplayer, so blur the element @@ -187,11 +206,11 @@ export const SceneEditPanel: React.FC = ({ } Mousetrap.bind("0", () => setRating(NaN)); - Mousetrap.bind("1", () => setRating(1)); - Mousetrap.bind("2", () => setRating(2)); - Mousetrap.bind("3", () => setRating(3)); - Mousetrap.bind("4", () => setRating(4)); - Mousetrap.bind("5", () => setRating(5)); + Mousetrap.bind("1", () => setRating(20)); + Mousetrap.bind("2", () => setRating(40)); + Mousetrap.bind("3", () => setRating(60)); + Mousetrap.bind("4", () => setRating(80)); + Mousetrap.bind("5", () => setRating(100)); setTimeout(() => { Mousetrap.unbind("0"); @@ -230,7 +249,7 @@ export const SceneEditPanel: React.FC = ({ function getSceneInput(input: InputValues): GQL.SceneUpdateInput { return { - id: scene.id, + id: scene.id!, ...input, }; } @@ -252,27 +271,49 @@ export const SceneEditPanel: React.FC = ({ formik.setFieldValue("movies", newMovies); } - async function onSave(input: GQL.SceneUpdateInput) { + function getCreateValues(values: InputValues): GQL.SceneCreateInput { + return { + ...values, + }; + } + + async function onSave(input: InputValues) { setIsLoading(true); try { - const result = await updateScene({ - variables: { - input: { - ...input, - rating: input.rating ?? null, + if (!isNew) { + const updateValues = getSceneInput(input); + const result = await updateScene({ + variables: { + input: { + ...updateValues, + id: scene.id!, + rating100: input.rating100 ?? null, + }, }, - }, - }); - if (result.data?.sceneUpdate) { - Toast.success({ - content: intl.formatMessage( - { id: "toast.updated_entity" }, - { entity: intl.formatMessage({ id: "scene" }).toLocaleLowerCase() } - ), }); - // clear the cover image so that it doesn't appear dirty - formik.resetForm({ values: formik.values }); + if (result.data?.sceneUpdate) { + Toast.success({ + content: intl.formatMessage( + { id: "toast.updated_entity" }, + { + entity: intl.formatMessage({ id: "scene" }).toLocaleLowerCase(), + } + ), + }); + } + } else { + const createValues = getCreateValues(input); + const result = await mutateCreateScene({ + ...createValues, + file_ids: fileID ? [fileID as string] : undefined, + }); + if (result.data?.sceneCreate?.id) { + history.push(`/scenes/${result.data?.sceneCreate.id}`); + } } + + // clear the cover image so that it doesn't appear dirty + formik.resetForm({ values: formik.values }); } catch (e) { Toast.error(e); } @@ -312,7 +353,7 @@ export const SceneEditPanel: React.FC = ({ async function onScrapeClicked(s: GQL.ScraperSourceInput) { setIsLoading(true); try { - const result = await queryScrapeScene(s, scene.id); + const result = await queryScrapeScene(s, scene.id!); if (!result.data || !result.data.scrapeSingleScene?.length) { Toast.success({ content: "No scenes found", @@ -337,7 +378,9 @@ export const SceneEditPanel: React.FC = ({ try { const input: GQL.ScrapedSceneInput = { date: fragment.date, + code: fragment.code, details: fragment.details, + director: fragment.director, remote_site_id: fragment.remote_site_id, title: fragment.title, url: fragment.url, @@ -393,7 +436,7 @@ export const SceneEditPanel: React.FC = ({ const currentScene = getSceneInput(formik.values); if (!currentScene.cover_image) { - currentScene.cover_image = scene.paths.screenshot; + currentScene.cover_image = scene.paths!.screenshot; } return ( @@ -476,7 +519,7 @@ export const SceneEditPanel: React.FC = ({ setScraper(undefined); onSceneSelected(s); }} - name={formik.values.title || ""} + name={formik.values.title || objectTitle(scene) || ""} /> ); }; @@ -536,10 +579,18 @@ export const SceneEditPanel: React.FC = ({ formik.setFieldValue("title", updatedScene.title); } + if (updatedScene.code) { + formik.setFieldValue("code", updatedScene.code); + } + if (updatedScene.details) { formik.setFieldValue("details", updatedScene.details); } + if (updatedScene.director) { + formik.setFieldValue("director", updatedScene.director); + } + if (updatedScene.date) { formik.setFieldValue("date", updatedScene.date); } @@ -656,6 +707,24 @@ export const SceneEditPanel: React.FC = ({ ); } + const image = useMemo(() => { + if (imageEncoding) { + return ; + } + + if (coverImagePreview) { + return ( + {intl.formatMessage({ + ); + } + + return
    ; + }, [imageEncoding, coverImagePreview, intl]); + if (isLoading) return ; return ( @@ -673,29 +742,34 @@ export const SceneEditPanel: React.FC = ({ - -
    -
    - - {renderScraperMenu()} - {renderScrapeQueryMenu()} - + {onDelete && ( + + )}
    + {!isNew && ( +
    + + {renderScraperMenu()} + {renderScrapeQueryMenu()} + +
    + )}
    {renderTextField("title", intl.formatMessage({ id: "title" }))} + {renderTextField("code", intl.formatMessage({ id: "scene_code" }))} @@ -716,15 +790,19 @@ export const SceneEditPanel: React.FC = ({ intl.formatMessage({ id: "date" }), "YYYY-MM-DD" )} + {renderTextField( + "director", + intl.formatMessage({ id: "director" }) + )} {FormUtils.renderLabel({ title: intl.formatMessage({ id: "rating" }), })} - - formik.setFieldValue("rating", value ?? null) + formik.setFieldValue("rating100", value ?? null) } /> @@ -739,8 +817,9 @@ export const SceneEditPanel: React.FC = ({ })} onSetGalleries(items)} + isMulti /> @@ -899,15 +978,7 @@ export const SceneEditPanel: React.FC = ({ - {imageEncoding ? ( - - ) : ( - {intl.formatMessage({ - )} + {image} void; onDeleteFile?: () => void; + onReassign?: () => void; loading?: boolean; } @@ -22,6 +31,7 @@ const FileInfoPanel: React.FC = ( props: IFileInfoPanelProps ) => { const intl = useIntl(); + const history = useHistory(); function renderFileSize() { const { size, unit } = TextUtils.fileSize(props.file.size); @@ -47,6 +57,12 @@ const FileInfoPanel: React.FC = ( const phash = props.file.fingerprints.find((f) => f.type === "phash"); const checksum = props.file.fingerprints.find((f) => f.type === "md5"); + function onSplit() { + history.push( + `/scenes/new?from_scene_id=${props.sceneID}&file_id=${props.file.id}` + ); + } + return (
    @@ -76,6 +92,13 @@ const FileInfoPanel: React.FC = ( truncate /> {renderFileSize()} + + + = ( > + +
+ ); @@ -213,7 +249,9 @@ export const SceneFileInfoPanel: React.FC = ( } if (props.scene.files.length === 1) { - return ; + return ( + + ); } async function onSetPrimaryFile(fileID: string) { @@ -235,6 +273,12 @@ export const SceneFileInfoPanel: React.FC = ( selected={[deletingFile]} /> )} + {reassigningFile && ( + setReassigningFile(undefined)} + selected={reassigningFile} + /> + )} {props.scene.files.map((file, index) => ( @@ -243,11 +287,13 @@ export const SceneFileInfoPanel: React.FC = ( onSetPrimaryFile(file.id)} onDeleteFile={() => setDeletingFile(file)} + onReassign={() => setReassigningFile(file)} loading={loading} /> @@ -256,7 +302,7 @@ export const SceneFileInfoPanel: React.FC = ( ))} ); - }, [props.scene, loading, Toast, deletingFile]); + }, [props.scene, loading, Toast, deletingFile, reassigningFile]); return ( <> @@ -276,6 +322,16 @@ export const SceneFileInfoPanel: React.FC = ( truncate /> {renderStashIDs()} + + {filesPanel} diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneScrapeDialog.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneScrapeDialog.tsx index a3515940d..c268648cc 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneScrapeDialog.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneScrapeDialog.tsx @@ -1,4 +1,4 @@ -import React, { useState } from "react"; +import React, { useMemo, useState } from "react"; import { StudioSelect, PerformerSelect } from "src/components/Shared"; import * as GQL from "src/core/generated-graphql"; import { MovieSelect, TagSelect } from "src/components/Shared/Select"; @@ -9,6 +9,7 @@ import { ScrapedInputGroupRow, ScrapedTextAreaRow, ScrapedImageRow, + IHasName, } from "src/components/Shared/ScrapeDialog"; import clone from "lodash-es/clone"; import { @@ -22,35 +23,45 @@ import useToast from "src/hooks/Toast"; import DurationUtils from "src/utils/duration"; import { useIntl } from "react-intl"; -function renderScrapedStudio( - result: ScrapeResult, - isNew?: boolean, - onChange?: (value: string) => void -) { - const resultValue = isNew ? result.newValue : result.originalValue; - const value = resultValue ? [resultValue] : []; - - return ( - { - if (onChange) { - onChange(items[0]?.id); - } - }} - ids={value} - /> - ); +interface IScrapedStudioRow { + title: string; + result: ScrapeResult; + onChange: (value: ScrapeResult) => void; + newStudio?: GQL.ScrapedStudio; + onCreateNew?: (value: GQL.ScrapedStudio) => void; } -function renderScrapedStudioRow( - title: string, - result: ScrapeResult, - onChange: (value: ScrapeResult) => void, - newStudio?: GQL.ScrapedStudio, - onCreateNew?: (value: GQL.ScrapedStudio) => void -) { +export const ScrapedStudioRow: React.FC = ({ + title, + result, + onChange, + newStudio, + onCreateNew, +}) => { + function renderScrapedStudio( + scrapeResult: ScrapeResult, + isNew?: boolean, + onChangeFn?: (value: string) => void + ) { + const resultValue = isNew + ? scrapeResult.newValue + : scrapeResult.originalValue; + const value = resultValue ? [resultValue] : []; + + return ( + { + if (onChangeFn) { + onChangeFn(items[0]?.id); + } + }} + ids={value} + /> + ); + } + return ( ); +}; + +interface IScrapedObjectsRow { + title: string; + result: ScrapeResult; + onChange: (value: ScrapeResult) => void; + newObjects?: T[]; + onCreateNew?: (value: T) => void; + renderObjects: ( + result: ScrapeResult, + isNew?: boolean, + onChange?: (value: string[]) => void + ) => JSX.Element; } -function renderScrapedPerformers( - result: ScrapeResult, - isNew?: boolean, - onChange?: (value: string[]) => void -) { - const resultValue = isNew ? result.newValue : result.originalValue; - const value = resultValue ?? []; - - return ( - { - if (onChange) { - onChange(items.map((i) => i.id)); - } - }} - ids={value} - /> - ); -} - -function renderScrapedPerformersRow( - title: string, - result: ScrapeResult, - onChange: (value: ScrapeResult) => void, - newPerformers: GQL.ScrapedPerformer[], - onCreateNew?: (value: GQL.ScrapedPerformer) => void -) { - const performersCopy = newPerformers.map((p) => { - const name: string = p.name ?? ""; - return { ...p, name }; - }); +export const ScrapedObjectsRow = ( + props: IScrapedObjectsRow +) => { + const { + title, + result, + onChange, + newObjects, + onCreateNew, + renderObjects, + } = props; return ( renderScrapedPerformers(result)} + renderOriginalField={() => renderObjects(result)} renderNewField={() => - renderScrapedPerformers(result, true, (value) => + renderObjects(result, true, (value) => onChange(result.cloneWithValue(value)) ) } onChange={onChange} - newValues={performersCopy} + newValues={newObjects} onCreateNew={(i) => { - if (onCreateNew) onCreateNew(newPerformers[i]); + if (onCreateNew) onCreateNew(newObjects![i]); }} /> ); -} +}; -function renderScrapedMovies( - result: ScrapeResult, - isNew?: boolean, - onChange?: (value: string[]) => void -) { - const resultValue = isNew ? result.newValue : result.originalValue; - const value = resultValue ?? []; +type IScrapedObjectRowImpl = Omit, "renderObjects">; + +export const ScrapedPerformersRow: React.FC< + IScrapedObjectRowImpl +> = ({ title, result, onChange, newObjects, onCreateNew }) => { + const performersCopy = useMemo(() => { + return ( + newObjects?.map((p) => { + const name: string = p.name ?? ""; + return { ...p, name }; + }) ?? [] + ); + }, [newObjects]); + + type PerformerType = GQL.ScrapedPerformer & { + name: string; + }; + + function renderScrapedPerformers( + scrapeResult: ScrapeResult, + isNew?: boolean, + onChangeFn?: (value: string[]) => void + ) { + const resultValue = isNew + ? scrapeResult.newValue + : scrapeResult.originalValue; + const value = resultValue ?? []; + + return ( + { + if (onChangeFn) { + onChangeFn(items.map((i) => i.id)); + } + }} + ids={value} + /> + ); + } return ( - { - if (onChange) { - onChange(items.map((i) => i.id)); - } - }} - ids={value} - /> - ); -} - -function renderScrapedMoviesRow( - title: string, - result: ScrapeResult, - onChange: (value: ScrapeResult) => void, - newMovies: GQL.ScrapedMovie[], - onCreateNew?: (value: GQL.ScrapedMovie) => void -) { - const moviesCopy = newMovies.map((p) => { - const name: string = p.name ?? ""; - return { ...p, name }; - }); - - return ( - title={title} result={result} - renderOriginalField={() => renderScrapedMovies(result)} - renderNewField={() => - renderScrapedMovies(result, true, (value) => - onChange(result.cloneWithValue(value)) - ) - } + renderObjects={renderScrapedPerformers} onChange={onChange} - newValues={moviesCopy} - onCreateNew={(i) => { - if (onCreateNew) onCreateNew(newMovies[i]); - }} + newObjects={performersCopy} + onCreateNew={onCreateNew} /> ); -} +}; -function renderScrapedTags( - result: ScrapeResult, - isNew?: boolean, - onChange?: (value: string[]) => void -) { - const resultValue = isNew ? result.newValue : result.originalValue; - const value = resultValue ?? []; +export const ScrapedMoviesRow: React.FC< + IScrapedObjectRowImpl +> = ({ title, result, onChange, newObjects, onCreateNew }) => { + const moviesCopy = useMemo(() => { + return ( + newObjects?.map((p) => { + const name: string = p.name ?? ""; + return { ...p, name }; + }) ?? [] + ); + }, [newObjects]); + + type MovieType = GQL.ScrapedMovie & { + name: string; + }; + + function renderScrapedMovies( + scrapeResult: ScrapeResult, + isNew?: boolean, + onChangeFn?: (value: string[]) => void + ) { + const resultValue = isNew + ? scrapeResult.newValue + : scrapeResult.originalValue; + const value = resultValue ?? []; + + return ( + { + if (onChangeFn) { + onChangeFn(items.map((i) => i.id)); + } + }} + ids={value} + /> + ); + } return ( - { - if (onChange) { - onChange(items.map((i) => i.id)); - } - }} - ids={value} - /> - ); -} - -function renderScrapedTagsRow( - title: string, - result: ScrapeResult, - onChange: (value: ScrapeResult) => void, - newTags: GQL.ScrapedTag[], - onCreateNew?: (value: GQL.ScrapedTag) => void -) { - return ( - title={title} result={result} - renderOriginalField={() => renderScrapedTags(result)} - renderNewField={() => - renderScrapedTags(result, true, (value) => - onChange(result.cloneWithValue(value)) - ) - } - newValues={newTags} + renderObjects={renderScrapedMovies} onChange={onChange} - onCreateNew={(i) => { - if (onCreateNew) onCreateNew(newTags[i]); - }} + newObjects={moviesCopy} + onCreateNew={onCreateNew} /> ); -} +}; + +export const ScrapedTagsRow: React.FC< + IScrapedObjectRowImpl +> = ({ title, result, onChange, newObjects, onCreateNew }) => { + function renderScrapedTags( + scrapeResult: ScrapeResult, + isNew?: boolean, + onChangeFn?: (value: string[]) => void + ) { + const resultValue = isNew + ? scrapeResult.newValue + : scrapeResult.originalValue; + const value = resultValue ?? []; + + return ( + { + if (onChangeFn) { + onChangeFn(items.map((i) => i.id)); + } + }} + ids={value} + /> + ); + } + + return ( + + title={title} + result={result} + renderObjects={renderScrapedTags} + onChange={onChange} + newObjects={newObjects} + onCreateNew={onCreateNew} + /> + ); +}; interface ISceneScrapeDialogProps { scene: Partial; @@ -248,12 +294,18 @@ export const SceneScrapeDialog: React.FC = ({ const [title, setTitle] = useState>( new ScrapeResult(scene.title, scraped.title) ); + const [code, setCode] = useState>( + new ScrapeResult(scene.code, scraped.code) + ); const [url, setURL] = useState>( new ScrapeResult(scene.url, scraped.url) ); const [date, setDate] = useState>( new ScrapeResult(scene.date, scraped.date) ); + const [director, setDirector] = useState>( + new ScrapeResult(scene.director, scraped.director) + ); const [studio, setStudio] = useState>( new ScrapeResult(scene.studio_id, scraped.studio?.stored_id) ); @@ -339,6 +391,7 @@ export const SceneScrapeDialog: React.FC = ({ const [details, setDetails] = useState>( new ScrapeResult(scene.details, scraped.details) ); + const [image, setImage] = useState>( new ScrapeResult(scene.cover_image, scraped.image) ); @@ -355,8 +408,10 @@ export const SceneScrapeDialog: React.FC = ({ if ( [ title, + code, url, date, + director, studio, performers, movies, @@ -521,8 +576,10 @@ export const SceneScrapeDialog: React.FC = ({ return { title: title.getNewValue(), + code: code.getNewValue(), url: url.getNewValue(), date: date.getNewValue(), + director: director.getNewValue(), studio: newStudioValue ? { stored_id: newStudioValue, @@ -561,6 +618,11 @@ export const SceneScrapeDialog: React.FC = ({ result={title} onChange={(value) => setTitle(value)} /> + setCode(value)} + /> = ({ result={date} onChange={(value) => setDate(value)} /> - {renderScrapedStudioRow( - intl.formatMessage({ id: "studios" }), - studio, - (value) => setStudio(value), - newStudio, - createNewStudio - )} - {renderScrapedPerformersRow( - intl.formatMessage({ id: "performers" }), - performers, - (value) => setPerformers(value), - newPerformers, - createNewPerformer - )} - {renderScrapedMoviesRow( - intl.formatMessage({ id: "movies" }), - movies, - (value) => setMovies(value), - newMovies, - createNewMovie - )} - {renderScrapedTagsRow( - intl.formatMessage({ id: "tags" }), - tags, - (value) => setTags(value), - newTags, - createNewTag - )} + setDirector(value)} + /> + setStudio(value)} + newStudio={newStudio} + onCreateNew={createNewStudio} + /> + setPerformers(value)} + newObjects={newPerformers} + onCreateNew={createNewPerformer} + /> + setMovies(value)} + newObjects={newMovies} + onCreateNew={createNewMovie} + /> + setTags(value)} + newObjects={newTags} + onCreateNew={createNewTag} + /> ListFilterModel; @@ -41,6 +43,9 @@ export const SceneList: React.FC = ({ const history = useHistory(); const config = React.useContext(ConfigurationContext); const [isGenerateDialogOpen, setIsGenerateDialogOpen] = useState(false); + const [mergeScenes, setMergeScenes] = useState< + { id: string; title: string }[] | undefined + >(undefined); const [isIdentifyDialogOpen, setIsIdentifyDialogOpen] = useState(false); const [isExportDialogOpen, setIsExportDialogOpen] = useState(false); const [isExportAll, setIsExportAll] = useState(false); @@ -66,6 +71,11 @@ export const SceneList: React.FC = ({ onClick: identify, isDisplayed: showWhenSelected, }, + { + text: `${intl.formatMessage({ id: "actions.merge" })}…`, + onClick: merge, + isDisplayed: showWhenSelected, + }, { text: intl.formatMessage({ id: "actions.export" }), onClick: onExport, @@ -108,6 +118,14 @@ export const SceneList: React.FC = ({ persistState, }); + function playScene( + queue: SceneQueue, + sceneID: string, + options: IPlaySceneOptions + ) { + history.push(queue.makeLink(sceneID, options)); + } + async function playSelected( result: FindScenesQueryResult, filter: ListFilterModel, @@ -118,9 +136,7 @@ export const SceneList: React.FC = ({ const queue = SceneQueue.fromSceneIDList(sceneIDs); const autoPlay = config.configuration?.interface.autostartVideoOnPlaySelected ?? false; - const cont = - config.configuration?.interface.continuePlaylistDefault ?? false; - queue.playScene(history, sceneIDs[0], { autoPlay, continue: cont }); + playScene(queue, sceneIDs[0], { autoPlay }); } async function playRandom( @@ -142,18 +158,12 @@ export const SceneList: React.FC = ({ filterCopy.sortBy = "random"; const queryResults = await queryFindScenes(filterCopy); if (queryResults.data.findScenes.scenes.length > index) { - const { id } = queryResults!.data!.findScenes!.scenes[index]; + const { id } = queryResults.data.findScenes.scenes[index]; // navigate to the image player page const queue = SceneQueue.fromListFilterModel(filterCopy); const autoPlay = config.configuration?.interface.autostartVideoOnPlaySelected ?? false; - const cont = - config.configuration?.interface.continuePlaylistDefault ?? false; - queue.playScene(history, id, { - sceneIndex: index, - autoPlay, - continue: cont, - }); + playScene(queue, id, { sceneIndex: index, autoPlay }); } } } @@ -166,6 +176,24 @@ export const SceneList: React.FC = ({ setIsIdentifyDialogOpen(true); } + async function merge( + result: FindScenesQueryResult, + filter: ListFilterModel, + selectedIds: Set + ) { + const selected = + result.data?.findScenes.scenes + .filter((s) => selectedIds.has(s.id)) + .map((s) => { + return { + id: s.id, + title: objectTitle(s), + }; + }) ?? []; + + setMergeScenes(selected); + } + async function onExport() { setIsExportAll(false); setIsExportDialogOpen(true); @@ -237,6 +265,23 @@ export const SceneList: React.FC = ({ ); } + function renderMergeDialog() { + if (mergeScenes) { + return ( + { + setMergeScenes(undefined); + if (mergedID) { + history.push(`/scenes/${mergedID}`); + } + }} + show + /> + ); + } + } + function renderScenes( result: FindScenesQueryResult, filter: ListFilterModel, @@ -293,6 +338,7 @@ export const SceneList: React.FC = ({ {maybeRenderSceneGenerateDialog(selectedIds)} {maybeRenderSceneIdentifyDialog(selectedIds)} {maybeRenderSceneExportDialog(selectedIds)} + {renderMergeDialog()} {renderScenes(result, filter, selectedIds)} ); diff --git a/ui/v2.5/src/components/Scenes/SceneListTable.tsx b/ui/v2.5/src/components/Scenes/SceneListTable.tsx index 51e005b58..7bf546257 100644 --- a/ui/v2.5/src/components/Scenes/SceneListTable.tsx +++ b/ui/v2.5/src/components/Scenes/SceneListTable.tsx @@ -90,7 +90,7 @@ export const SceneListTable: React.FC = (
{title}
- {scene.rating ? scene.rating : ""} + {scene.rating100 ? scene.rating100 : ""} {file?.duration && TextUtils.secondsToTimestamp(file.duration)} {renderTags(scene.tags)} {renderPerformers(scene.performers)} diff --git a/ui/v2.5/src/components/Scenes/SceneMergeDialog.tsx b/ui/v2.5/src/components/Scenes/SceneMergeDialog.tsx new file mode 100644 index 000000000..dfbeacb74 --- /dev/null +++ b/ui/v2.5/src/components/Scenes/SceneMergeDialog.tsx @@ -0,0 +1,731 @@ +import { Form, Col, Row, Button, FormControl } from "react-bootstrap"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import * as GQL from "src/core/generated-graphql"; +import { + GallerySelect, + Icon, + LoadingIndicator, + Modal, + SceneSelect, + StringListSelect, +} from "src/components/Shared"; +import { FormUtils, ImageUtils, TextUtils } from "src/utils"; +import { mutateSceneMerge, queryFindScenesByID } from "src/core/StashService"; +import { FormattedMessage, useIntl } from "react-intl"; +import { useToast } from "src/hooks"; +import { faExchangeAlt, faSignInAlt } from "@fortawesome/free-solid-svg-icons"; +import { + hasScrapedValues, + ScrapeDialog, + ScrapeDialogRow, + ScrapedImageRow, + ScrapedInputGroupRow, + ScrapedTextAreaRow, + ScrapeResult, +} from "../Shared/ScrapeDialog"; +import { clone, uniq } from "lodash-es"; +import { + ScrapedMoviesRow, + ScrapedPerformersRow, + ScrapedStudioRow, + ScrapedTagsRow, +} from "./SceneDetails/SceneScrapeDialog"; +import { galleryTitle } from "src/core/galleries"; +import { RatingSystem } from "src/components/Shared/Rating/RatingSystem"; + +interface IStashIDsField { + values: GQL.StashId[]; +} + +const StashIDsField: React.FC = ({ values }) => { + return v.stash_id)} />; +}; + +interface ISceneMergeDetailsProps { + sources: GQL.SlimSceneDataFragment[]; + dest: GQL.SlimSceneDataFragment; + onClose: (values?: GQL.SceneUpdateInput) => void; +} + +const SceneMergeDetails: React.FC = ({ + sources, + dest, + onClose, +}) => { + const intl = useIntl(); + + const [loading, setLoading] = useState(true); + + const [title, setTitle] = useState>( + new ScrapeResult(dest.title) + ); + const [url, setURL] = useState>( + new ScrapeResult(dest.url) + ); + const [date, setDate] = useState>( + new ScrapeResult(dest.date) + ); + + const [rating, setRating] = useState( + new ScrapeResult(dest.rating100) + ); + const [oCounter, setOCounter] = useState( + new ScrapeResult(dest.o_counter) + ); + const [playCount, setPlayCount] = useState( + new ScrapeResult(dest.play_count) + ); + const [playDuration, setPlayDuration] = useState( + new ScrapeResult(dest.play_duration) + ); + + const [studio, setStudio] = useState>( + new ScrapeResult(dest.studio?.id) + ); + + function sortIdList(idList?: string[] | null) { + if (!idList) { + return; + } + + const ret = clone(idList); + // sort by id numerically + ret.sort((a, b) => { + return parseInt(a, 10) - parseInt(b, 10); + }); + + return ret; + } + + const [performers, setPerformers] = useState>( + new ScrapeResult(sortIdList(dest.performers.map((p) => p.id))) + ); + + const [movies, setMovies] = useState>( + new ScrapeResult(sortIdList(dest.movies.map((p) => p.movie.id))) + ); + + const [tags, setTags] = useState>( + new ScrapeResult(sortIdList(dest.tags.map((t) => t.id))) + ); + + const [details, setDetails] = useState>( + new ScrapeResult(dest.details) + ); + + const [galleries, setGalleries] = useState>( + new ScrapeResult(sortIdList(dest.galleries.map((p) => p.id))) + ); + + const [stashIDs, setStashIDs] = useState(new ScrapeResult([])); + + const [image, setImage] = useState>( + new ScrapeResult(dest.paths.screenshot) + ); + + // calculate the values for everything + // uses the first set value for single value fields, and combines all + useEffect(() => { + async function loadImages() { + const src = sources.find((s) => s.paths.screenshot); + if (!dest.paths.screenshot || !src) return; + + setLoading(true); + + const destData = await ImageUtils.imageToDataURL(dest.paths.screenshot); + const srcData = await ImageUtils.imageToDataURL(src.paths!.screenshot!); + + // keep destination image by default + const useNewValue = false; + setImage(new ScrapeResult(destData, srcData, useNewValue)); + + setLoading(false); + } + + // append dest to all so that if dest has stash_ids with the same + // endpoint, then it will be excluded first + const all = sources.concat(dest); + + setTitle( + new ScrapeResult( + dest.title, + sources.find((s) => s.title)?.title, + !dest.title + ) + ); + setURL( + new ScrapeResult(dest.url, sources.find((s) => s.url)?.url, !dest.url) + ); + setDate( + new ScrapeResult(dest.date, sources.find((s) => s.date)?.date, !dest.date) + ); + setStudio( + new ScrapeResult( + dest.studio?.id, + sources.find((s) => s.studio)?.studio?.id, + !dest.studio + ) + ); + + setPerformers( + new ScrapeResult( + dest.performers.map((p) => p.id), + uniq(all.map((s) => s.performers.map((p) => p.id)).flat()) + ) + ); + setTags( + new ScrapeResult( + dest.tags.map((p) => p.id), + uniq(all.map((s) => s.tags.map((p) => p.id)).flat()) + ) + ); + setDetails( + new ScrapeResult( + dest.details, + sources.find((s) => s.details)?.details, + !dest.details + ) + ); + + setMovies( + new ScrapeResult( + dest.movies.map((m) => m.movie.id), + uniq(all.map((s) => s.movies.map((m) => m.movie.id)).flat()) + ) + ); + + setGalleries( + new ScrapeResult( + dest.galleries.map((p) => p.id), + uniq(all.map((s) => s.galleries.map((p) => p.id)).flat()) + ) + ); + + setRating( + new ScrapeResult( + dest.rating100, + sources.find((s) => s.rating100)?.rating100, + !dest.rating100 + ) + ); + + setOCounter( + new ScrapeResult( + dest.o_counter ?? 0, + all.map((s) => s.o_counter ?? 0).reduce((pv, cv) => pv + cv, 0) + ) + ); + + setPlayCount( + new ScrapeResult( + dest.play_count ?? 0, + all.map((s) => s.play_count ?? 0).reduce((pv, cv) => pv + cv, 0) + ) + ); + + setPlayDuration( + new ScrapeResult( + dest.play_duration ?? 0, + all.map((s) => s.play_duration ?? 0).reduce((pv, cv) => pv + cv, 0) + ) + ); + + setStashIDs( + new ScrapeResult( + dest.stash_ids, + all + .map((s) => s.stash_ids) + .flat() + .filter((s, index, a) => { + // remove entries with duplicate endpoints + return index === a.findIndex((ss) => ss.endpoint === s.endpoint); + }), + !dest.stash_ids.length + ) + ); + + loadImages(); + }, [sources, dest]); + + const convertGalleries = useCallback( + (ids?: string[]) => { + const all = [dest, ...sources]; + return ids + ?.map((g) => + all + .map((s) => s.galleries) + .flat() + .find((gg) => g === gg.id) + ) + .map((g) => { + return { + id: g!.id, + title: galleryTitle(g!), + }; + }); + }, + [dest, sources] + ); + + const originalGalleries = useMemo(() => { + return convertGalleries(galleries.originalValue); + }, [galleries, convertGalleries]); + + const newGalleries = useMemo(() => { + return convertGalleries(galleries.newValue); + }, [galleries, convertGalleries]); + + // ensure this is updated if fields are changed + const hasValues = useMemo(() => { + return hasScrapedValues([ + title, + url, + date, + rating, + oCounter, + galleries, + studio, + performers, + movies, + tags, + details, + stashIDs, + image, + ]); + }, [ + title, + url, + date, + rating, + oCounter, + galleries, + studio, + performers, + movies, + tags, + details, + stashIDs, + image, + ]); + + function renderScrapeRows() { + if (loading) { + return ( +
+ +
+ ); + } + + if (!hasValues) { + return ( +
+ +
+ ); + } + + return ( + <> + setTitle(value)} + /> + setURL(value)} + /> + setDate(value)} + /> + ( + + )} + renderNewField={() => ( + + )} + onChange={(value) => setRating(value)} + /> + ( + {}} + className="bg-secondary text-white border-secondary" + /> + )} + renderNewField={() => ( + {}} + className="bg-secondary text-white border-secondary" + /> + )} + onChange={(value) => setOCounter(value)} + /> + ( + {}} + className="bg-secondary text-white border-secondary" + /> + )} + renderNewField={() => ( + {}} + className="bg-secondary text-white border-secondary" + /> + )} + onChange={(value) => setPlayCount(value)} + /> + ( + {}} + className="bg-secondary text-white border-secondary" + /> + )} + renderNewField={() => ( + {}} + className="bg-secondary text-white border-secondary" + /> + )} + onChange={(value) => setPlayDuration(value)} + /> + ( + {}} + disabled + /> + )} + renderNewField={() => ( + {}} + disabled + /> + )} + onChange={(value) => setGalleries(value)} + /> + setStudio(value)} + /> + setPerformers(value)} + /> + setMovies(value)} + /> + setTags(value)} + /> + setDetails(value)} + /> + ( + + )} + renderNewField={() => ( + + )} + onChange={(value) => setStashIDs(value)} + /> + setImage(value)} + /> + + ); + } + + function createValues(): GQL.SceneUpdateInput { + const all = [dest, ...sources]; + + // only set the cover image if it's different from the existing cover image + const coverImage = image.useNewValue ? image.getNewValue() : undefined; + + return { + id: dest.id, + title: title.getNewValue(), + url: url.getNewValue(), + date: date.getNewValue(), + rating100: rating.getNewValue(), + o_counter: oCounter.getNewValue(), + play_count: playCount.getNewValue(), + play_duration: playDuration.getNewValue(), + gallery_ids: galleries.getNewValue(), + studio_id: studio.getNewValue(), + performer_ids: performers.getNewValue(), + movies: movies.getNewValue()?.map((m) => { + // find the equivalent movie in the original scenes + const found = all + .map((s) => s.movies) + .flat() + .find((mm) => mm.movie.id === m); + return { + movie_id: m, + scene_index: found!.scene_index, + }; + }), + tag_ids: tags.getNewValue(), + details: details.getNewValue(), + stash_ids: stashIDs.getNewValue(), + cover_image: coverImage, + }; + } + + const dialogTitle = intl.formatMessage({ + id: "actions.merge", + }); + + const destinationLabel = !hasValues + ? "" + : intl.formatMessage({ id: "dialogs.merge.destination" }); + const sourceLabel = !hasValues + ? "" + : intl.formatMessage({ id: "dialogs.merge.source" }); + + return ( + { + if (!apply) { + onClose(); + } else { + onClose(createValues()); + } + }} + /> + ); +}; + +interface ISceneMergeModalProps { + show: boolean; + onClose: (mergedID?: string) => void; + scenes: { id: string; title: string }[]; +} + +export const SceneMergeModal: React.FC = ({ + show, + onClose, + scenes, +}) => { + const [sourceScenes, setSourceScenes] = useState< + { id: string; title: string }[] + >([]); + const [destScene, setDestScene] = useState<{ id: string; title: string }[]>( + [] + ); + + const [loadedSources, setLoadedSources] = useState< + GQL.SlimSceneDataFragment[] + >([]); + const [loadedDest, setLoadedDest] = useState(); + + const [running, setRunning] = useState(false); + const [secondStep, setSecondStep] = useState(false); + + const intl = useIntl(); + const Toast = useToast(); + + const title = intl.formatMessage({ + id: "actions.merge", + }); + + useEffect(() => { + if (scenes.length > 0) { + // set the first scene as the destination, others as source + setDestScene([scenes[0]]); + + if (scenes.length > 1) { + setSourceScenes(scenes.slice(1)); + } + } + }, [scenes]); + + async function loadScenes() { + const sceneIDs = sourceScenes.map((s) => parseInt(s.id)); + sceneIDs.push(parseInt(destScene[0].id)); + const query = await queryFindScenesByID(sceneIDs); + const { scenes: loadedScenes } = query.data.findScenes; + + setLoadedDest(loadedScenes.find((s) => s.id === destScene[0].id)); + setLoadedSources(loadedScenes.filter((s) => s.id !== destScene[0].id)); + setSecondStep(true); + } + + async function onMerge(values: GQL.SceneUpdateInput) { + try { + setRunning(true); + const result = await mutateSceneMerge( + destScene[0].id, + sourceScenes.map((s) => s.id), + values + ); + if (result.data?.sceneMerge) { + Toast.success({ + content: intl.formatMessage({ id: "toast.merged_scenes" }), + }); + // refetch the scene + await queryFindScenesByID([parseInt(destScene[0].id)]); + onClose(destScene[0].id); + } + onClose(); + } catch (e) { + Toast.error(e); + } finally { + setRunning(false); + } + } + + function canMerge() { + return sourceScenes.length > 0 && destScene.length !== 0; + } + + function switchScenes() { + if (sourceScenes.length && destScene.length) { + const newDest = sourceScenes[0]; + setSourceScenes([...sourceScenes.slice(1), destScene[0]]); + setDestScene([newDest]); + } + } + + if (secondStep && destScene.length > 0) { + return ( + { + if (values) { + onMerge(values); + } else { + onClose(); + } + }} + /> + ); + } + + return ( + loadScenes(), + }} + disabled={!canMerge()} + cancel={{ + variant: "secondary", + onClick: () => onClose(), + }} + isRunning={running} + > +
+
+ + {FormUtils.renderLabel({ + title: intl.formatMessage({ id: "dialogs.merge.source" }), + labelProps: { + column: true, + sm: 3, + xl: 12, + }, + })} + + setSourceScenes(items)} + selected={sourceScenes} + /> + + + + + + + {FormUtils.renderLabel({ + title: intl.formatMessage({ + id: "dialogs.merge.destination", + }), + labelProps: { + column: true, + sm: 3, + xl: 12, + }, + })} + + setDestScene(items)} + selected={destScene} + /> + + +
+
+
+ ); +}; diff --git a/ui/v2.5/src/components/Scenes/Scenes.tsx b/ui/v2.5/src/components/Scenes/Scenes.tsx index ea2a7befe..97a9ea5c6 100644 --- a/ui/v2.5/src/components/Scenes/Scenes.tsx +++ b/ui/v2.5/src/components/Scenes/Scenes.tsx @@ -8,6 +8,7 @@ import { PersistanceLevel } from "src/hooks/ListHook"; const SceneList = lazy(() => import("./SceneList")); const SceneMarkerList = lazy(() => import("./SceneMarkerList")); const Scene = lazy(() => import("./SceneDetails/Scene")); +const SceneCreate = lazy(() => import("./SceneDetails/SceneCreate")); const Scenes: React.FC = () => { const intl = useIntl(); @@ -30,6 +31,7 @@ const Scenes: React.FC = () => { )} /> + diff --git a/ui/v2.5/src/components/Scenes/styles.scss b/ui/v2.5/src/components/Scenes/styles.scss index 6c95f60c3..20d073024 100644 --- a/ui/v2.5/src/components/Scenes/styles.scss +++ b/ui/v2.5/src/components/Scenes/styles.scss @@ -240,6 +240,10 @@ textarea.scene-description { .scene-card.card { overflow: hidden; padding: 0; + + &.fileless { + background-color: darken($card-bg, 5%); + } } .scene-cover { @@ -475,37 +479,6 @@ input[type="range"].blue-slider { } } -.rating-stars { - display: inline-block; - - button { - font-size: inherit; - margin-right: 1px; - padding: 0; - - &:hover { - background-color: inherit; - } - - &:disabled { - background-color: inherit; - opacity: inherit; - } - } - - .unsetting { - color: gold; - } - - .setting { - color: gold; - } - - .set { - color: gold; - } -} - #scene-edit-details { .rating-stars { font-size: 1.3em; @@ -658,3 +631,7 @@ input[type="range"].blue-slider { padding: 0.5rem; } } + +.scrape-dialog .rating-number.disabled { + padding-left: 0.5em; +} diff --git a/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx b/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx index 05e92458c..8e5fb1286 100644 --- a/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx +++ b/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx @@ -1,7 +1,11 @@ import React from "react"; import { Button, Form } from "react-bootstrap"; import { FormattedMessage, useIntl } from "react-intl"; -import { DurationInput, LoadingIndicator } from "src/components/Shared"; +import { + DurationInput, + PercentInput, + LoadingIndicator, +} from "src/components/Shared"; import { CheckboxGroup } from "./CheckboxGroup"; import { SettingSection } from "../SettingSection"; import { @@ -24,6 +28,15 @@ import { connectionStateLabel, InteractiveContext, } from "src/hooks/Interactive/context"; +import { + defaultRatingStarPrecision, + defaultRatingSystemOptions, + defaultRatingSystemType, + RatingStarPrecision, + ratingStarPrecisionIntlMap, + ratingSystemIntlMap, + RatingSystemType, +} from "src/utils/rating"; const allMenuItems = [ { id: "scenes", headingID: "scenes" }, @@ -80,6 +93,24 @@ export const SettingsInterfacePanel: React.FC = () => { }); } + function saveRatingSystemType(t: RatingSystemType) { + saveUI({ + ratingSystemOptions: { + ...ui.ratingSystemOptions, + type: t, + }, + }); + } + + function saveRatingSystemStarPrecision(p: RatingStarPrecision) { + saveUI({ + ratingSystemOptions: { + ...(ui.ratingSystemOptions ?? defaultRatingSystemOptions), + starPrecision: p, + }, + }); + } + if (error) return

{error.message}

; if (loading) return ; @@ -93,23 +124,31 @@ export const SettingsInterfacePanel: React.FC = () => { value={iface.language ?? undefined} onChange={(v) => saveInterface({ language: v })} > + + - + + + - + + + + + @@ -215,6 +254,41 @@ export const SettingsInterfacePanel: React.FC = () => { checked={iface.showScrubber ?? undefined} onChange={(v) => saveInterface({ showScrubber: v })} /> + saveUI({ alwaysStartFromBeginning: v })} + /> + saveUI({ trackActivity: v })} + /> + + id="ignore-interval" + headingID="config.ui.minimum_play_percent.heading" + subHeadingID="config.ui.minimum_play_percent.description" + value={ui.minimumPlayPercent ?? 0} + onChange={(v) => saveUI({ minimumPlayPercent: v })} + disabled={!ui.trackActivity} + renderField={(value, setValue) => ( + setValue(interval ?? 0)} + /> + )} + renderValue={(v) => { + return {v}%; + }} + /> + saveLightboxSettings({ slideshowDelay: v })} + /> { } /> + saveRatingSystemType(v as RatingSystemType)} + > + {Array.from(ratingSystemIntlMap.entries()).map((v) => ( + + ))} + + {(ui.ratingSystemOptions?.type ?? defaultRatingSystemType) === + RatingSystemType.Stars && ( + + saveRatingSystemStarPrecision(v as RatingStarPrecision) + } + > + {Array.from(ratingStarPrecisionIntlMap.entries()).map((v) => ( + + ))} + + )} @@ -446,6 +556,36 @@ export const SettingsInterfacePanel: React.FC = () => { }} /> + + saveInterface({ javascriptEnabled: v })} + /> + + + id="custom-javascript" + headingID="config.ui.custom_javascript.heading" + subHeadingID="config.ui.custom_javascript.description" + value={iface.javascript ?? undefined} + onChange={(v) => saveInterface({ javascript: v })} + renderField={(value, setValue) => ( + ) => + setValue(e.currentTarget.value) + } + rows={16} + className="text-input code" + /> + )} + renderValue={() => { + return <>; + }} + /> + [ - ...newEntries.reverse(), - ...existingEntries, -]; - export const SettingsLogsPanel: React.FC = () => { + const [entries, setEntries] = useState([]); const { data, error } = useLoggingSubscribe(); - const { data: existingData } = useLogs(); - const [currentData, dispatchLogUpdate] = useReducer(logReducer, []); const [logLevel, setLogLevel] = useState("Info"); const intl = useIntl(); useEffect(() => { - const newData = (data?.loggingSubscribe ?? []).map((e) => new LogEntry(e)); - dispatchLogUpdate(newData); + async function getInitialLogs() { + const logQuery = await queryLogs(); + if (logQuery.error) return; + + const initEntries = logQuery.data.logs.map((e) => new LogEntry(e)); + if (initEntries.length !== 0) { + setEntries((prev) => { + return [...prev, ...initEntries].slice(0, MAX_LOG_ENTRIES); + }); + } + } + + getInitialLogs(); + }, []); + + useEffect(() => { + if (!data) return; + + const newEntries = data.loggingSubscribe.map((e) => new LogEntry(e)); + newEntries.reverse(); + setEntries((prev) => { + return [...newEntries, ...prev].slice(0, MAX_LOG_ENTRIES); + }); }, [data]); - const oldData = (existingData?.logs ?? []).map((e) => new LogEntry(e)); - const filteredLogEntries = [...currentData, ...oldData] + const displayEntries = entries .filter(filterByLogLevel) - .slice(0, MAX_LOG_ENTRIES); + .slice(0, MAX_DISPLAY_LOG_ENTRIES); - const maybeRenderError = error ? ( -
Error connecting to log server: {error.message}
- ) : ( - "" - ); + function maybeRenderError() { + if (error) { + return ( +
+ Error connecting to log server: {error.message} +
+ ); + } + } function filterByLogLevel(logEntry: LogEntry) { if (logLevel === "Trace") return true; @@ -127,8 +147,8 @@ export const SettingsLogsPanel: React.FC = () => {
- {maybeRenderError} - {filteredLogEntries.map((logEntry) => ( + {maybeRenderError()} + {displayEntries.map((logEntry) => ( ))}
diff --git a/ui/v2.5/src/components/Settings/SettingsSecurityPanel.tsx b/ui/v2.5/src/components/Settings/SettingsSecurityPanel.tsx index 0af6813a9..02d1fae1d 100644 --- a/ui/v2.5/src/components/Settings/SettingsSecurityPanel.tsx +++ b/ui/v2.5/src/components/Settings/SettingsSecurityPanel.tsx @@ -71,9 +71,14 @@ export const SettingsSecurityPanel: React.FC = () => { const intl = useIntl(); const Toast = useToast(); - const { general, apiKey, loading, error, saveGeneral } = React.useContext( - SettingStateContext - ); + const { + general, + apiKey, + loading, + error, + saveGeneral, + refetch, + } = React.useContext(SettingStateContext); const [generateAPIKey] = useGenerateAPIKey(); @@ -84,6 +89,7 @@ export const SettingsSecurityPanel: React.FC = () => { input: {}, }, }); + refetch(); } catch (e) { Toast.error(e); } @@ -98,6 +104,7 @@ export const SettingsSecurityPanel: React.FC = () => { }, }, }); + refetch(); } catch (e) { Toast.error(e); } diff --git a/ui/v2.5/src/components/Settings/context.tsx b/ui/v2.5/src/components/Settings/context.tsx index d0aeafefc..9df77a1ef 100644 --- a/ui/v2.5/src/components/Settings/context.tsx +++ b/ui/v2.5/src/components/Settings/context.tsx @@ -46,6 +46,8 @@ export interface ISettingsContextState { saveScraping: (input: Partial) => void; saveDLNA: (input: Partial) => void; saveUI: (input: Partial) => void; + + refetch: () => void; } export const SettingStateContext = React.createContext({ @@ -64,12 +66,13 @@ export const SettingStateContext = React.createContext({ saveScraping: () => {}, saveDLNA: () => {}, saveUI: () => {}, + refetch: () => {}, }); export const SettingsContext: React.FC = ({ children }) => { const Toast = useToast(); - const { data, error, loading } = useConfiguration(); + const { data, error, loading, refetch } = useConfiguration(); const initialRef = useRef(false); const [general, setGeneral] = useState({}); @@ -125,9 +128,14 @@ export const SettingsContext: React.FC = ({ children }) => { }, [saveError, Toast]); useEffect(() => { + if (!data?.configuration || error) return; + + // always set api key + setApiKey(data.configuration.general.apiKey); + // only initialise once - assume we have control over these settings and // they aren't modified elsewhere - if (!data?.configuration || error || initialRef.current) return; + if (initialRef.current) return; initialRef.current = true; setGeneral({ ...withoutTypename(data.configuration.general) }); @@ -136,7 +144,6 @@ export const SettingsContext: React.FC = ({ children }) => { setScraping({ ...withoutTypename(data.configuration.scraping) }); setDLNA({ ...withoutTypename(data.configuration.dlna) }); setUI(data.configuration.ui); - setApiKey(data.configuration.general.apiKey); }, [data, error]); const resetSuccess = useMemo( @@ -509,6 +516,7 @@ export const SettingsContext: React.FC = ({ children }) => { saveScraping, saveDLNA, saveUI, + refetch, }} > {maybeRenderLoadingIndicator()} diff --git a/ui/v2.5/src/components/Setup/Migrate.tsx b/ui/v2.5/src/components/Setup/Migrate.tsx index c72625a48..57ac073c8 100644 --- a/ui/v2.5/src/components/Setup/Migrate.tsx +++ b/ui/v2.5/src/components/Setup/Migrate.tsx @@ -16,6 +16,12 @@ export const Migrate: React.FC = () => { const intl = useIntl(); + // if database path includes path separators, then this is passed through + // to the migration path. Extract the base name of the database file. + const databasePath = systemStatus + ? systemStatus?.systemStatus.databasePath?.split(/[\\/]/).pop() + : ""; + // make suffix based on current time const now = new Date() .toISOString() @@ -24,7 +30,7 @@ export const Migrate: React.FC = () => { .replace(/:/g, "") .replace(/\..*/, ""); const defaultBackupPath = systemStatus - ? `${systemStatus.systemStatus.databasePath}.${systemStatus.systemStatus.databaseSchema}.${now}` + ? `${databasePath}.${systemStatus.systemStatus.databaseSchema}.${now}` : ""; const discordLink = ( diff --git a/ui/v2.5/src/components/Shared/CountryFlag.tsx b/ui/v2.5/src/components/Shared/CountryFlag.tsx index 32b1f666f..3d73c280e 100644 --- a/ui/v2.5/src/components/Shared/CountryFlag.tsx +++ b/ui/v2.5/src/components/Shared/CountryFlag.tsx @@ -1,21 +1,28 @@ import React from "react"; -import { getISOCountry } from "src/utils"; +import { useIntl } from "react-intl"; +import { getCountryByISO } from "src/utils"; interface ICountryFlag { country?: string | null; className?: string; } -const CountryFlag: React.FC = ({ className, country }) => { - const ISOCountry = getISOCountry(country); - if (!ISOCountry?.code) return <>; +const CountryFlag: React.FC = ({ + className, + country: isoCountry, +}) => { + const { locale } = useIntl(); + + const country = getCountryByISO(isoCountry, locale); + + if (!isoCountry || !country) return <>; return ( ); }; diff --git a/ui/v2.5/src/components/Shared/CountryLabel.tsx b/ui/v2.5/src/components/Shared/CountryLabel.tsx new file mode 100644 index 000000000..82c83bfc4 --- /dev/null +++ b/ui/v2.5/src/components/Shared/CountryLabel.tsx @@ -0,0 +1,26 @@ +import React from "react"; +import { useIntl } from "react-intl"; +import { CountryFlag } from "src/components/Shared"; +import { getCountryByISO } from "src/utils"; + +interface IProps { + country: string | undefined; + showFlag?: boolean; +} + +const CountryLabel: React.FC = ({ country, showFlag = true }) => { + const { locale } = useIntl(); + + // #3063 - use alpha2 values only + const fromISO = + country?.length === 2 ? getCountryByISO(country, locale) : undefined; + + return ( +
+ {showFlag && } + {fromISO ?? country} +
+ ); +}; + +export default CountryLabel; diff --git a/ui/v2.5/src/components/Shared/CountrySelect.tsx b/ui/v2.5/src/components/Shared/CountrySelect.tsx new file mode 100644 index 000000000..e354279fd --- /dev/null +++ b/ui/v2.5/src/components/Shared/CountrySelect.tsx @@ -0,0 +1,51 @@ +import React from "react"; +import Creatable from "react-select/creatable"; +import { useIntl } from "react-intl"; +import { getCountries } from "src/utils"; +import CountryLabel from "./CountryLabel"; + +interface IProps { + value?: string | undefined; + onChange?: (value: string) => void; + disabled?: boolean; + className?: string; + showFlag?: boolean; + isClearable?: boolean; +} + +const CountrySelect: React.FC = ({ + value, + onChange, + disabled = false, + isClearable = true, + showFlag, + className, +}) => { + const { locale } = useIntl(); + const options = getCountries(locale); + const selected = options.find((opt) => opt.value === value) ?? { + label: value, + value, + }; + + return ( + ( + + )} + placeholder="Country" + options={options} + onChange={(selectedOption) => onChange?.(selectedOption?.value ?? "")} + isDisabled={disabled || !onChange} + components={{ + IndicatorSeparator: null, + }} + className={`CountrySelect ${className}`} + /> + ); +}; + +export default CountrySelect; diff --git a/ui/v2.5/src/components/Shared/GridCard.tsx b/ui/v2.5/src/components/Shared/GridCard.tsx index f14693e74..f7ec2ee81 100644 --- a/ui/v2.5/src/components/Shared/GridCard.tsx +++ b/ui/v2.5/src/components/Shared/GridCard.tsx @@ -18,6 +18,8 @@ interface ICardProps { selecting?: boolean; selected?: boolean; onSelectedChanged?: (selected: boolean, shiftKey: boolean) => void; + resumeTime?: number; + duration?: number; interactiveHeatmap?: string; } @@ -91,6 +93,22 @@ export const GridCard: React.FC = (props: ICardProps) => { } } + function maybeRenderProgressBar() { + if ( + props.resumeTime && + props.duration && + props.duration > props.resumeTime + ) { + const percentValue = (100 / props.duration) * props.resumeTime; + const percentStr = percentValue + "%"; + return ( +
+
+
+ ); + } + } + return ( = (props: ICardProps) => { {props.image} {props.overlays} + {maybeRenderProgressBar()}
{maybeRenderInteractiveHeatmap()}
diff --git a/ui/v2.5/src/components/Shared/PercentInput.tsx b/ui/v2.5/src/components/Shared/PercentInput.tsx new file mode 100644 index 000000000..783ead755 --- /dev/null +++ b/ui/v2.5/src/components/Shared/PercentInput.tsx @@ -0,0 +1,140 @@ +import { + faChevronDown, + faChevronUp, + faClock, +} from "@fortawesome/free-solid-svg-icons"; +import React, { useState, useEffect } from "react"; +import { Button, ButtonGroup, InputGroup, Form } from "react-bootstrap"; +import Icon from "src/components/Shared/Icon"; +import { PercentUtils } from "src/utils"; + +interface IProps { + disabled?: boolean; + numericValue: number | undefined; + mandatory?: boolean; + onValueChange( + valueAsNumber: number | undefined, + valueAsString?: string + ): void; + onReset?(): void; + className?: string; + placeholder?: string; +} + +export const PercentInput: React.FC = (props: IProps) => { + const [value, setValue] = useState( + props.numericValue !== undefined + ? PercentUtils.numberToString(props.numericValue) + : undefined + ); + + useEffect(() => { + if (props.numericValue !== undefined || props.mandatory) { + setValue(PercentUtils.numberToString(props.numericValue ?? 0)); + } else { + setValue(undefined); + } + }, [props.numericValue, props.mandatory]); + + function increment() { + if (value === undefined) { + return; + } + + let percent = PercentUtils.stringToNumber(value); + if (percent >= 100) { + percent = 0; + } else { + percent += 1; + } + props.onValueChange(percent, PercentUtils.numberToString(percent)); + } + + function decrement() { + if (value === undefined) { + return; + } + + let percent = PercentUtils.stringToNumber(value); + if (percent <= 0) { + percent = 100; + } else { + percent -= 1; + } + props.onValueChange(percent, PercentUtils.numberToString(percent)); + } + + function renderButtons() { + if (!props.disabled) { + return ( + + + + + ); + } + } + + function onReset() { + if (props.onReset) { + props.onReset(); + } + } + + function maybeRenderReset() { + if (props.onReset) { + return ( + + ); + } + } + + return ( +
+ + ) => + setValue(e.currentTarget.value) + } + onBlur={() => { + if (props.mandatory || (value !== undefined && value !== "")) { + props.onValueChange(PercentUtils.stringToNumber(value), value); + } else { + props.onValueChange(undefined); + } + }} + placeholder={ + !props.disabled + ? props.placeholder + ? `${props.placeholder} (%)` + : "%" + : undefined + } + /> + + {maybeRenderReset()} + {renderButtons()} + + +
+ ); +}; diff --git a/ui/v2.5/src/components/Shared/Rating/RatingNumber.tsx b/ui/v2.5/src/components/Shared/Rating/RatingNumber.tsx new file mode 100644 index 000000000..afb98dccd --- /dev/null +++ b/ui/v2.5/src/components/Shared/Rating/RatingNumber.tsx @@ -0,0 +1,118 @@ +import React, { useRef } from "react"; + +export interface IRatingNumberProps { + value?: number; + onSetRating?: (value?: number) => void; + disabled?: boolean; +} + +export const RatingNumber: React.FC = ( + props: IRatingNumberProps +) => { + const text = ((props.value ?? 0) / 10).toFixed(1); + const useValidation = useRef(true); + + function stepChange() { + useValidation.current = false; + } + + function nonStepChange() { + useValidation.current = true; + } + + function setCursorPosition( + target: HTMLInputElement, + pos: number, + endPos?: number + ) { + // This is a workaround to a missing feature where you can't set cursor position in input numbers. + // See https://stackoverflow.com/questions/33406169/failed-to-execute-setselectionrange-on-htmlinputelement-the-input-elements + target.type = "text"; + + target.setSelectionRange(pos, endPos ?? pos); + target.type = "number"; + } + + function handleChange(e: React.ChangeEvent) { + if (!props.onSetRating) { + return; + } + + let val = e.target.value; + if (!useValidation.current) { + e.target.value = Number(val).toFixed(1); + const tempVal = Number(val) * 10; + props.onSetRating(tempVal != 0 ? tempVal : undefined); + useValidation.current = true; + return; + } + + const match = /(\d?)(\d?)(.?)((\d)?)/g.exec(val); + const matchOld = /(\d?)(\d?)(.?)((\d{0,2})?)/g.exec(text ?? ""); + + if (match == null || props.onSetRating == null) { + return; + } + + if (match[2] && !(match[2] == "0" && match[1] == "1")) { + match[2] = ""; + } + if (match[4] == null || match[4] == "") { + match[4] = "0"; + } + + let value = match[1] + match[2] + "." + match[4]; + e.target.value = value; + + if (val.length > 0) { + if (Number(value) > 10) { + value = "10.0"; + } + e.target.value = Number(value).toFixed(1); + let tempVal = Number(value) * 10; + props.onSetRating(tempVal != 0 ? tempVal : undefined); + + let cursorPosition = 0; + if (match[2] && !match[4]) { + cursorPosition = 3; + } else if (matchOld != null && match[1] !== matchOld[1]) { + cursorPosition = 2; + } else if ( + matchOld != null && + match[1] === matchOld[1] && + match[2] === matchOld[2] && + match[4] === matchOld[4] + ) { + cursorPosition = 2; + } + + setCursorPosition(e.target, cursorPosition); + } + } + + if (props.disabled) { + return ( +
+ {Number((props.value ?? 0) / 10).toFixed(1)} +
+ ); + } else { + return ( +
+ +
+ ); + } +}; diff --git a/ui/v2.5/src/components/Shared/Rating/RatingStars.tsx b/ui/v2.5/src/components/Shared/Rating/RatingStars.tsx new file mode 100644 index 000000000..e2801ee80 --- /dev/null +++ b/ui/v2.5/src/components/Shared/Rating/RatingStars.tsx @@ -0,0 +1,233 @@ +import React, { useState } from "react"; +import { Button } from "react-bootstrap"; +import Icon from "src/components/Shared/Icon"; +import { faStar as fasStar } from "@fortawesome/free-solid-svg-icons"; +import { faStar as farStar } from "@fortawesome/free-regular-svg-icons"; +import { + convertFromRatingFormat, + convertToRatingFormat, + getRatingPrecision, + RatingStarPrecision, + RatingSystemType, +} from "src/utils/rating"; +import { useIntl } from "react-intl"; + +export interface IRatingStarsProps { + value?: number; + onSetRating?: (value?: number) => void; + disabled?: boolean; + precision: RatingStarPrecision; + valueRequired?: boolean; +} + +export const RatingStars: React.FC = ( + props: IRatingStarsProps +) => { + const intl = useIntl(); + const [hoverRating, setHoverRating] = useState(); + const disabled = props.disabled || !props.onSetRating; + + const rating = convertToRatingFormat(props.value, { + type: RatingSystemType.Stars, + starPrecision: props.precision, + }); + const stars = rating ? Math.floor(rating) : 0; + const fraction = rating ? rating % 1 : 0; + + const max = 5; + const precision = getRatingPrecision(props.precision); + + function newToggleFraction() { + if (precision !== 1) { + if (fraction !== precision) { + if (fraction == 0) { + return 1 - precision; + } + + return fraction - precision; + } + } + } + + function setRating(thisStar: number) { + if (!props.onSetRating) { + return; + } + + let newRating: number | undefined = thisStar; + + // toggle rating fraction if we're clicking on the current rating + if ( + (stars === thisStar && !fraction) || + (stars + 1 === thisStar && fraction) + ) { + const f = newToggleFraction(); + if (!f) { + if (props.valueRequired) { + if (fraction) { + newRating = stars + 1; + } else { + newRating = stars; + } + } else { + newRating = undefined; + } + } else if (fraction) { + // we're toggling from an existing fraction so use the stars value + newRating = stars + f; + } else { + // we're toggling from a whole value, so decrement from current rating + newRating = stars - 1 + f; + } + } + + // set the hover rating to undefined so that it doesn't immediately clear + // the stars + setHoverRating(undefined); + + if (!newRating) { + props.onSetRating(undefined); + return; + } + + props.onSetRating( + convertFromRatingFormat(newRating, RatingSystemType.Stars) + ); + } + + function onMouseOver(thisStar: number) { + if (!disabled) { + setHoverRating(thisStar); + } + } + + function onMouseOut(thisStar: number) { + if (!disabled && hoverRating === thisStar) { + setHoverRating(undefined); + } + } + + function getClassName(thisStar: number) { + if (hoverRating && hoverRating >= thisStar) { + if (hoverRating === stars) { + return "unsetting"; + } + + return "setting"; + } + + if (stars && stars >= thisStar) { + return "set"; + } + + return "unset"; + } + + function getTooltip(thisStar: number, current: RatingFraction | undefined) { + if (disabled) { + if (rating) { + // always return current rating for disabled control + return rating.toString(); + } + + return undefined; + } + + // adjust tooltip to use fractions + if (!current) { + return intl.formatMessage({ id: "actions.unset" }); + } + + return (current.rating + current.fraction).toString(); + } + + type RatingFraction = { + rating: number; + fraction: number; + }; + + function getCurrentSelectedRating(): RatingFraction | undefined { + let r: number = hoverRating ? hoverRating : stars; + let f: number | undefined = fraction; + + if (hoverRating) { + if (hoverRating === stars && precision === 1) { + if (props.valueRequired) { + return { rating: r, fraction: 0 }; + } + + // unsetting + return undefined; + } + if (hoverRating === stars + 1 && fraction && fraction === precision) { + if (props.valueRequired) { + return { rating: r, fraction: 0 }; + } + // unsetting + return undefined; + } + + if (f && hoverRating === stars + 1) { + f = newToggleFraction(); + r--; + } else if (!f && hoverRating === stars) { + f = newToggleFraction(); + r--; + } else { + f = 0; + } + } + + return { rating: r, fraction: f ?? 0 }; + } + + function getButtonClassName( + thisStar: number, + current: RatingFraction | undefined + ) { + if (!current || thisStar > current.rating + 1) { + return "star-fill-0"; + } + + if (thisStar <= current.rating) { + return "star-fill-100"; + } + + let w = current.fraction * 100; + return `star-fill-${w}`; + } + + const renderRatingButton = (thisStar: number) => { + const ratingFraction = getCurrentSelectedRating(); + + return ( + + ); + }; + + return ( +
+ {Array.from(Array(max)).map((value, index) => + renderRatingButton(index + 1) + )} +
+ ); +}; diff --git a/ui/v2.5/src/components/Shared/Rating/RatingSystem.tsx b/ui/v2.5/src/components/Shared/Rating/RatingSystem.tsx new file mode 100644 index 000000000..61e7c21dc --- /dev/null +++ b/ui/v2.5/src/components/Shared/Rating/RatingSystem.tsx @@ -0,0 +1,52 @@ +import React from "react"; +import { IUIConfig } from "src/core/config"; +import { ConfigurationContext } from "src/hooks/Config"; +import { + defaultRatingStarPrecision, + defaultRatingSystemOptions, + RatingSystemType, +} from "src/utils/rating"; +import { RatingNumber } from "./RatingNumber"; +import { RatingStars } from "./RatingStars"; + +export interface IRatingSystemProps { + value?: number; + onSetRating?: (value?: number) => void; + disabled?: boolean; + valueRequired?: boolean; +} + +export const RatingSystem: React.FC = ( + props: IRatingSystemProps +) => { + const { configuration: config } = React.useContext(ConfigurationContext); + const ratingSystemOptions = + (config?.ui as IUIConfig)?.ratingSystemOptions ?? + defaultRatingSystemOptions; + + function getRatingStars() { + return ( + + ); + } + + if (ratingSystemOptions.type === RatingSystemType.Stars) { + return getRatingStars(); + } else { + return ( + + ); + } +}; diff --git a/ui/v2.5/src/components/Shared/Rating/styles.scss b/ui/v2.5/src/components/Shared/Rating/styles.scss new file mode 100644 index 000000000..2784943bb --- /dev/null +++ b/ui/v2.5/src/components/Shared/Rating/styles.scss @@ -0,0 +1,62 @@ +.rating-stars { + display: inline-flex; + vertical-align: middle; + + button { + font-size: inherit; + margin-right: 1px; + padding: 0; + position: relative; + + &:hover { + background-color: inherit; + } + + &:disabled { + background-color: inherit; + opacity: inherit; + } + + &.star-fill-0 .filled-star { + width: 0; + } + + &.star-fill-25 .filled-star { + width: 35%; + } + + &.star-fill-50 .filled-star { + width: 50%; + } + + &.star-fill-75 .filled-star { + width: 65%; + } + + &.star-fill-100 .filled-star { + width: 100%; + } + + .filled-star { + overflow: hidden; + position: absolute; + } + } + + .unsetting { + color: gold; + } + + .setting { + color: gold; + } + + .set { + color: gold; + } +} + +.rating-number.disabled { + align-items: center; + display: inline-flex; +} diff --git a/ui/v2.5/src/components/Shared/RatingBanner.tsx b/ui/v2.5/src/components/Shared/RatingBanner.tsx index 0a573ed98..f2f5cde44 100644 --- a/ui/v2.5/src/components/Shared/RatingBanner.tsx +++ b/ui/v2.5/src/components/Shared/RatingBanner.tsx @@ -1,15 +1,43 @@ -import React from "react"; +import React, { useContext } from "react"; import { FormattedMessage } from "react-intl"; +import { + convertToRatingFormat, + defaultRatingSystemOptions, + RatingStarPrecision, + RatingSystemType, +} from "src/utils/rating"; +import { ConfigurationContext } from "src/hooks/Config"; +import { IUIConfig } from "src/core/config"; interface IProps { rating?: number | null; } -export const RatingBanner: React.FC = ({ rating }) => - rating ? ( -
- : {rating} +export const RatingBanner: React.FC = ({ rating }) => { + const { configuration: config } = useContext(ConfigurationContext); + const ratingSystemOptions = + (config?.ui as IUIConfig)?.ratingSystemOptions ?? + defaultRatingSystemOptions; + const isLegacy = + ratingSystemOptions.type === RatingSystemType.Stars && + ratingSystemOptions.starPrecision === RatingStarPrecision.Full; + + const convertedRating = convertToRatingFormat( + rating ?? undefined, + ratingSystemOptions + ); + + return rating ? ( +
+ : {convertedRating}
) : ( <> ); +}; diff --git a/ui/v2.5/src/components/Shared/RatingStars.tsx b/ui/v2.5/src/components/Shared/RatingStars.tsx deleted file mode 100644 index 3b26fc96e..000000000 --- a/ui/v2.5/src/components/Shared/RatingStars.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { faStar as fasStar } from "@fortawesome/free-solid-svg-icons"; -import { faStar as farStar } from "@fortawesome/free-regular-svg-icons"; -import React from "react"; -import Icon from "./Icon"; - -const CLASSNAME = "RatingStars"; -const CLASSNAME_FILLED = `${CLASSNAME}-filled`; -const CLASSNAME_UNFILLED = `${CLASSNAME}-unfilled`; - -interface IProps { - rating?: number | null; -} - -export const RatingStars: React.FC = ({ rating }) => - rating ? ( -
- - = 2 ? fasStar : farStar} - className={rating >= 2 ? CLASSNAME_FILLED : CLASSNAME_UNFILLED} - /> - = 3 ? fasStar : farStar} - className={rating >= 3 ? CLASSNAME_FILLED : CLASSNAME_UNFILLED} - /> - = 4 ? fasStar : farStar} - className={rating >= 4 ? CLASSNAME_FILLED : CLASSNAME_UNFILLED} - /> - -
- ) : ( - <> - ); diff --git a/ui/v2.5/src/components/Shared/ReassignFilesDialog.tsx b/ui/v2.5/src/components/Shared/ReassignFilesDialog.tsx new file mode 100644 index 000000000..8a15359b2 --- /dev/null +++ b/ui/v2.5/src/components/Shared/ReassignFilesDialog.tsx @@ -0,0 +1,101 @@ +import React, { useState } from "react"; +import { Modal, SceneSelect } from "src/components/Shared"; +import { useToast } from "src/hooks"; +import { useIntl } from "react-intl"; +import { faSignOutAlt } from "@fortawesome/free-solid-svg-icons"; +import { Col, Form, Row } from "react-bootstrap"; +import { FormUtils } from "src/utils"; +import { mutateSceneAssignFile } from "src/core/StashService"; + +interface IFile { + id: string; + path: string; +} + +interface IReassignFilesDialogProps { + selected: IFile; + onClose: () => void; +} + +export const ReassignFilesDialog: React.FC = ( + props: IReassignFilesDialogProps +) => { + const [scenes, setScenes] = useState<{ id: string; title: string }[]>([]); + + const intl = useIntl(); + const singularEntity = intl.formatMessage({ id: "file" }); + const pluralEntity = intl.formatMessage({ id: "files" }); + + const header = intl.formatMessage( + { id: "dialogs.reassign_entity_title" }, + { count: 1, singularEntity, pluralEntity } + ); + + const toastMessage = intl.formatMessage( + { id: "toast.reassign_past_tense" }, + { count: 1, singularEntity, pluralEntity } + ); + + const Toast = useToast(); + + // Network state + const [reassigning, setReassigning] = useState(false); + + async function onAccept() { + if (!scenes.length) { + return; + } + + setReassigning(true); + try { + await mutateSceneAssignFile(scenes[0].id, props.selected.id); + Toast.success({ content: toastMessage }); + props.onClose(); + } catch (e) { + Toast.error(e); + props.onClose(); + } + setReassigning(false); + } + + return ( + props.onClose(), + text: intl.formatMessage({ id: "actions.cancel" }), + variant: "secondary", + }} + isRunning={reassigning} + > +
+ + {FormUtils.renderLabel({ + title: intl.formatMessage({ + id: "dialogs.reassign_files.destination", + }), + labelProps: { + column: true, + sm: 3, + xl: 12, + }, + })} + + setScenes(items)} + /> + + +
+
+ ); +}; + +export default ReassignFilesDialog; diff --git a/ui/v2.5/src/components/Shared/ScrapeDialog.tsx b/ui/v2.5/src/components/Shared/ScrapeDialog.tsx index a81f86981..57609635e 100644 --- a/ui/v2.5/src/components/Shared/ScrapeDialog.tsx +++ b/ui/v2.5/src/components/Shared/ScrapeDialog.tsx @@ -20,6 +20,8 @@ import { faPlus, faTimes, } from "@fortawesome/free-solid-svg-icons"; +import { getCountryByISO } from "src/utils"; +import CountrySelect from "./CountrySelect"; export class ScrapeResult { public newValue?: T; @@ -27,13 +29,17 @@ export class ScrapeResult { public scraped: boolean = false; public useNewValue: boolean = false; - public constructor(originalValue?: T | null, newValue?: T | null) { + public constructor( + originalValue?: T | null, + newValue?: T | null, + useNewValue?: boolean + ) { this.originalValue = originalValue ?? undefined; this.newValue = newValue ?? undefined; const valuesEqual = isEqual(originalValue, newValue); - this.useNewValue = !!this.newValue && !valuesEqual; - this.scraped = this.useNewValue; + this.useNewValue = useNewValue ?? (!!this.newValue && !valuesEqual); + this.scraped = !!this.newValue && !valuesEqual; } public setOriginalValue(value?: T) { @@ -61,7 +67,12 @@ export class ScrapeResult { } } -interface IHasName { +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function hasScrapedValues(values: ScrapeResult[]) { + return values.some((r) => r.scraped); +} + +export interface IHasName { name: string | undefined; } @@ -345,6 +356,8 @@ export const ScrapedImageRow: React.FC = (props) => { interface IScrapeDialogProps { title: string; + existingLabel?: string; + scrapedLabel?: string; renderScrapeRows: () => JSX.Element; onClose: (apply?: boolean) => void; } @@ -377,10 +390,14 @@ export const ScrapeDialog: React.FC = ( - + {props.existingLabel ?? ( + + )} - + {props.scrapedLabel ?? ( + + )} @@ -392,3 +409,48 @@ export const ScrapeDialog: React.FC = ( ); }; + +interface IScrapedCountryRowProps { + title: string; + result: ScrapeResult; + onChange: (value: ScrapeResult) => void; + locked?: boolean; + locale?: string; +} + +export const ScrapedCountryRow: React.FC = ({ + title, + result, + onChange, + locked, + locale, +}) => ( + ( + + )} + renderNewField={() => ( + { + if (onChange) { + onChange(result.cloneWithValue(value)); + } + }} + showFlag={false} + isClearable={false} + className="flex-grow-1" + /> + )} + onChange={onChange} + /> +); diff --git a/ui/v2.5/src/components/Shared/Select.tsx b/ui/v2.5/src/components/Shared/Select.tsx index 7bd85eba0..8b44b864c 100644 --- a/ui/v2.5/src/components/Shared/Select.tsx +++ b/ui/v2.5/src/components/Shared/Select.tsx @@ -6,6 +6,8 @@ import Select, { components as reactSelectComponents, GroupedOptionsType, OptionsType, + MenuListComponentProps, + GroupTypeBase, } from "react-select"; import CreatableSelect from "react-select/creatable"; import debounce from "lodash-es/debounce"; @@ -94,16 +96,13 @@ interface IFilterComponentProps extends IFilterProps { interface IFilterSelectProps extends Omit, "onChange" | "items" | "onCreateOption"> {} -type Gallery = { id: string; title: string }; -interface IGallerySelect { - galleries: Gallery[]; - onSelect: (items: Gallery[]) => void; -} - -type Scene = { id: string; title: string }; -interface ISceneSelect { - scenes: Scene[]; - onSelect: (items: Scene[]) => void; +type TitledObject = { id: string; title: string }; +interface ITitledSelect { + className?: string; + selected: TitledObject[]; + onSelect: (items: TitledObject[]) => void; + isMulti?: boolean; + disabled?: boolean; } const getSelectedItems = (selectedItems: ValueType) => @@ -116,6 +115,55 @@ const getSelectedItems = (selectedItems: ValueType) => const getSelectedValues = (selectedItems: ValueType) => getSelectedItems(selectedItems).map((item) => item.value); +const LimitedSelectMenu = ( + props: MenuListComponentProps> +) => { + const maxOptionsShown = 200; + const [hiddenCount, setHiddenCount] = useState(0); + const hiddenCountStyle = { + padding: "8px 12px", + opacity: "50%", + }; + const menuChildren = useMemo(() => { + if (Array.isArray(props.children)) { + // limit the number of select options showing in the select dropdowns + // always showing the 'Create "..."' option when it exists + let creationOptionIndex = (props.children as React.ReactNodeArray).findIndex( + (child: React.ReactNode) => { + let maybeCreatableOption = child as React.ReactElement< + OptionProps< + Option & { __isNew__: boolean }, + T, + GroupTypeBase
{showNavigation && !isFullscreen && images.length > 1 && ( -
+
- { setRating(v ?? null); }} diff --git a/ui/v2.5/src/hooks/Lightbox/hooks.ts b/ui/v2.5/src/hooks/Lightbox/hooks.ts index cf3ce8ff6..dffa0fb5c 100644 --- a/ui/v2.5/src/hooks/Lightbox/hooks.ts +++ b/ui/v2.5/src/hooks/Lightbox/hooks.ts @@ -1,4 +1,4 @@ -import { useCallback, useContext, useEffect } from "react"; +import { useCallback, useContext, useEffect, useMemo, useState } from "react"; import * as GQL from "src/core/generated-graphql"; import { LightboxContext, IState } from "./context"; @@ -39,27 +39,74 @@ export const useLightbox = (state: Partial>) => { export const useGalleryLightbox = (id: string) => { const { setLightboxState } = useContext(LightboxContext); - const [fetchGallery, { data }] = GQL.useFindGalleryLazyQuery({ - variables: { id }, + + const pageSize = 40; + const [page, setPage] = useState(1); + + const currentFilter = useMemo(() => { + return { + page, + per_page: pageSize, + sort: "path", + }; + }, [page]); + + const [fetchGallery, { data }] = GQL.useFindImagesLazyQuery({ + variables: { + filter: currentFilter, + image_filter: { + galleries: { + modifier: GQL.CriterionModifier.Includes, + value: [id], + }, + }, + }, }); + const pages = useMemo(() => { + const totalCount = data?.findImages.count ?? 0; + return Math.ceil(totalCount / pageSize); + }, [data?.findImages.count]); + + const handleLightBoxPage = useCallback( + (direction: number) => { + if (direction === -1) { + if (page === 1) { + setPage(pages); + } else { + setPage(page - 1); + } + } else if (direction === 1) { + if (page === pages) { + // return to the first page + setPage(1); + } else { + setPage(page + 1); + } + } + }, + [page, pages] + ); + useEffect(() => { if (data) setLightboxState({ - images: data.findGallery?.images ?? [], isLoading: false, isVisible: true, + images: data.findImages?.images ?? [], + pageCallback: pages > 1 ? handleLightBoxPage : undefined, + pageHeader: `Page ${page} / ${pages}`, }); - }, [setLightboxState, data]); + }, [setLightboxState, data, handleLightBoxPage, page, pages]); const show = () => { if (data) setLightboxState({ isLoading: false, isVisible: true, - images: data.findGallery?.images ?? [], - pageCallback: undefined, - pageHeader: undefined, + images: data.findImages?.images ?? [], + pageCallback: pages > 1 ? handleLightBoxPage : undefined, + pageHeader: `Page ${page} / ${pages}`, }); else { setLightboxState({ diff --git a/ui/v2.5/src/hooks/Lightbox/types.ts b/ui/v2.5/src/hooks/Lightbox/types.ts index 70bd16454..ce630c812 100644 --- a/ui/v2.5/src/hooks/Lightbox/types.ts +++ b/ui/v2.5/src/hooks/Lightbox/types.ts @@ -8,7 +8,7 @@ interface IImagePaths { export interface ILightboxImage { id?: string; title?: GQL.Maybe; - rating?: GQL.Maybe; + rating100?: GQL.Maybe; o_counter?: GQL.Maybe; paths: IImagePaths; } diff --git a/ui/v2.5/src/hooks/ListHook.tsx b/ui/v2.5/src/hooks/ListHook.tsx index 92d960dfd..620950951 100644 --- a/ui/v2.5/src/hooks/ListHook.tsx +++ b/ui/v2.5/src/hooks/ListHook.tsx @@ -8,6 +8,7 @@ import React, { useState, useEffect, useMemo, + useContext, } from "react"; import { ApolloError } from "@apollo/client"; import { useHistory, useLocation } from "react-router-dom"; @@ -62,6 +63,7 @@ import { import { AddFilterDialog } from "src/components/List/AddFilterDialog"; import { TextUtils } from "src/utils"; import { FormattedNumber } from "react-intl"; +import { ConfigurationContext } from "./Config"; const getSelectedData = ( result: I[], @@ -582,6 +584,7 @@ const useList = ( const location = useLocation(); const [interfaceState, setInterfaceState] = useInterfaceLocalForage(); const [filterInitialised, setFilterInitialised] = useState(false); + const { configuration: config } = useContext(ConfigurationContext); // Store initial pathname to prevent hooks from operating outside this page const originalPathName = useRef(location.pathname); const persistanceKey = options.persistanceKey ?? options.filterMode; @@ -591,6 +594,7 @@ const useList = ( const createNewFilter = useCallback(() => { const filter = new ListFilterModel( options.filterMode, + config, defaultSort, defaultDisplayMode, options.defaultZoomIndex @@ -599,6 +603,7 @@ const useList = ( return filter; }, [ options.filterMode, + config, history, defaultSort, defaultDisplayMode, diff --git a/ui/v2.5/src/index.scss b/ui/v2.5/src/index.scss index 97ab4e11c..3f5a8366f 100755 --- a/ui/v2.5/src/index.scss +++ b/ui/v2.5/src/index.scss @@ -17,6 +17,7 @@ @import "src/components/Setup/styles.scss"; @import "src/components/Studios/styles.scss"; @import "src/components/Shared/styles.scss"; +@import "src/components/Shared/Rating/styles.scss"; @import "src/components/Tags/styles.scss"; @import "src/components/Wall/styles.scss"; @import "src/components/Tagger/styles.scss"; @@ -25,8 +26,6 @@ @import "src/components/Dialogs/IdentifyDialog/styles.scss"; @import "src/components/Dialogs/styles.scss"; @import "../node_modules/flag-icon-css/css/flag-icon.min.css"; -@import "video.js/dist/video-js.css"; -@import "videojs-seek-buttons/dist/videojs-seek-buttons.css"; /* stylelint-disable */ #root { @@ -406,6 +405,90 @@ div.dropdown-menu { margin: 0 10px; } +.rating-100-20 { + background: #f00; +} + +.rating-100-19 { + background: #ff2409; +} + +.rating-100-18 { + background: #ff4812; +} + +.rating-100-17 { + background: #ff6a07; +} + +.rating-100-16 { + background: #ff8000; +} + +.rating-100-15 { + background: #fa8804; +} + +.rating-100-14 { + background: #f39409; +} + +.rating-100-13 { + background: #eca00e; +} + +.rating-100-12 { + background: #e7a811; +} + +.rating-100-11 { + background: #dfb617; +} + +.rating-100-10 { + background: #d2ca20; +} + +.rating-100-9 { + background: #cbb526; +} + +.rating-100-8 { + background: #c39f2b; +} + +.rating-100-7 { + background: #bd8e2f; +} + +.rating-100-6 { + background: #b47435; +} + +.rating-100-5 { + background: #af7944; +} + +.rating-100-4 { + background: #a7805b; +} + +.rating-100-3 { + background: #a48363; +} + +.rating-100-2 { + background: #9e8974; +} + +.rating-100-1 { + background: #9b8c7d; +} + +.rating-100-0 { + background: #939393; +} + .rating-5 { background: #ff2f39; } @@ -429,9 +512,9 @@ div.dropdown-menu { .rating-banner { color: #fff; display: block; - font-size: 0.86rem; - font-weight: 400; - left: -46px; + font-size: 1rem; + font-weight: bold; + left: -48px; letter-spacing: 1px; line-height: 1.6rem; padding: 6px 45px; diff --git a/ui/v2.5/src/index.tsx b/ui/v2.5/src/index.tsx index bae8e45a0..de26ef79c 100755 --- a/ui/v2.5/src/index.tsx +++ b/ui/v2.5/src/index.tsx @@ -20,6 +20,10 @@ ReactDOM.render( document.getElementById("root") ); +const script = document.createElement("script"); +script.src = `${getPlatformURL()}javascript`; +document.body.appendChild(script); + // If you want your app to work offline and load faster, you can change // unregister() to register() below. Note this comes with some pitfalls. // Learn more about service workers: http://bit.ly/CRA-PWA diff --git a/ui/v2.5/src/locales/bn-BD.json b/ui/v2.5/src/locales/bn-BD.json new file mode 100644 index 000000000..e69142fad --- /dev/null +++ b/ui/v2.5/src/locales/bn-BD.json @@ -0,0 +1,256 @@ +{ + "actions": { + "add": "যুক্ত", + "add_directory": "ডিরেক্টরি যুক্ত করুন", + "add_entity": "যুক্ত{entityType}", + "add_to_entity": "{entityType} এ যুক্ত করুন", + "allow": "সম্মত আছেন", + "allow_temporarily": "অস্থায়ীভাবে সম্মত আছেন", + "apply": "প্রয়োগ করুন", + "auto_tag": "স্বয়ংক্রিয়", + "backup": "জমানো", + "browse_for_image": "ইমেজ ব্রাউজের জন্যে…", + "cancel": "বাতিল", + "clean": "পরিষ্কার", + "clear": "পরিষ্কার", + "clear_back_image": "আগের ইমেজ পরিষ্কার করুন", + "clear_front_image": "সামনের ইমেজ পরিষ্কার করুন", + "clear_image": "ইমেজ পরিষ্কার করুন", + "close": "বন্ধ করুন", + "confirm": "নিশ্চিত", + "continue": "এগিয়ে চলুন", + "create": "তৈরি করুন", + "create_entity": "{entityType} তৈরি করুন", + "create_marker": "মার্কার তৈরি করুন", + "created_entity": "তৈরি হয়েছে {entity_type}: {entity_name}", + "customise": "সাজাও", + "delete": "মুছুন", + "delete_entity": "{entityType} মুছুন", + "delete_file": "ফাইল মুছুন", + "delete_file_and_funscript": "ফাইল মুছুন (সাথে funscript ও)", + "delete_generated_supporting_files": "জেনারেট হওয়া সমর্থিত ফাইলসমূহ মুছুন", + "delete_stashid": "StashID মুছুন", + "disallow": "অনুমতি নাকচ করুন", + "download": "ডাউনলোড", + "download_backup": "জনানোগুলো ডাউনলোড করুন", + "edit": "সম্পাদন করুন", + "edit_entity": "{entityType} সম্পাদন করুন", + "export": "রপ্তানি…", + "export_all": "সব রপ্তানি করুন…", + "find": "খোঁজ করুন", + "finish": "শেষ করুন", + "from_file": "ফাইল থেকে…", + "from_url": "ইউআরএল থেকে…", + "full_export": "পুরো রপ্তানি", + "full_import": "পুরো রপ্তানি", + "generate": "জেনারেট করুন", + "generate_thumb_default": "ডিফল্ট পূর্বচিত্র উৎপাদন করুন", + "generate_thumb_from_current": "বর্তমানের থেকে পূর্বচিত্র উৎপাদন করুন", + "hash_migration": "হ্যাশ স্থানান্তর", + "hide": "লুকান", + "hide_configuration": "কনফিগারেশন লুকান", + "identify": "চেনা নিশ্চিত করুন", + "ignore": "অগ্রাহ্য করুন", + "import": "আমদানি…", + "import_from_file": "ফাইল থেকে আমদানি করুন", + "logout": "লগ ছাড়ুন", + "make_primary": "প্রাথমিক বানান", + "merge": "ছাটুন", + "merge_from": "এর থেকে ছাটুন", + "merge_into": "এর মধ্যে ছাটুন", + "next_action": "পরবর্তী", + "not_running": "চলছে না", + "open_in_external_player": "বাইরের প্লেয়ার এ খুলুন", + "open_random": "অনির্ধারিত খুলুন", + "overwrite": "পূনরায় লিখুন", + "play_random": "অনির্ধারিত খেলুন", + "play_selected": "খেলা নির্বাচিত", + "preview": "পূর্বরূপ", + "previous_action": "পেছনে", + "refresh": "রিফ্রেশ", + "reload_plugins": "প্লাগিনগুলো পুনরায় লোড করুন", + "reload_scrapers": "বর্শিগুলো পুনরায় লোড করুন", + "remove": "সরান", + "remove_from_gallery": "গ্যালারি থেকে সরান", + "rename_gen_files": "উৎপাদিত ফাইলগুলোকে পুনরায় নামকরণ", + "rescan": "পুনরায় ছাকুন", + "reshuffle": "পুনরায় এলোমেলো করুন", + "running": "চলছে", + "save": "সংরক্ষণ", + "save_delete_settings": "ডিলিট করার সময় এই পছন্দগুলো ব্যাবহার করুন", + "save_filter": "ফিল্টার সংরক্ষণ করুন", + "scan": "ছাকুন", + "scrape": "গাথুন", + "scrape_query": "কুয়েরি গাথুন", + "scrape_scene_fragment": "ফ্রেগমেন্ট দিয়ে গাথুন", + "scrape_with": "দিয়ে গাথুন…", + "search": "অনুসন্ধান", + "select_all": "সব নির্বাচিত করুন", + "select_entity": "{entityType} নির্বাচিত করুন", + "select_folders": "ফোল্ডার নির্বাচন করুন", + "select_none": "কোনোকিছু না নির্বাচন করুন", + "selective_auto_tag": "নির্বাচনী স্বক্রিয় ট্যাগ", + "selective_clean": "নির্বাচনী পরিষ্কার", + "selective_scan": "নির্বাচনী ছাকা", + "set_as_default": "ডিফল্ট করুন", + "set_back_image": "পেছনের ইমেজ…", + "set_front_image": "সমানের ইমেজ…", + "set_image": "ইমেজ লাগান…", + "show": "দেখান", + "show_configuration": "কনফিগারেশন দেখান", + "skip": "এড়িয়ে যান", + "stop": "থামান", + "submit": "জমা করুন", + "submit_stash_box": "স্টাস-বক্স এ জমা করুন", + "submit_update": "আপডেট জমা করুন", + "tasks": { + "clean_confirm_message": "আপনি কি নিশ্চিত পরিষ্কার করবেন?\nএটা ডেটাবেইজের তথ্য মুছে দেবে সাথে সকল দৃশ্য ও গ্যালারির জন্যে উৎপাদিত কনেন্ট আর ফাইল সিস্টেমে পাওয়া যাবেনা।", + "dry_mode_selected": "শুষ্ক মোড নির্বাচিত। কোনো সত্যিকার মুছন হবেনা, শুধু লগ।", + "import_warning": "নিশ্চিত যে আমদানি করবেন? এটা ডাটাবেজ মুছবে এবং আপনার রপ্তানি করা মেটাডাটা থেকে পুনরায় আমদানি করবে।" + }, + "temp_disable": "সাময়িক অকার্য করুন…", + "temp_enable": "সাময়িক কার্যকর করুন…", + "unset": "আনসেট", + "use_default": "ডিফল্ট ব্যাবহার করুন", + "view_random": "অনির্ধারিত দেখুন" + }, + "actions_name": "ক্রিয়াসমুহ", + "age": "বয়স", + "aliases": "সন্ধিবন্ধরা", + "all": "সব", + "also_known_as": "আরো নামে জ্ঞাত", + "ascending": "আগানো", + "average_resolution": "মধ্যম রেসুলিউশন", + "birth_year": "জন্মের সন", + "birthdate": "জন্মতারিখ", + "bitrate": "বিটের হার", + "captions": "ক্যাপশনসমুহ", + "career_length": "বহকের দৈর্ঘ্য", + "component_tagger": { + "config": { + "active_instance": "সচল স্ট্যাস-বক্স প্রতিনিধি:", + "blacklist_desc": "কালো তালিকার আইটেমগুলো কুয়েরির বাইরে রাখা হয়েছে। মনে রাখুন তারা সাধারণ প্রতিক্রিয়া এবং একইভাবে কেস সেনসিটিভ। কিছু অক্ষরকে অবশ্যই ব্যাকস্ল্যাশ দিয়ে ছেড়ে রাখা যাবে: {chars_require_escape}", + "blacklist_label": "কালো তালিকা", + "query_mode_auto": "স্বয়ং", + "query_mode_auto_desc": "মেটাডাটা থাকলে ব্যাবহার করুন , অথবা ফাইলের নাম", + "query_mode_dir": "ডির", + "query_mode_dir_desc": "শুধু ভিডিও ফাইলের আত্মীয় ডিরেক্টরি ব্যাবহার করে", + "query_mode_filename": "ফাইলের নাম", + "query_mode_filename_desc": "শুধু ফাইলের নাম ব্যাবহার করে", + "query_mode_label": "কুয়েরি মোড", + "query_mode_metadata": "মেটাডাটা", + "query_mode_metadata_desc": "শুধু মেটাডাটা ব্যাবহার করে", + "query_mode_path": "পথ", + "query_mode_path_desc": "সম্পূর্ণ ফাইলের পথ ব্যাবহার করে", + "set_cover_desc": "যদি কোনোটা পাওয়া যায় তবে দৃশ্যের কভার পুনঃস্থাপন করুন।", + "set_cover_label": "দৃশ্যের কভার ইমেজ সেট করুন", + "set_tag_desc": "দৃশ্যে ট্যাগ লাগান, হয় পুনরায় লিখে নয় দৃশ্যে থাকা ট্যাগ ছেটে।", + "set_tag_label": "ট্যাগ সেট করুন", + "show_male_desc": "যেকোনোটি নাড়ান পুরুষ প্রদর্শনকারী ট্যাগ এ উপস্থিত হবে।", + "show_male_label": "পুরুষ প্রদর্শনকারী দেখান", + "source": "উৎস" + }, + "noun_query": "কুয়েরি", + "results": { + "duration_off": "{নাম্বারে} হলেও চলনকাল বন্ধ", + "duration_unknown": "চলনকাল অজানা", + "fp_found": "{fpCount, plural, =0 {কোনো নতুন আঙ্গুলেরছাপ পাওয়া যায়নি } অন্যান্য {নতুন অঙ্গুলেরছাপের মিল পাওয়া গেছে}}", + "fp_matches": "চলনকাল একটি মিল", + "fp_matches_multi": "চলনকাল মেলে {matchCount}/{durationLength}আঙ্গুলেরছাপ(গুলো)", + "hash_matches": "{হ্যাশ_ধরন} হলো একটা মিল", + "match_failed_already_tagged": "দৃশ্য আগে থেকেই ট্যাগকৃত", + "match_failed_no_result": "কোনো ফলাফল পাওয়া যায়নি", + "match_success": "দৃশ্য সফলভাবে ট্যাগ করা হয়েছে", + "phash_matches": "{গণনা} পিহ্যাশগুলোর মিল", + "unnamed": "বেনামী" + }, + "verb_match_fp": "আঙ্গুলেরছাপ মিল করুন", + "verb_matched": "মিলিত", + "verb_scrape_all": "সবগুলোকে উঠান", + "verb_submit_fp": "জমান {এফপিগণনা,বহু,একক{# আঙ্গুলেরছাপ } অন্য { # আঙ্গুলেরছাপ }", + "verb_toggle_config": "{ নাড়ান} { কনফিগারেশন }", + "verb_toggle_unmatched": "{ নাড়ান } অমিলিত দৃশ্যগুলো" + }, + "config": { + "about": { + "build_hash": "হ্যাশ নির্মাণ:", + "build_time": "নির্মাণের সময়:", + "check_for_new_version": "নতুন সংস্করণ যাচাই করুন", + "latest_version": "সর্বশেষ সংস্করণ", + "latest_version_build_hash": "সর্বশেষ সংস্করণের হ্যাশ নির্মাণ:", + "new_version_notice": "{ নতুন }", + "stash_discord": "আমাদের {url} চ্যানেলে যুক্ত থাকুন", + "stash_home": "স্টাস এর হোম {url} তে", + "stash_open_collective": "{url} এর মাধ্যমে আমাদের সাহায্য করুন", + "stash_wiki": "স্টাস {url} এর পাতা", + "version": "সংস্করণ" + }, + "application_paths": { + "heading": "অ্যাপ্লিকেশন এর পথ" + }, + "categories": { + "about": "সমন্ধে", + "changelog": "পরিবর্তনের লগ", + "interface": "ইন্টারফেস", + "logs": "লগগুলো", + "metadata_providers": "মেটাতথ্য প্রদানকারীরা", + "plugins": "সংযোজকসমূহ", + "scraping": "উঠান", + "security": "নিরাপত্তা", + "services": "সেবাসমূহ", + "system": "সিস্টেম", + "tasks": "কাজ", + "tools": "সরঞ্জামগুলো" + }, + "dlna": { + "allow_temp_ip": "অনুমোদন {tempIP}", + "allowed_ip_addresses": "আইপি ঠিকানা অনুমোদিত", + "allowed_ip_temporarily": "আইপি অস্থায়ীভাবে অনুমোদিত", + "default_ip_whitelist": "ডিফল্ট আইপি সাদাতালিকা", + "default_ip_whitelist_desc": "ডিফল্ট আইপি ঠিকানাগুলো DLNA একসেস করার অনুমতি দিন। সব আইপি থিকানাগুলোকে অনুমোদন করতে {wildcard} ব্যাবহার করুন।", + "disabled_dlna_temporarily": "DLNA অস্থায়ীভাবে অচল করুন", + "disallowed_ip": "আইপি অননুমোদিত", + "enabled_by_default": "প্রাথমিকভাবে সচলকৃত", + "enabled_dlna_temporarily": "DLNA অস্থায়ীভাবে সচল", + "network_interfaces": "ইন্টারফেসগুলো", + "network_interfaces_desc": "DLNA সার্ভার অন ফাঁস করে দেওয়ার ইন্টারফেস। একটা খালি তালিকা সবগুলো ইন্টারফেসে চলন ঘটায়। পরিবর্তন করতে পর DLNA পুনরায় শুরুর দরকার হয়।", + "recent_ip_addresses": "সাম্প্রতিক আইপি ঠিকানাগুলো", + "server_display_name": "সার্ভার প্রদর্শনের নাম", + "server_display_name_desc": "DLNA সার্ভারটার প্রদর্শিত নাম। প্রাথমিকভাবে {server_name} যদি ফাঁকা থাকে।", + "successfully_cancelled_temporary_behaviour": "অস্থায়ী আচার সফলভাবে বাতিল করা হয়েছে", + "until_restart": "পুনরায় শুরুর করা পর্যন্ত" + }, + "general": { + "auth": { + "api_key": "এপিআই চাবি", + "api_key_desc": "বাইরের সিস্টেমের জন্যে এপিআই চাবি। শুধুমাত্র যখন বাভারকারিনাম/পাসশব্দ কনফিগারকৃত তখনই প্রয়োজন। ব্যাবহারকারীনাম অবশ্যই এপিআই চাবি উৎপাদনের আগে সংরক্ষিত হতে হবে।", + "authentication": "অ্যাথনিটিকেশন", + "clear_api_key": "এপিআই চাবি পরিষ্কার করুন", + "credentials": { + "description": "স্টাস থেকে বরখাস্ত করতে দস্তাবেজসমূহ।", + "heading": "দস্তাবেজসমূহ" + }, + "generate_api_key": "এপিআই চাবি উৎপাদন", + "log_file": "লগ ফাইল", + "log_file_desc": "যেখানে আউটপুট লগ হয় সেখানের ফাইল পথ। ফাইল লগ করতে খালি। পুনরায় শুরুর প্রয়োজন।", + "log_http": "http অ্যাকসেস লগ করুন", + "log_http_desc": "টার্মিনাল এ http অ্যাকসেস লগ করুন। পুনরায় শুরুর প্রয়োজন।", + "log_to_terminal": "টার্মিনালে লগ করুন", + "log_to_terminal_desc": "একটা ফাইলে সংযোজন হিসেবে টার্মিনালে লগ করে। সবসময় সত্য যদি ফাইল লগ করা অচল থাকে। পুনরায় শুরুর প্রয়োজন।", + "maximum_session_age": "সর্বোচ্চ সেশন এর বয়স", + "maximum_session_age_desc": "লগ ইন সেশনের মেয়াদ শেষ হওয়ার আগের সর্বোচ্চ অলস সময়, সেকেন্ড এ।", + "password": "পাসশব্দ", + "password_desc": "স্ট্যাস অ্যাকসেস করার পাসশব্দ। ব্যাবহারকারী অথনিটিকেশিন অচল করতে খালি রাখুন", + "stash-box_integration": "স্ট্যাস-বক্স ইন্টিগ্রেশন", + "username": "ব্যাবহারকারীনাম", + "username_desc": "স্ট্যাস অ্যাকসেস করার ব্যাবহারকারীনাম। ব্যাবহাকারী অথনিটিকেশন অচল করতে খালি রাখুন" + }, + "backup_directory_path": { + "description": "SQLite ডেটাবেস ফাইল জমানোর ডিরেক্টরির অবস্থান", + "heading": "জমা রাখা ডিরেক্টরির পথ" + }, + "cache_location": "ক্যাশে ডিরেক্টরির অবস্থান", + "cache_path_head": "ক্যাশের পথ" + } + } +} diff --git a/ui/v2.5/src/locales/countryNames/zh-TW.json b/ui/v2.5/src/locales/countryNames/zh-TW.json new file mode 100644 index 000000000..b6fc33d02 --- /dev/null +++ b/ui/v2.5/src/locales/countryNames/zh-TW.json @@ -0,0 +1,255 @@ +{ + "locale": "tw", + "countries": { + "AD": "安道爾", + "AE": "阿聯酋", + "AF": "阿富汗", + "AG": "安地卡及巴布達", + "AI": "安圭拉", + "AL": "阿爾巴尼亞", + "AM": "亞美尼亞", + "AO": "安哥拉", + "AQ": "南極洲", + "AR": "阿根廷", + "AS": "美屬薩摩亞", + "AT": "奧地利", + "AU": "澳大利亞", + "AW": "阿魯巴", + "AX": "奧蘭", + "AZ": "阿塞拜疆", + "BA": "波斯尼亞和黑塞哥維那", + "BB": "巴巴多斯", + "BD": "孟加拉國", + "BE": "比利時", + "BF": "布吉納法索", + "BG": "保加利亞", + "BH": "巴林", + "BI": "布隆迪", + "BJ": "貝寧", + "BL": "聖巴泰勒米", + "BM": "百慕大", + "BN": "文萊", + "BO": "玻利維亞", + "BQ": "加勒比荷蘭", + "BR": "巴西", + "BS": "巴哈馬", + "BT": "不丹", + "BV": "布韋島", + "BW": "博茨瓦納", + "BY": "白俄羅斯", + "BZ": "伯利茲", + "CA": "加拿大", + "CC": "科科斯(基林)群島", + "CD": "剛果(金)", + "CF": "中非", + "CG": "剛果(布)", + "CH": "瑞士", + "CI": "科特迪瓦", + "CK": "庫克群島", + "CL": "智利", + "CM": "喀麥隆", + "CN": "中國", + "CO": "哥倫比亞", + "CR": "哥斯達黎加", + "CU": "古巴", + "CV": "佛得角", + "CW": "庫拉索", + "CX": "聖誕島", + "CY": "賽普勒斯", + "CZ": "捷克", + "DE": "德國", + "DJ": "吉布提", + "DK": "丹麥", + "DM": "多米尼克", + "DO": "多米尼加", + "DZ": "阿爾及利亞", + "EC": "厄瓜多爾", + "EE": "愛沙尼亞", + "EG": "埃及", + "EH": "阿拉伯撒哈拉民主共和國", + "ER": "厄立特里亞", + "ES": "西班牙", + "ET": "衣索比亞", + "FI": "芬蘭", + "FJ": "斐濟", + "FK": "福克蘭群島", + "FM": "密克羅尼西亞聯邦", + "FO": "法羅群島", + "FR": "法國", + "GA": "加彭", + "GB": "英國", + "GD": "格瑞那達", + "GE": "格魯吉亞", + "GF": "法屬圭亞那", + "GG": "根西", + "GH": "加納", + "GI": "直布羅陀", + "GL": "格陵蘭", + "GM": "岡比亞", + "GN": "幾內亞", + "GP": "瓜德羅普", + "GQ": "赤道幾內亞", + "GR": "希臘", + "GS": "南喬治亞和南桑威奇群島", + "GT": "危地馬拉", + "GU": "關島", + "GW": "幾內亞比紹", + "GY": "圭亞那", + "HK": "香港", + "HM": "赫德島和麥克唐納群島", + "HN": "宏都拉斯", + "HR": "克羅地亞", + "HT": "海地", + "HU": "匈牙利", + "ID": "印尼", + "IE": "愛爾蘭", + "IL": "以色列", + "IM": "馬恩島", + "IN": "印度", + "IO": "英屬印度洋領地", + "IQ": "伊拉克", + "IR": "伊朗", + "IS": "冰島", + "IT": "意大利", + "JE": "澤西", + "JM": "牙買加", + "JO": "約旦", + "JP": "日本", + "KE": "肯尼亞", + "KG": "吉爾吉斯斯坦", + "KH": "柬埔寨", + "KI": "基里巴斯", + "KM": "科摩羅", + "KN": "聖基茨和尼維斯", + "KP": "朝鮮", + "KR": "韓國", + "KW": "科威特", + "KY": "開曼群島", + "KZ": "哈薩克斯坦", + "LA": "老撾", + "LB": "黎巴嫩", + "LC": "聖盧西亞", + "LI": "列支敦斯登", + "LK": "斯里蘭卡", + "LR": "利比里亞", + "LS": "賴索托", + "LT": "立陶宛", + "LU": "盧森堡", + "LV": "拉脫維亞", + "LY": "利比亞", + "MA": "摩洛哥", + "MC": "摩納哥", + "MD": "摩爾多瓦", + "ME": "蒙特內哥羅", + "MF": "法屬聖馬丁", + "MG": "馬達加斯加", + "MH": "馬紹爾群島", + "MK": "馬其頓", + "ML": "馬里", + "MM": "緬甸", + "MN": "蒙古", + "MO": "澳門", + "MP": "北馬里亞納群島", + "MQ": "馬提尼克", + "MR": "毛里塔尼亞", + "MS": "蒙特塞拉特", + "MT": "馬爾他", + "MU": "模里西斯", + "MV": "馬爾地夫", + "MW": "馬拉維", + "MX": "墨西哥", + "MY": "馬來西亞", + "MZ": "莫桑比克", + "NA": "納米比亞", + "NC": "新喀裡多尼亞", + "NE": "尼日爾", + "NF": "諾福克島", + "NG": "奈及利亞", + "NI": "尼加拉瓜", + "NL": "荷蘭", + "NO": "挪威", + "NP": "尼泊爾", + "NR": "瑙魯", + "NU": "紐埃", + "NZ": "新西蘭", + "OM": "阿曼", + "PA": "巴拿馬", + "PE": "秘魯", + "PF": "法屬玻里尼西亞", + "PG": "巴布亞新幾內亞", + "PH": "菲律賓", + "PK": "巴基斯坦", + "PL": "波蘭", + "PM": "聖皮埃爾和密克隆", + "PN": "皮特凱恩群島", + "PR": "波多黎各", + "PS": "巴勒斯坦", + "PT": "葡萄牙", + "PW": "帛琉", + "PY": "巴拉圭", + "QA": "卡塔爾", + "RE": "留尼汪", + "RO": "羅馬尼亞", + "RS": "塞爾維亞", + "RU": "俄羅斯", + "RW": "盧旺達", + "SA": "沙烏地阿拉伯", + "SB": "所羅門群島", + "SC": "塞舌爾", + "SD": "蘇丹", + "SE": "瑞典", + "SG": "新加坡", + "SH": "聖赫勒拿", + "SI": "斯洛維尼亞", + "SJ": "斯瓦爾巴群島和揚馬延島", + "SK": "斯洛伐克", + "SL": "塞拉利昂", + "SM": "聖馬力諾", + "SN": "塞內加爾", + "SO": "索馬利亞", + "SR": "蘇里南", + "SS": "南蘇丹", + "ST": "聖多美和普林西比", + "SV": "薩爾瓦多", + "SX": "荷屬聖馬丁", + "SY": "敘利亞", + "SZ": "斯威士蘭", + "TC": "特克斯和凱科斯群島", + "TD": "乍得", + "TF": "法屬南部領地", + "TG": "多哥", + "TH": "泰國", + "TJ": "塔吉克斯坦", + "TK": "托克勞", + "TL": "東帝汶", + "TM": "土庫曼斯坦", + "TN": "突尼西亞", + "TO": "湯加", + "TR": "土耳其", + "TT": "千里達及托巴哥", + "TV": "圖瓦盧", + "TW": "臺灣", + "TZ": "坦桑尼亞", + "UA": "烏克蘭", + "UG": "烏干達", + "UM": "美國本土外小島嶼", + "US": "美國", + "UY": "烏拉圭", + "UZ": "烏茲別克斯坦", + "VA": "梵蒂岡", + "VC": "聖文森及格瑞那丁", + "VE": "委內瑞拉", + "VG": "英屬維爾京群島", + "VI": "美屬維爾京群島", + "VN": "越南", + "VU": "瓦努阿圖", + "WF": "瓦利斯和富圖納", + "WS": "薩摩亞", + "YE": "葉門", + "YT": "馬約特", + "ZA": "南非", + "ZM": "尚比亞", + "ZW": "辛巴威", + "XK": "科索沃" + } +} diff --git a/ui/v2.5/src/locales/cs-CZ.json b/ui/v2.5/src/locales/cs-CZ.json index 4e49e26cc..e069b5894 100644 --- a/ui/v2.5/src/locales/cs-CZ.json +++ b/ui/v2.5/src/locales/cs-CZ.json @@ -54,6 +54,7 @@ "import": "Importovat…", "import_from_file": "Importovat ze souboru", "logout": "Odhlásit se", + "make_primary": "Natavit jako primární", "merge": "Sloučit", "merge_from": "Sloučit z", "merge_into": "Sloučit do", @@ -66,6 +67,7 @@ "play_selected": "Přehrát vybrané", "preview": "Náhled", "previous_action": "Zpět", + "reassign": "Přeřadit", "refresh": "Obnovit", "reload_plugins": "Znovu načíst pluginy", "reload_scrapers": "Znovu načíst scrapery", @@ -98,10 +100,12 @@ "show": "Zobrazit", "show_configuration": "Zobrazit konfiguraci", "skip": "Přeskočit", + "split": "Rozdělit", "stop": "Zastavit", "submit": "Publikovat", "submit_stash_box": "Publikovat do Stash-Box", "submit_update": "Publikovat aktualizaci", + "swap": "Prohodit", "tasks": { "clean_confirm_message": "Jste si jistý, že chcete čistit? Tato operace vymaže informace z databáze a generovaný obsah pro všechny scény a galerie, které nejsou nalezeny v souborovém systému.", "dry_mode_selected": "Vybrán \"Dry Mode\". Nic nebude smazáno, pouze logováno.", @@ -120,6 +124,7 @@ "also_known_as": "Též známá jako", "ascending": "Vzestupně", "average_resolution": "Střední rozlišení", + "between_and": "a", "birth_year": "Rok narození", "birthdate": "Datum narození", "bitrate": "Bitová rychlost", @@ -189,6 +194,7 @@ }, "categories": { "about": "O programu", + "changelog": "Seznam změn", "interface": "Rozhraní", "logs": "Logy", "metadata_providers": "Poskytovatelé Metadat", @@ -243,6 +249,10 @@ "username": "Přihlašovací jméno", "username_desc": "Přihlašovací jméno pro přístup do aplikace. Ponechte prázdné pro vypnutí autentizace" }, + "backup_directory_path": { + "description": "Adresář umístění záloh databáze SQLite", + "heading": "Cesta k adresáři záloh" + }, "cache_location": "Složka pro mezipaměť", "cache_path_head": "Cesta k mezipaměti", "calculate_md5_and_ohash_desc": "Spočítat MD5 kontrolní součet kromě oshashe. Zapnutí zpomalí následující skenování. Hash dle názvu souboru musí být nastaven na oshash pro zrušení výpočtu MD5.", @@ -420,12 +430,26 @@ "scene_tools": "Nástroje scény" }, "ui": { + "abbreviate_counters": { + "description": "Zkrátit počty na kartách a stránkách zobrazení podrobností, například „1831“ bude naformátováno na „1,8K“.", + "heading": "Zkrátit počítadla" + }, "basic_settings": "Základní nastavení", "custom_css": { "description": "Stránka musí být znovu načtena, aby byly změny viditelné.", "heading": "Vlastní CSS", "option_label": "Vlastní CSS aktivováno" }, + "custom_javascript": { + "description": "Stránka musí být znovu načtena, aby byly viditelné změny.", + "heading": "Vlastní Javascript", + "option_label": "Vlastní Javascript povolen" + }, + "custom_locales": { + "description": "Přepsat individuální jazykovou lokalizaci. Navštivte https://github.com/stashapp/stash/blob/develop/ui/v2.5/src/locales/en-GB.json pro zobrazení seznamu. Stránka musí být obnovena, aby byly změny viditelné.", + "heading": "Vlastní jazyková lokalizace", + "option_label": "Vlastní jazyková lokalizace povolena" + }, "delete_options": { "description": "Výchozí nastavení pro mazání obrázků, galerií a scén.", "heading": "Možnosti mazání", @@ -446,7 +470,24 @@ "description": "Zakázat funkčnost tvorby nových objektů z rozbalovacích seznamů", "heading": "Zakázat vytvoření z rozbalovacího seznamu" }, - "heading": "Editace" + "heading": "Editace", + "rating_system": { + "star_precision": { + "label": "Přesnost hvězd hodnocení", + "options": { + "full": "Plná", + "half": "Poloviční", + "quarter": "Čtvrtinová" + } + }, + "type": { + "label": "Typ hodnocení", + "options": { + "decimal": "Desetinný", + "stars": "Hvězdy" + } + } + } }, "funscript_offset": { "description": "Offset v milisekundách pro přehrávání interaktivních skriptů.", @@ -490,6 +531,10 @@ "description": "Zobrazit nebo skrýt různé typy obsahu na navigační liště", "heading": "Menu položky" }, + "minimum_play_percent": { + "description": "Procento času scény, po kterém bude počet přehrání zvýšen.", + "heading": "Minimální procento počtu přehrání" + }, "performers": { "options": { "image_location": { @@ -516,6 +561,7 @@ "scene_player": { "heading": "Přehrávač scén", "options": { + "always_start_from_beginning": "Vždy spustit video od začátku", "auto_start_video": "Automaticky spustit video", "auto_start_video_on_play_selected": { "description": "Automaticky spustit video pokud je přehráváno vybrané nebo náhodné video ze stránky scén", @@ -525,7 +571,8 @@ "description": "Přehrát následující scénu na seznamu při skončení vida", "heading": "(Výchozí nastavení) Pokračovat v playlistu" }, - "show_scrubber": "Zobrazit Scrubber" + "show_scrubber": "Zobrazit Scrubber", + "track_activity": "Sledování činností" } }, "scene_wall": { @@ -539,10 +586,17 @@ "description": "Počet pokusů o posunutí před přechodem na další/předchozí položku. Platí pouze pro režim posouvání Pan Y.", "heading": "Počet pokusů o rolování před přechodem" }, + "show_tag_card_on_hover": { + "description": "Zobrazit přehledku tagu při přejetí přes štítek tagu", + "heading": "Popisky tagových štítků" + }, "slideshow_delay": { "description": "Slideshow je k dispozici v galeriích v režimu zobrazení stěny", "heading": "Zpoždění prezentace (sekundy)" }, + "studio_panel": { + "heading": "Pohled studií" + }, "title": "Uživatelské rozhraní" } }, @@ -587,6 +641,7 @@ "death_date": "Datum úmrtí", "death_year": "Rok úmrtí", "descending": "Sestupně", + "description": "Popis", "detail": "Detail", "details": "Detaily", "developmentVersion": "Vývojářská verze", @@ -601,11 +656,45 @@ "delete_object_desc": "Jste si jisti, že chcete smazat {count, plural, one {tuto {singularEntity}} other {tyto {pluralEntity}}}?", "delete_object_overflow": "…a {count} other {count, plural, one {{singularEntity}} other {{pluralEntity}}}.", "delete_object_title": "Smazat {count, plural, one {{singularEntity}} other {{pluralEntity}}}", + "dont_show_until_updated": "Skrýt do příští aktualizace", "edit_entity_title": "Upravit {count, plural, one {{singularEntity}} other {{pluralEntity}}}", "export_include_related_objects": "Zahrnout související objekty do exportu", "export_title": "Exportovat", "lightbox": { - "delay": "Zpoždění (sekundy)" + "delay": "Zpoždění (sekundy)", + "display_mode": { + "fit_horizontally": "Seřídit horizontálně", + "fit_to_screen": "Seřídit na velikost obrazovky", + "label": "Mód zobrazení", + "original": "Originál" + }, + "options": "Možnosti", + "reset_zoom_on_nav": "Resetovat zoom po změně obrázku", + "scale_up": { + "description": "Škálovat menší obrázky tak, aby vyplnily obrazovku", + "label": "Zvětšit, aby se vešly" + }, + "scroll_mode": { + "description": "Držet shift pro dočasné použití druhého módu.", + "label": "Rolovací mód", + "pan_y": "Náklon Y", + "zoom": "Zvětšit" + } + }, + "merge_tags": { + "destination": "Cíl", + "source": "Zdroj" + }, + "overwrite_filter_confirm": "JSte si jisti, že chcete přepsat aktuálně uložený dotaz {entityName}?", + "scene_gen": { + "force_transcodes": "Vynutit generování Transkódu", + "force_transcodes_tooltip": "Ve výchozím nastavení, transkódy jsou generovány pouze tehdy, když video soubor není podporován prohlížečem. V případě aktivování, transkódy budou generovány i v ostatních případech.", + "image_previews": "Animované náhledy obrázků", + "image_previews_tooltip": "Animobané WebP náhledy, vyžadovány pouze pokud Typ náhledu je nastaven na \"Animovaný obrázek\".", + "interactive_heatmap_speed": "Generovat heatmapy a rychlosti pro interaktivní scény", + "marker_image_previews": "Animované náhledy markerů", + "marker_image_previews_tooltip": "Animované WebP náhledy markerů, nezbytné pouze tehdy, pokud je Typ náhledu nastaven na \"Animovaný obrázek\".", + "marker_screenshots": "Screenshoty markerů" } } } diff --git a/ui/v2.5/src/locales/da-DK.json b/ui/v2.5/src/locales/da-DK.json index d045191ab..8cdb5c5f5 100644 --- a/ui/v2.5/src/locales/da-DK.json +++ b/ui/v2.5/src/locales/da-DK.json @@ -54,6 +54,7 @@ "import": "Importer…", "import_from_file": "Importer fra fil", "logout": "Log ud", + "make_primary": "Gør til primær", "merge": "Fusioner", "merge_from": "Fusioner fra", "merge_into": "Fusioner til", @@ -189,6 +190,7 @@ }, "categories": { "about": "Om", + "changelog": "Ændringslog", "interface": "Interface", "logs": "Logfiler", "metadata_providers": "Metadataudbydere", @@ -243,6 +245,10 @@ "username": "Brugernavn", "username_desc": "Brugernavn for at få adgang til Stash. Lad tom for at deaktivere brugergodkendelse" }, + "backup_directory_path": { + "description": "Mappelokation for SQLite database backup filer", + "heading": "Backup mappesti" + }, "cache_location": "Directory placering af cachen", "cache_path_head": "Cache Sti", "calculate_md5_and_ohash_desc": "Beregn MD5 kontrolsum ud over oshash. Aktivering vil medføre, at indledende scanninger bliver langsommere. Filnavnehash skal indstilles til oshash for at deaktivere MD5-beregning.", @@ -420,12 +426,20 @@ "scene_tools": "Sceneværktøjer" }, "ui": { + "abbreviate_counters": { + "description": "Forkort tællere i kort- og detaljebilleder, fx bliver \"1831\" forkortet til \"1.8K\"." + }, "basic_settings": "Grundlæggende indstillinger", "custom_css": { "description": "Siden skal genindlæses for at ændringer kan træde i kraft.", "heading": "Brugerdefineret CSS", "option_label": "Brugerdefineret CSS aktiveret" }, + "custom_locales": { + "description": "Ændre individuelle lokale strenge. Se https://github.com/stashapp/stash/blob/develop/ui/v2.5/src/locales/en-GB.json for master-listen. Siden skal genindlæses for at aktivere ændringerne.", + "heading": "Egen oversættelse", + "option_label": "Egen oversættelse aktiveret" + }, "delete_options": { "description": "Standardindstillinger ved sletning af billeder, gallerier og scener.", "heading": "Slet indstillinger", diff --git a/ui/v2.5/src/locales/de-DE.json b/ui/v2.5/src/locales/de-DE.json index d20711f1f..b974233f2 100644 --- a/ui/v2.5/src/locales/de-DE.json +++ b/ui/v2.5/src/locales/de-DE.json @@ -54,6 +54,7 @@ "import": "Importieren…", "import_from_file": "Importieren aus Datei", "logout": "Ausloggen", + "make_primary": "Als Primärquelle festlegen", "merge": "Zusammenführen", "merge_from": "Zusammenführen aus", "merge_into": "Zusammenführen in", @@ -189,6 +190,7 @@ }, "categories": { "about": "Über", + "changelog": "Änderungsprotokoll", "interface": "Oberfläche", "logs": "Protokoll", "metadata_providers": "Metadaten-Anbieter", @@ -243,6 +245,10 @@ "username": "Benutzername", "username_desc": "Benutzername für den Zugriff auf Stash. Feld leer lassen, um Benutzerauthentifizierung zu deaktivieren" }, + "backup_directory_path": { + "description": "Verzeichnisspeicherort für SQLite-Datenbankdateisicherungen", + "heading": "Backup-Verzeichnispfad" + }, "cache_location": "Verzeichnis für den Cache", "cache_path_head": "Cache Pfad", "calculate_md5_and_ohash_desc": "Berechne MD5 Prüfsumme zusätzlich zu oshash. Aktivierung führt dazu, dass erstmalige Scans mehr Zeit benötigen. Dateibenennungshash muss auf oshash gesetzt sein, um Berechnung des MD5 zu unterbinden.", @@ -344,7 +350,7 @@ "auto_tagging": "Automatisches Tagging", "backing_up_database": "Datenbank sichern", "backup_and_download": "Führt eine Sicherung der Datenbank durch und lädt die resultierende Datei herunter.", - "backup_database": "Führt eine Sicherung der Datenbank aus, welche im selben Verzeichnis wie die Datenbank mit dem Dateinamenformat {filename_format} gespeichert wird", + "backup_database": "Führt eine Sicherung der Datenbank in den Backup-Verzeichnispfad mit dem Dateiformat {filename_format} aus", "cleanup_desc": "Suche nach fehlenden Dateien und entfernen Sie diese aus der Datenbank. Dies ist eine destruktive Aktion.", "data_management": "Datenmanagement", "defaults_set": "Standardeinstellungen wurden eingestellt und werden genutzt, wenn {action} Button auf der Aufgabenseite geklickt wurde.", @@ -420,12 +426,21 @@ "scene_tools": "Szenen-Tools" }, "ui": { + "abbreviate_counters": { + "description": "Verkürze Zähler in Karten und den Detail-Ansichten ab, zum Beispiel wird \"1831\" als \"1.8K\" abgekürzt.", + "heading": "Zähler verkürzen" + }, "basic_settings": "Grundeinstellungen", "custom_css": { "description": "Die Seite muss neu geladen werden, damit die Änderungen wirksam werden.", "heading": "Benutzerdefinierte CSS", "option_label": "Benutzerdefiniertes CSS aktiviert" }, + "custom_locales": { + "description": "Überschreibe einzelne Locale-Strings. Siehe https://github.com/stashapp/stash/blob/develop/ui/v2.5/src/locales/en-GB.json für die Hauptliste. Die Seite muss neu geladen werden, damit die Änderungen wirksam werden.", + "heading": "Benutzerdefinierte Lokalisierung", + "option_label": "Benutzerdefinierte Lokalisierung aktiviert" + }, "delete_options": { "description": "Standardeinstellungen wenn Bilder, Galerien und Szenen gelöscht werden.", "heading": "Optionen löschen", @@ -518,7 +533,7 @@ "options": { "auto_start_video": "Video automatisch starten", "auto_start_video_on_play_selected": { - "description": "Automatische Wiedergabe von ausgewählten oder zufälligen Szenenvideos von der Szenenseite", + "description": "Automatischer Start von Videos aus der Warteschlange oder bei einer Wiedergabe von ausgewählten oder zufälligen Videos von der Szenen-Seite", "heading": "Automatische Wiedergabe von ausgewählten Videos" }, "continue_playlist_default": { @@ -539,10 +554,32 @@ "description": "Anzahl der Versuche, einen Bildlauf durchzuführen, bevor zum nächsten/vorherigen Element gewechselt wird. Gilt nur für den Bildlaufmodus Schwenkung Y.", "heading": "Anzahl Scroll-Versuche vor Übergang" }, + "show_tag_card_on_hover": { + "description": "Tag-Karte anzeigen, wenn der Mauszeiger über Tag-Abzeichen bewegt wird", + "heading": "Tag-Karten-Tooltips" + }, "slideshow_delay": { "description": "Die Diashow ist in Galerien in der Wandansicht verfügbar", "heading": "Verzögerung der Diashow (Sekunden)" }, + "studio_panel": { + "heading": "Studioansicht", + "options": { + "show_child_studio_content": { + "description": "In der Studioansicht, zeige auch Inhalte von Unterstudios", + "heading": "Zeige Inhalte von Unterstudios" + } + } + }, + "tag_panel": { + "heading": "Tag-Ansicht", + "options": { + "show_child_tagged_content": { + "description": "In der Tag-Ansicht, zeige auch Inhalte der Sub-Tags", + "heading": "Zeige Sub-Tag Inhalte" + } + } + }, "title": "Benutzeroberfläche" } }, @@ -587,6 +624,7 @@ "death_date": "Todesdatum", "death_year": "Todesjahr", "descending": "Absteigend", + "description": "Beschreibung", "detail": "Detail", "details": "Details", "developmentVersion": "Entwicklungsversion", @@ -601,6 +639,7 @@ "delete_object_desc": "Möchten Sie {count, plural, one {diese {singularEntity}} other {diese {pluralEntity}}} wirklich löschen?", "delete_object_overflow": "…und {count} other {count, plural, one {{singularEntity}} other {{pluralEntity}}}.", "delete_object_title": "{count, plural, one {{singularEntity}} other {{pluralEntity}}} löschen", + "dont_show_until_updated": "Bis zum nächsten Update nicht mehr anzeigen", "edit_entity_title": "Bearbeiten von {count, plural, one {{singularEntity}} other {{pluralEntity}}}", "export_include_related_objects": "Zugehörige Objekte in den Export einbeziehen", "export_title": "Export", @@ -674,7 +713,7 @@ "unsaved_changes": "Nicht gespeicherte Änderungen. Bist du sicher dass du die Seite verlassen willst?" }, "dimensions": "Maße", - "director": "Direktor", + "director": "Regisseur", "display_mode": { "grid": "Gitter", "list": "Liste", @@ -726,6 +765,7 @@ "false": "Falsch", "favourite": "Favorit", "file": "Datei", + "file_count": "Dateianzahl", "file_info": "Datei", "file_mod_time": "Dateiänderungszeit", "files": "Dateien", @@ -733,6 +773,7 @@ "filter": "Filter", "filter_name": "Filtername", "filters": "Filter", + "folder": "Ordner", "framerate": "Bildrate", "frames_per_second": "{value} Bilder pro Sekunde", "front_page": { @@ -865,11 +906,13 @@ }, "performers": "Darsteller", "piercings": "Piercings", + "primary_file": "Primäre Datei", "queue": "Playlist", "random": "Zufällig", "rating": "Wertung", "recently_added_objects": "Kürzlich hinzugefügte {objects}", "recently_released_objects": "Kürzlich erschienene {objects}", + "release_notes": "Versionshinweise", "resolution": "Auflösung", "scene": "Szene", "sceneTagger": "Szenen-Tagger", @@ -919,9 +962,10 @@ "migration_failed_error": "Der folgende Fehler ist bei der Migration der Datenbank aufgetreten:", "migration_failed_help": "Bitte führe nötige Korrekturen durch und probiere es erneut. Falls du nicht weißt was du falsch gemacht hast, helfen wir gerne auf {discordLink}. Solltest du dir sicher sein einen Bug gefunden zu haben, schau doch mal auf {githubLink} vorbei.", "migration_irreversible_warning": "Der Migrationsprozess des Datenbankschemas ist irreversibel. Nachdem sie ausgeführt wurde, ist deine Datenbank inkompatibel mit älteren Versionen von Stash.", + "migration_notes": "Anmerkungen zur Migration", "migration_required": "Migration nötig", "perform_schema_migration": "Führe Migration des Datenbankschemas durch", - "schema_too_old": "Das Schema deiner aktuellen Stash-Datenbank ist von Version {databaseSchema} und muss auf die Version {appSchema} migriert werden. Die aktuelle Version von Stash wird nicht ohne Migration der Datenbank funktionieren können." + "schema_too_old": "Das Schema deiner aktuellen Stash-Datenbank ist Version {databaseSchema} und muss auf Version {appSchema} migriert werden. Die aktuelle Version von Stash wird nicht ohne Migration der Datenbank funktionieren können. Wenn Sie nicht migrieren möchten, müssen Sie ein Downgrade auf eine Version durchführen, die Ihrem Datenbankschema entspricht." }, "paths": { "database_filename_empty_for_default": "Datenbank-Dateiname (Leer für Standardwert)", @@ -1019,5 +1063,6 @@ "videos": "Videos", "view_all": "Alle ansehen", "weight": "Gewicht", - "years_old": "Jahre alt" + "years_old": "Jahre alt", + "zip_file_count": "Anzahl der Zip-Dateien" } diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index 445359512..52d19f2eb 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -67,6 +67,7 @@ "play_selected": "Play selected", "preview": "Preview", "previous_action": "Back", + "reassign": "Reassign", "refresh": "Refresh", "reload_plugins": "Reload plugins", "reload_scrapers": "Reload scrapers", @@ -98,11 +99,13 @@ "set_image": "Set image…", "show": "Show", "show_configuration": "Show Configuration", + "split": "Split", "skip": "Skip", "stop": "Stop", "submit": "Submit", "submit_stash_box": "Submit to Stash-Box", "submit_update": "Submit update", + "swap": "Swap", "tasks": { "clean_confirm_message": "Are you sure you want to Clean? This will delete database information and generated content for all scenes and galleries that are no longer found in the filesystem.", "dry_mode_selected": "Dry Mode selected. No actual deleting will take place, only logging.", @@ -124,6 +127,7 @@ "birth_year": "Birth Year", "birthdate": "Birthdate", "bitrate": "Bit Rate", + "between_and": "and", "captions": "Captions", "career_length": "Career Length", "component_tagger": { @@ -415,7 +419,7 @@ "escape_chars": "Use \\ to escape literal characters", "filename": "Filename", "filename_pattern": "Filename Pattern", - "ignore_organized": "Ignore organised scenes", + "ignore_organized": "Ignore organized scenes", "ignored_words": "Ignored words", "matches_with": "Matches with {i}", "select_parser_recipe": "Select Parser Recipe", @@ -426,12 +430,21 @@ "scene_tools": "Scene Tools" }, "ui": { + "abbreviate_counters": { + "description": "Abbreviate counters in cards and details view pages, for example \"1831\" will get formated to \"1.8K\".", + "heading": "Abbreviate counters" + }, "basic_settings": "Basic Settings", "custom_css": { "description": "Page must be reloaded for changes to take effect.", "heading": "Custom CSS", "option_label": "Custom CSS enabled" }, + "custom_javascript": { + "description": "Page must be reloaded for changes to take effect.", + "heading": "Custom Javascript", + "option_label": "Custom Javascript enabled" + }, "custom_locales": { "description": "Override individual locale strings. See https://github.com/stashapp/stash/blob/develop/ui/v2.5/src/locales/en-GB.json for the master list. Page must be reloaded for changes to take effect.", "heading": "Custom localisation", @@ -457,12 +470,25 @@ "description": "Remove the ability to create new objects from the dropdown selectors", "heading": "Disable dropdown create" }, + "rating_system": { + "type": { + "label": "Rating System Type", + "options": { + "stars": "Stars", + "decimal": "Decimal" + } + }, + "star_precision": { + "label": "Rating Star Precision", + "options": { + "full": "Full", + "half": "Half", + "quarter": "Quarter" + } + } + }, "heading": "Editing" }, - "abbreviate_counters": { - "description": "Abbreviate counters in cards and details view pages, for example \"1831\" will get formated to \"1.8K\".", - "heading": "Abbreviate counters" - }, "funscript_offset": { "description": "Time offset in milliseconds for interactive scripts playback.", "heading": "Funscript Offset (ms)" @@ -505,9 +531,9 @@ "description": "Show or hide different types of content on the navigation bar", "heading": "Menu Items" }, - "show_tag_card_on_hover": { - "description": "Show tag card when hovering tag badges", - "heading": "Tag card tooltips" + "minimum_play_percent": { + "description": "The percentage of time in which a scene must be played before its play count is incremented.", + "heading": "Minumum Play Percent" }, "performers": { "options": { @@ -535,6 +561,7 @@ "scene_player": { "heading": "Scene Player", "options": { + "always_start_from_beginning": "Always start video from beginning", "auto_start_video": "Auto-start video", "auto_start_video_on_play_selected": { "description": "Auto-start scene videos when playing from queue, or playing selected or random from Scenes page", @@ -544,7 +571,8 @@ "description": "Play next scene in queue when video finishes", "heading": "Continue playlist by default" }, - "show_scrubber": "Show Scrubber" + "show_scrubber": "Show Scrubber", + "track_activity": "Track Activity" } }, "scene_wall": { @@ -558,19 +586,14 @@ "description": "Number of times to attempt to scroll before moving to the next/previous item. Only applies for Pan Y scroll mode.", "heading": "Scroll attempts before transition" }, + "show_tag_card_on_hover": { + "description": "Show tag card when hovering tag badges", + "heading": "Tag card tooltips" + }, "slideshow_delay": { "description": "Slideshow is available in galleries when in wall view mode", "heading": "Slideshow Delay (seconds)" }, - "tag_panel": { - "heading": "Tag view", - "options": { - "show_child_tagged_content": { - "description": "In the tag view, display content from the subtags as well", - "heading": "Display subtag content" - } - } - }, "studio_panel": { "heading": "Studio view", "options": { @@ -580,10 +603,20 @@ } } }, + "tag_panel": { + "heading": "Tag view", + "options": { + "show_child_tagged_content": { + "description": "In the tag view, display content from the subtags as well", + "heading": "Display subtag content" + } + } + }, "title": "User Interface" } }, "configuration": "Configuration", + "resume_time": "Resume Time", "countables": { "files": "{count, plural, one {File} other {Files}}", "galleries": "{count, plural, one {Gallery} other {Galleries}}", @@ -630,6 +663,7 @@ "developmentVersion": "Development Version", "dialogs": { "aliases_must_be_unique": "aliases must be unique", + "create_new_entity": "Create new {entity}", "delete_alert": "The following {count, plural, one {{singularEntity}} other {{pluralEntity}}} will be deleted permanently:", "delete_confirm": "Are you sure you want to delete {entityName}?", "delete_entity_desc": "{count, plural, one {Are you sure you want to delete this {singularEntity}? Unless the file is also deleted, this {singularEntity} will be re-added when scan is performed.} other {Are you sure you want to delete these {pluralEntity}? Unless the files are also deleted, these {pluralEntity} will be re-added when scan is performed.}}", @@ -640,10 +674,10 @@ "delete_object_desc": "Are you sure you want to delete {count, plural, one {this {singularEntity}} other {these {pluralEntity}}}?", "delete_object_overflow": "…and {count} other {count, plural, one {{singularEntity}} other {{pluralEntity}}}.", "delete_object_title": "Delete {count, plural, one {{singularEntity}} other {{pluralEntity}}}", + "dont_show_until_updated": "Don't show until next update", "edit_entity_title": "Edit {count, plural, one {{singularEntity}} other {{pluralEntity}}}", "export_include_related_objects": "Include related objects in export", "export_title": "Export", - "dont_show_until_updated": "Don't show until next update", "lightbox": { "delay": "Delay (Sec)", "display_mode": { @@ -665,11 +699,20 @@ "zoom": "Zoom" } }, + "merge": { + "destination": "Destination", + "empty_results": "Destination field values will be unchanged.", + "source": "Source" + }, "merge_tags": { "destination": "Destination", "source": "Source" }, "overwrite_filter_confirm": "Are you sure you want to overwrite existing saved query {entityName}?", + "reassign_entity_title": "{count, plural, one {Reassign {singularEntity}} other {Reassign {pluralEntity}}}", + "reassign_files": { + "destination": "Reassign to" + }, "scene_gen": { "force_transcodes": "Force Transcode generation", "force_transcodes_tooltip": "By default, transcodes are only generated when the video file is not supported in the browser. When enabled, transcodes will be generated even when the video file appears to be supported in the browser.", @@ -770,6 +813,7 @@ "file_info": "File Info", "file_mod_time": "File Modification Time", "files": "files", + "files_amount": "{value} files", "filesize": "File Size", "filter": "Filter", "filter_name": "Filter name", @@ -807,6 +851,7 @@ }, "hasMarkers": "Has Markers", "height": "Height", + "height_cm": "Height (cm)", "help": "Help", "ignore_auto_tag": "Ignore Auto Tag", "image": "Image", @@ -819,6 +864,7 @@ "interactive": "Interactive", "interactive_speed": "Interactive speed", "isMissing": "Is Missing", + "last_played_at": "Last Played At", "library": "Library", "loading": { "generic": "Loading…" @@ -836,6 +882,8 @@ "age": "{age} {years_old}", "age_context": "{age} {years_old} in this scene" }, + "play_count": "Play Count", + "play_duration": "Play Duration", "phash": "PHash", "stream": "Stream", "video_codec": "Video Codec" @@ -916,8 +964,12 @@ "release_notes": "Release Notes", "resolution": "Resolution", "scene": "Scene", + "scene_date": "Date of Scene", + "scene_created_at": "Scene Created At", + "scene_updated_at": "Scene Updated At", "sceneTagger": "Scene Tagger", "sceneTags": "Scene Tags", + "scene_code": "Studio Code", "scene_count": "Scene Count", "scene_id": "Scene ID", "scenes": "Scenes", @@ -1020,6 +1072,7 @@ "submit_update": "Already exists in {endpoint_name}" }, "statistics": "Statistics", + "stash_id_endpoint": "Stash ID Endpoint", "stats": { "image_size": "Images size", "scenes_duration": "Scenes duration", @@ -1046,9 +1099,11 @@ "default_filter_set": "Default filter set", "delete_past_tense": "Deleted {count, plural, one {{singularEntity}} other {{pluralEntity}}}", "generating_screenshot": "Generating screenshot…", + "merged_scenes": "Merged scenes", "merged_tags": "Merged tags", - "rescanning_entity": "Rescanning {count, plural, one {{singularEntity}} other {{pluralEntity}}}…", + "reassign_past_tense": "File reassigned", "removed_entity": "Removed {count, plural, one {{singularEntity}} other {{pluralEntity}}}", + "rescanning_entity": "Rescanning {count, plural, one {{singularEntity}} other {{pluralEntity}}}…", "saved_entity": "Saved {entity}", "started_auto_tagging": "Started auto tagging", "started_generating": "Started generating", @@ -1063,7 +1118,10 @@ "url": "URL", "videos": "Videos", "view_all": "View All", + "play_count": "Play Count", + "play_duration": "Play Duration", "weight": "Weight", + "weight_kg": "Weight (kg)", "years_old": "years old", "zip_file_count": "Zip File Count" } diff --git a/ui/v2.5/src/locales/et-EE.json b/ui/v2.5/src/locales/et-EE.json new file mode 100644 index 000000000..db8c48734 --- /dev/null +++ b/ui/v2.5/src/locales/et-EE.json @@ -0,0 +1,1127 @@ +{ + "actions": { + "add": "Lisa", + "add_directory": "Lisa Kaust", + "add_entity": "Lisa {entityType}", + "add_to_entity": "Lisa {entityType}-sse", + "allow": "Luba", + "allow_temporarily": "Luba ajutiselt", + "apply": "Rakenda", + "auto_tag": "Märgi Automaatselt", + "backup": "Varunda", + "browse_for_image": "Otsi pilti…", + "cancel": "Tühista", + "clean": "Puhasta", + "clear": "Eemalda", + "clear_back_image": "Eemalda tagapilt", + "clear_front_image": "Eemalda esipilt", + "clear_image": "Eemalda Pilt", + "close": "Sulge", + "confirm": "Kinnita", + "continue": "Jätka", + "create": "Loo", + "create_entity": "Loo {entityType}", + "create_marker": "Loo Marker", + "created_entity": "Loodud {entity_type}: {entity_name}", + "customise": "Kohanda", + "delete": "Kustuta", + "delete_entity": "Kustuta {entityType}", + "delete_file": "Kustuta fail", + "delete_file_and_funscript": "Kustuta fail (ja funscript)", + "delete_generated_supporting_files": "Kustuta genereeritud toetusfailid", + "delete_stashid": "Kustuta StashID", + "disallow": "Keela", + "download": "Lae alla", + "download_backup": "Lae varundus alla", + "edit": "Muuda", + "edit_entity": "Muuda {entityType}", + "export": "Ekspordi…", + "export_all": "Ekspordi kõik…", + "find": "Otsi", + "finish": "Lõpeta", + "from_file": "Failist…", + "from_url": "URL-ilt…", + "full_export": "Täielik Eksportimine", + "full_import": "Täielik Importimine", + "generate": "Genereeri", + "generate_thumb_default": "Genereri vaikepisipilt", + "generate_thumb_from_current": "Genereeri pisipilt praegusest", + "hash_migration": "hashi migratsioon", + "hide": "Peida", + "hide_configuration": "Peida Seadistus", + "identify": "Tuvasta", + "ignore": "Ignoreeri", + "import": "Impordi…", + "import_from_file": "Impordi failist", + "logout": "Logi välja", + "make_primary": "Määra Peamiseks", + "merge": "Liida", + "merge_from": "Liida teisest", + "merge_into": "Liida teise", + "next_action": "Järgmine", + "not_running": "ei jookse", + "open_in_external_player": "Ava välises mängijas", + "open_random": "Ava Suvaline", + "overwrite": "Kirjuta üle", + "play_random": "Mängi Suvaline", + "play_selected": "Mängi valitud", + "preview": "Eelvaade", + "previous_action": "Tagasi", + "reassign": "Määra Ümber", + "refresh": "Värskenda", + "reload_plugins": "Lae pluginad uuesti", + "reload_scrapers": "Lae kraapijad uuesti", + "remove": "Eemalda", + "remove_from_gallery": "Eemalda Galeriist", + "rename_gen_files": "Nimeta genereeritud failid ümber", + "rescan": "Skaneeri uuesti", + "reshuffle": "Sega uuesti", + "running": "jookseb", + "save": "Salvesta", + "save_delete_settings": "Kasuta neid sätteid kustutamisel tavasätetena", + "save_filter": "Salvesta filter", + "scan": "Skaneeri", + "scrape": "Kraabi", + "scrape_query": "Kraapimispäring", + "scrape_scene_fragment": "Kraabi fragmentide kaupa", + "scrape_with": "Kraabi kasutades…", + "search": "Otsi", + "select_all": "Vali Kõik", + "select_entity": "Vali {entityType}", + "select_folders": "Vali kaustad", + "select_none": "Vali Mitte Midagi", + "selective_auto_tag": "Valikuline Automaatne Märkija", + "selective_clean": "Valikuline Puhastus", + "selective_scan": "Valikuline Skaneerimine", + "set_as_default": "Määra vaikeväärtuseks", + "set_back_image": "Tagapilt…", + "set_front_image": "Esipilt…", + "set_image": "Seadista pilt…", + "show": "Näita", + "show_configuration": "Näita Seadistust", + "skip": "Jäta vahele", + "split": "Jaga Kaheks", + "stop": "Stop", + "submit": "Esita", + "submit_stash_box": "Esita Stash-Kasti", + "submit_update": "Esita uuendus", + "swap": "Vaheta", + "tasks": { + "clean_confirm_message": "Kas oled kindel, et tahad Puhastada? See kustutab andmebaasi ja genereeritud sisu kõikide stseenide ja galeriide jaoks, mida enam failisüsteemis ei leidu.", + "dry_mode_selected": "Kuiv režiim valitud. Tegelikku kustutamist ei toimu, ainult logidesse kirjutamine.", + "import_warning": "Kas oled kindel, et tahad importida? See kustutab andmebaasi ja impordib ekporditud metaandmed uuesti." + }, + "temp_disable": "Keela ajutiselt…", + "temp_enable": "Luba ajutiselt…", + "unset": "Tühista", + "use_default": "Kasuta vaikeseadet", + "view_random": "Vaata Suvalist" + }, + "actions_name": "Tegevused", + "age": "Vanus", + "aliases": "Varjunimed", + "all": "kõik", + "also_known_as": "Tuntud ka kui", + "ascending": "Kasvav", + "average_resolution": "Keskmine Resolutsioon", + "between_and": "ja", + "birth_year": "Sünniaasta", + "birthdate": "Sünnikuupäev", + "bitrate": "Bitikiirus", + "captions": "Subtiitrid", + "career_length": "Karjääri Pikkus", + "component_tagger": { + "config": { + "active_instance": "Aktiivne stash-kasti eksemplar:", + "blacklist_desc": "Musta nimekirja üksused on päringutest välja jäetud. Pane tähele, et need on regulaaravaldised ja tõstutundetud. Teatud tähemärgid tuleb eemaldada kaldkriipsuga: {chars_require_escape}", + "blacklist_label": "Must nimekiri", + "query_mode_auto": "Auto", + "query_mode_auto_desc": "Kasutab metaandmeid, kui need olemas on, või failinime", + "query_mode_dir": "Kaust", + "query_mode_dir_desc": "Kasutab ainult videofaili kausta", + "query_mode_filename": "Failinimi", + "query_mode_filename_desc": "Kasutab ainult failinime", + "query_mode_label": "Päringurežiim", + "query_mode_metadata": "Metaandmed", + "query_mode_metadata_desc": "Kasutab ainult metaandmeid", + "query_mode_path": "Failitee", + "query_mode_path_desc": "Kasutab kogu failiteed", + "set_cover_desc": "Asenda stseeni kaanepilt, kui seda õnnestub leida.", + "set_cover_label": "Määra stseeni kaanepilt", + "set_tag_desc": "Ühenda stseenile külge silte, kas olemasolevate siltide ülekirjutamise või liitmise kaudu.", + "set_tag_label": "Määra sildid", + "show_male_desc": "Vali, kas meesnäitlejad on määramiseks saadaval.", + "show_male_label": "Näita meesnäitlejaid", + "source": "Allikas" + }, + "noun_query": "Päring", + "results": { + "duration_off": "Kestus on vähemalt {number}s vale", + "duration_unknown": "Kestus teadmata", + "fp_found": "{fpCount, plural, =0 {Uusi sõrmejälje kattuvusi ei leitud} other {# uut sõrmejälje kattuvust leitud}}", + "fp_matches": "Kestus klapib", + "fp_matches_multi": "Kestus klapib {matchCount}/{durationsLength} sõrmejäljel", + "hash_matches": "{hash_type} klapib", + "match_failed_already_tagged": "Stseen juba sildistatud", + "match_failed_no_result": "Vasteid ei leitud", + "match_success": "Stseen edukalt sildistatud", + "phash_matches": "{count} PHashi kattuvust", + "unnamed": "Nimeta" + }, + "verb_match_fp": "Leia Sõrmejälje Kattuvusi", + "verb_matched": "Kokkusobitatud", + "verb_scrape_all": "Kraabi Kõikjalt", + "verb_submit_fp": "Esita {fpCount, plural, one{# Sõrmejälg} other{# Sõrmejälge}}", + "verb_toggle_config": "{toggle} {configuration}", + "verb_toggle_unmatched": "{toggle} kokkusobitamata stseenid" + }, + "config": { + "about": { + "build_hash": "Ehituse hash:", + "build_time": "Ehituse aeg:", + "check_for_new_version": "Kontrolli värskendusi", + "latest_version": "Uusim Versioon", + "latest_version_build_hash": "Uusima Versiooni Ehituse Hash:", + "new_version_notice": "[UUS]", + "stash_discord": "Liitu meie {url}i kanaliga", + "stash_home": "Stashi kodu {url}-is", + "stash_open_collective": "Toeta meid läbi {url}-i", + "stash_wiki": "Stashi {url} leht", + "version": "Versioon" + }, + "application_paths": { + "heading": "Rakenduse Failiteed" + }, + "categories": { + "about": "Lisainfo", + "changelog": "Muudatuste nimekiri", + "interface": "Kasutajaliides", + "logs": "Logid", + "metadata_providers": "Metaandmete Pakkujad", + "plugins": "Pluginad", + "scraping": "Kraapimine", + "security": "Turvalisus", + "services": "Teenused", + "system": "Süsteem", + "tasks": "Ülesanded", + "tools": "Tööriistad" + }, + "dlna": { + "allow_temp_ip": "Luba {tempIP}", + "allowed_ip_addresses": "Lubatud IP aadressid", + "allowed_ip_temporarily": "Lubatud IP ajutiselt", + "default_ip_whitelist": "Vaikimisi IP Valge Nimekiri", + "default_ip_whitelist_desc": "Vaikimisi IP aadressid lubavad DLNA ligipääsu. Kasuta {wildcard}, et lubada kõiki IP aadresse.", + "disabled_dlna_temporarily": "DLNA ajutiselt keelatud", + "disallowed_ip": "Keelatud IP", + "enabled_by_default": "Vaikimisi lubatud", + "enabled_dlna_temporarily": "DLNA ajutiselt lubatud", + "network_interfaces": "Kasutajaliidesed", + "network_interfaces_desc": "Kasutajaliidesed DLNA serveri paljastamiseks. Tühi nimekiri lubab jooksutamist kõigil kasutajaliidestel. Vajalik DLNA taaskäivitus peale muutmist.", + "recent_ip_addresses": "Hiljutised IP aadressid", + "server_display_name": "Serveri Nimi", + "server_display_name_desc": "DLNA server nimi. Vaikimisi {server_name}, kui midagi pole sisestatud.", + "successfully_cancelled_temporary_behaviour": "Edukalt tühistatud ajutine käitumine", + "until_restart": "restardini" + }, + "general": { + "auth": { + "api_key": "API Võti", + "api_key_desc": "API võti välistele süsteemidele. Nõutud ainult siis, kui kasutajanimi/parool on sätitud. Kasutajanimi peab olema salvestatud enne API võtme genereerimist.", + "authentication": "Autentimine", + "clear_api_key": "Puhasta API võti", + "credentials": { + "description": "Mandaat Stashile ligipääsu piiramiseks.", + "heading": "Mandaat" + }, + "generate_api_key": "Genereeri API võti", + "log_file": "Logi fail", + "log_file_desc": "Failitee failini, kuhu logid sisestada. Jäta tühjaks, kui soovid logide salvestamise välja lülitada. Vajab taaskäivitust.", + "log_http": "Logi http ligipääs", + "log_http_desc": "Avaldab http ligipääsu logid terminali. Vajab taaskäivitust.", + "log_to_terminal": "Logi terminali", + "log_to_terminal_desc": "Avaldab logid lisaks failile ka terminalis. Alati sisselülitatud, kui logimine faili on keelatud. Vajab taaskäivitust.", + "maximum_session_age": "Maksimaalne Sessiooni Vanus", + "maximum_session_age_desc": "Maksimaalne paigalseisuaeg enne kui sessioon aegub, sekundites.", + "password": "Parool", + "password_desc": "Parool Stashi pääsemiseks. Jäta tühjaks, kui soovid sisselogimise keelata", + "stash-box_integration": "Stash-kasti integratsioon", + "username": "Kasutajanimi", + "username_desc": "Kasutajanimi Stashi pääsemiseks. Jäta tühjaks, kui soovid sisselogimise keelata" + }, + "backup_directory_path": { + "description": "Failitee SQLite andmebaasi varundusfailide jaoks", + "heading": "Varunduse Failitee" + }, + "cache_location": "Failitee vahemäluni", + "cache_path_head": "Vahemälu Failitee", + "calculate_md5_and_ohash_desc": "Kalkuleeri MD5 checksum lisaks oshashile. Lubamine põhjustab aeglasemat esmast skaneerimist. Faili nimetuse hash peab olema sätitud oshashiks, et keelata MD5 kalkuleerimine.", + "calculate_md5_and_ohash_label": "Kalkuleeri MD5 videote jaoks", + "check_for_insecure_certificates": "Otsi ebaturvalisi sertifikaate", + "check_for_insecure_certificates_desc": "Mõned lehed kasutavad ebaturvalisi ssl sertifikaate. Kui märkimata, kraapija jätab sertifikaadi kontrollimise vahele ning võimaldab nendelt lehtedelt andmeid kraapida. Kui kraapimise ajal esineb sertifikaadivigu, eemalda linnuke.", + "chrome_cdp_path": "Chrome CDP tee", + "chrome_cdp_path_desc": "Failitee Chrome käivitajani, või kaugaadress (algab http:// või https:// -iga, näiteks http://localhost:9222/json/version) Chrome'i eksemplarini.", + "create_galleries_from_folders_desc": "Kui lubatud, loob galeriisid pilte sisaldavatest kaustadest.", + "create_galleries_from_folders_label": "Loo galeriisid kaustadest, mis sisaldavad pilte", + "db_path_head": "Andmebaasi Failitee", + "directory_locations_to_your_content": "Failitee asukohad sisule", + "excluded_image_gallery_patterns_desc": "Pildi- ja galeriifailide/teede regexpid, mida skannimisest välja jätta ja Clean'i lisada", + "excluded_image_gallery_patterns_head": "Välistatud Pildi/Galerii Mustrid", + "excluded_video_patterns_desc": "Videofailide/teede regexpid, mida skannimisest välja jätta ja Clean'i lisada", + "excluded_video_patterns_head": "Välistatud Video Mustrid", + "gallery_ext_desc": "Komadega eraldatud faililaiendite loend, mis tuvastatakse galerii ZIP-failidena.", + "gallery_ext_head": "Galerii zip Laiendused", + "generated_file_naming_hash_desc": "Kasutage failide nimetamiseks MD5 või oshashi. Selle muutmiseks on vaja, et kõikides stseenides oleks kohaldatav MD5/oshash väärtus täidetud. Pärast selle väärtuse muutmist tuleb olemasolevad loodud failid migreerida või uuesti genereerida. Vaadake üleviimise kohta lehekülge Ülesanded.", + "generated_file_naming_hash_head": "Genereeritud faili nimetamise hash", + "generated_files_location": "Loodud failide (stseenimarkerid, stseeni eelvaated, spraidid jne) asukoht Failiteel", + "generated_path_head": "Genereeritud Failitee", + "hashing": "Hashimine", + "image_ext_desc": "Komadega eraldatud faililaiendite loend, mis tuvastatakse piltidena.", + "image_ext_head": "Pildilaiendused", + "include_audio_desc": "Kaasa eelvaadete loomisel helivoog.", + "include_audio_head": "Kaasa heli", + "logging": "Logimine", + "maximum_streaming_transcode_size_desc": "Transkodeeritud voogude maksimaalne suurus", + "maximum_streaming_transcode_size_head": "Maksimaalne voogesituse ümberkodeerimise suurus", + "maximum_transcode_size_desc": "Loodud ümberkoodimiste maksimaalne suurus", + "maximum_transcode_size_head": "Maksimaalne ümberkodeerimise suurus", + "metadata_path": { + "description": "Kataloogi asukoht, mida kasutatakse täieliku ekspordi või impordi teostamisel", + "heading": "Metaandmete Failitee" + }, + "number_of_parallel_task_for_scan_generation_desc": "Automaatse tuvastamise jaoks määra 0. Hoiatus, kui tehakse rohkem toiminguid, kui on vaja 100% protsessori kasutuse saavutamiseks, väheneb jõudlus ja võib esineda muid probleeme.", + "number_of_parallel_task_for_scan_generation_head": "Paralleelsete skaneerimise/genereerimise ülesannete arv", + "parallel_scan_head": "Paralleelne Skaneerimine/Generatsioon", + "preview_generation": "Eelvaate Genereerimine", + "python_path": { + "description": "Pythoni käivitataja asukoht. Kasutatakse skriptipõhiste kraapijate ja pluginate jaoks. Kui see on tühi, lahendatakse python keskkonnast", + "heading": "Pythoni Failitee" + }, + "scraper_user_agent": "Kraapija Kasutajaagent", + "scraper_user_agent_desc": "Kasutajaagendi string, mida kasutatakse kraapimise HTTP-päringute käigus", + "scrapers_path": { + "description": "Failitee kraapijate sättefailide jaoks", + "heading": "Kraapijate Failitee" + }, + "scraping": "Kraapimine", + "sqlite_location": "Failitee asukoht SQLite andmebaasi jaoks (vajab taaskäivitust)", + "video_ext_desc": "Komadega eraldatud faililaiendite loend, mis tuvastatakse videotena.", + "video_ext_head": "Videolaiendused", + "video_head": "Video" + }, + "library": { + "exclusions": "Välistused", + "gallery_and_image_options": "Galerii ja Piltide sätted", + "media_content_extensions": "Media sisu laiendused" + }, + "logs": { + "log_level": "Logimise tase" + }, + "plugins": { + "hooks": "Hookid", + "triggers_on": "Sisselülitatud päästikud" + }, + "scraping": { + "entity_metadata": "{entityType} Metaandmed", + "entity_scrapers": "{entityType} kraapijad", + "excluded_tag_patterns_desc": "Sildinimede regexpid, mida kraapise tulemustest välja jätta", + "excluded_tag_patterns_head": "Välistatud Siltide Mustrid", + "scraper": "Kraapija", + "scrapers": "Kraapijad", + "search_by_name": "Otsi nime järgi", + "supported_types": "Toetatud tüübid", + "supported_urls": "URL-id" + }, + "stashbox": { + "add_instance": "Lisa stash-kasti eksemplar", + "api_key": "API võti", + "description": "Stash-box hõlbustab stseenide ja esinejate automaatset märgistamist sõrmejälgede ja failinimede põhjal.\nLõpp-punkti ja API võtme leiad oma konto lehelt stash-kasti eksemplaris. Nimed on nõutavad, kui lisatakse rohkem kui üks eksemplar.", + "endpoint": "Lõpp-punkt", + "graphql_endpoint": "GraphQL lõpp-punkt", + "name": "Nimi", + "title": "Stash-kasti Lõpp-punktid" + }, + "system": { + "transcoding": "Ümbertöötlemine" + }, + "tasks": { + "added_job_to_queue": "{operation_name} lisatud tööde järjekorda", + "auto_tag": { + "auto_tagging_all_paths": "Automaatne Märkimine kõikidel failiteedel", + "auto_tagging_paths": "Automaatne Märkimine järgnevatel failiteedel" + }, + "auto_tag_based_on_filenames": "Automaatselt märgi sisu vastavalt failinimedele.", + "auto_tagging": "Automaatne Märkimine", + "backing_up_database": "Andmebaasi varundamine", + "backup_and_download": "Teeb andmebaasist varukoopia ja laadib saadud faili alla.", + "backup_database": "Varundab andmebaasi varundamise failiteele, failinimega vormingus {filename_format}", + "cleanup_desc": "Kontrolli puuduvaid faile ja eemalda need andmebaasist. See on hävitav tegevus.", + "data_management": "Andmehaldus", + "defaults_set": "Vaikesätted on määratud ja neid kasutatakse, kui klõpsate lehel Ülesanded nupul {action}.", + "dont_include_file_extension_as_part_of_the_title": "Ärge lisage pealkirja osana faililaiendit", + "empty_queue": "Praegu ei tööta ühtegi ülesannet.", + "export_to_json": "Ekspordib andmebaasi sisu metaandmete failiteele JSON-vormingus.", + "generate": { + "generating_from_paths": "Genereeri stseenide jaoks järgnevatelt failiteedelt", + "generating_scenes": "Genereerimine {num} {scene} jaoks" + }, + "generate_desc": "Genereeri toetavad pildi-, sprite-, video-, vtt- ja muud failid.", + "generate_phashes_during_scan": "Genereeri nähtavaid hashe", + "generate_phashes_during_scan_tooltip": "Duplikaatide eemaldamiseks ja stseenide tuvastamiseks.", + "generate_previews_during_scan": "Genereeri animeeritud eelvaateid", + "generate_previews_during_scan_tooltip": "Genereeri animeeritud WebP eelvaateid, nõutav ainult siis, kui eelvaate tüüp on seatud väärtusele Animeeritud Pilt.", + "generate_sprites_during_scan": "Genereeri puhastusspriite", + "generate_thumbnails_during_scan": "Genereeri piltide jaoks pisipilte", + "generate_video_previews_during_scan": "Genereeri eelvaateid", + "generate_video_previews_during_scan_tooltip": "Genereeri video eelvaateid, mis esitatakse kursorit stseeni kohal hoides", + "generated_content": "Genereeritud Sisu", + "identify": { + "and_create_missing": "ja loo puuduv", + "create_missing": "Loo puuduv", + "default_options": "Vaikesätted", + "description": "Stseeni metaandmete automaatne määramine stash-kasti ja kraapija allikate abil.", + "explicit_set_description": "Kui allikaspetsiifilistes suvandites neid ei alistata, kasutatakse järgmisi valikuid.", + "field": "Väli", + "field_behaviour": "{strategy} {field}", + "field_options": "Välja Valikud", + "heading": "Tuvastamine", + "identifying_from_paths": "Stseenide tuvastamine järgmistelt failiteedelt", + "identifying_scenes": "Tuvastan {num} {scene}", + "include_male_performers": "Kaasa meesnäitlejaid", + "set_cover_images": "Määra kaanepildid", + "set_organized": "Määra organiseeritud silt", + "source": "Allikas", + "source_options": "{source} Sätted", + "sources": "Allikad", + "strategy": "Strateegia" + }, + "import_from_exported_json": "Impordi eksporditud JSON-ist metaandmete kataloogist. Kustutab olemasoleva andmebaasi.", + "incremental_import": "Järkjärguline import esitatud eksporditud ZIP-failist.", + "job_queue": "Ülesannete Järjekord", + "maintenance": "Hooldus", + "migrate_hash_files": "Kasutatakse pärast genereeritud faili nimetamise hashi muutmist olemasolevate loodud failide ümbernimetamiseks uuele hashivormingule.", + "migrations": "Migreerimised", + "only_dry_run": "Tee ainult kuivjooks. Ära eemalda midagi", + "plugin_tasks": "Plugina Ülesanded", + "scan": { + "scanning_all_paths": "Kõikide failiteede skaneerimine", + "scanning_paths": "Järgnevate failiteede skaneerimine" + }, + "scan_for_content_desc": "Skaneeri uue sisu leidmiseks ja andmebaasi lisamiseks.", + "set_name_date_details_from_metadata_if_present": "Määra nimi, kuupäev, detailid failisisestest metaandmetest" + }, + "tools": { + "scene_duplicate_checker": "Duplikaatstseenide Kontroll", + "scene_filename_parser": { + "add_field": "Lisa Väli", + "capitalize_title": "Pealkirja kirjutamine suurtähtedega", + "display_fields": "Kuva väljad", + "escape_chars": "Literaalsete märkide vältimiseks kasutage \\", + "filename": "Failinimi", + "filename_pattern": "Failinime Muster", + "ignore_organized": "Ignoreeri organiseeritud stseene", + "ignored_words": "Ignoreeritud sõnad", + "matches_with": "Kattub {i}-ga", + "select_parser_recipe": "Valige Parser-i Retsept", + "title": "Stseeni Failinimede Parser", + "whitespace_chars": "Tühikumärgid", + "whitespace_chars_desc": "Need märgid asendatakse pealkirjas tühikutega" + }, + "scene_tools": "Stseeni Tööriistad" + }, + "ui": { + "abbreviate_counters": { + "description": "Lühenda loendureid kaartidel ja üksikasjade vaatamise lehtedel, näiteks \"1831\" vormindatakse kujule \"1,8K\".", + "heading": "Loendurite lühendamine" + }, + "basic_settings": "Põhisätted", + "custom_css": { + "description": "Muudatuste jõustumiseks tuleb leht uuesti laadida.", + "heading": "Kohandatud CSS", + "option_label": "Kohandatud CSS lubatud" + }, + "custom_javascript": { + "description": "Pead muudatuste nägemiseks lehe uuesti laadima.", + "heading": "Kohandatud Javascript", + "option_label": "Kohandatud Javascript lubatud" + }, + "custom_locales": { + "description": "Muutke üksikuid keele stringe. Põhiloendi leiate aadressilt https://github.com/stashapp/stash/blob/develop/ui/v2.5/src/locales/en-GB.json. Muudatuste jõustumiseks tuleb leht uuesti laadida.", + "heading": "Kohandatud tõlge", + "option_label": "Kohandatud tõlge lubatud" + }, + "delete_options": { + "description": "Vaikesätted piltide, galeriide ja stseenide kustutamisel.", + "heading": "Kustutamissätted", + "options": { + "delete_file": "Kustuta fail vaikesättena", + "delete_generated_supporting_files": "Kustuta genereeritud toetusfailid vaikesättena" + } + }, + "desktop_integration": { + "desktop_integration": "Töölaua Integratsioon", + "notifications_enabled": "Luba Teavitused", + "send_desktop_notifications_for_events": "Saada töölaua teated sündmuste korral", + "skip_opening_browser": "Jäta Brauseri Avamine Vahele", + "skip_opening_browser_on_startup": "Jäta käivitamise ajal brauseri automaatne avamine vahele" + }, + "editing": { + "disable_dropdown_create": { + "description": "Eemaldage rippmenüü valijatest võimalus luua uusi objekte", + "heading": "Keela rippmenüü loomine" + }, + "heading": "Redigeerimine", + "rating_system": { + "star_precision": { + "label": "Tähtedega Hindamise Täpsus", + "options": { + "full": "Täis", + "half": "Pool", + "quarter": "Neljandik" + } + }, + "type": { + "label": "Hindamissüsteemi Tüüp", + "options": { + "decimal": "Komakohaga", + "stars": "Tähed" + } + } + } + }, + "funscript_offset": { + "description": "Interaktiivsete skriptide taasesituse aja nihe millisekundites.", + "heading": "Funscripti nihe (ms)" + }, + "handy_connection": { + "connect": "Ühenda", + "server_offset": { + "heading": "Serveri Nihe" + }, + "status": { + "heading": "Handy Ühenduse Staatus" + }, + "sync": "Sünkroniseeri" + }, + "handy_connection_key": { + "description": "Handy ühendusvõti interaktiivsete stseenide jaoks. Selle määramine võimaldab Stashil jagada teie praeguse stseeni teavet saidiga handyfeeling.com", + "heading": "Handy Ühendusvõti" + }, + "image_lightbox": { + "heading": "Pildi Valguskast" + }, + "images": { + "heading": "Pildid", + "options": { + "write_image_thumbnails": { + "description": "Kirjuta piltide pisipildid kettale, kui need luuakse käigupealt", + "heading": "Kirjutage piltide pisipildid" + } + } + }, + "interactive_options": "Interaktiivsed Valikud", + "language": { + "heading": "Keel" + }, + "max_loop_duration": { + "description": "Stseeni maksimaalne kestus, mille jooksul stseenimängija videot uuesti mängib – sisesta 0 keelamiseks", + "heading": "Silmuse maksimaalne kestus" + }, + "menu_items": { + "description": "Saad navigeerimisribal kuvada või peita erinevat tüüpi sisu", + "heading": "Menüüelemendid" + }, + "minimum_play_percent": { + "description": "Aja protsent, kui kaua stseeni tuleb esitada, enne kui selle esitamiste arvu suurendatakse.", + "heading": "Minimaalne Esitusprotsent" + }, + "performers": { + "options": { + "image_location": { + "description": "Esitaja vaikekujutiste kohandatud failitee. Sisseehitatud vaikeseadete kasutamiseks jätke tühjaks", + "heading": "Kohandatud Esinejate Pilditee" + } + } + }, + "preview_type": { + "description": "Seinaelementide konfiguratsioon", + "heading": "Eelvaate Tüüp", + "options": { + "animated": "Animeeritud Pilt", + "static": "Staatiline Pilt", + "video": "Video" + } + }, + "scene_list": { + "heading": "Stseenide Nimekiri", + "options": { + "show_studio_as_text": "Näita Stuudioid tekstina" + } + }, + "scene_player": { + "heading": "Stseenimängija", + "options": { + "always_start_from_beginning": "Alusta videot alati algusest", + "auto_start_video": "Video automaatne alustamine", + "auto_start_video_on_play_selected": { + "description": "Stseenivideote automaatne esitamine järjekorrast esitamisel või stseenide lehelt valitud või juhusliku esitamise korral", + "heading": "Video automaatne esitamine valitud esitamisel" + }, + "continue_playlist_default": { + "description": "Kui video lõppeb, esitage järjekorras järgmine stseen", + "heading": "Esitusloendi jätkamine vaikimisi" + }, + "show_scrubber": "Näita Detailide Otsijat", + "track_activity": "Jälgi Tegevust" + } + }, + "scene_wall": { + "heading": "Stseenide/Markerite Sein", + "options": { + "display_title": "Kuva pealkiri ja sildid", + "toggle_sound": "Luba heli" + } + }, + "scroll_attempts_before_change": { + "description": "Kerimise katsete arv enne järgmise/eelmise üksuse juurde liikumist. Kehtib ainult Pan Y kerimisrežiimi puhul.", + "heading": "Kerimiskatsed enne üleminekut" + }, + "show_tag_card_on_hover": { + "description": "Kuva märgendi kaarti, kui hõljute sildi märkidel", + "heading": "Sildikaardi tööriistanäpunäited" + }, + "slideshow_delay": { + "description": "Slaidiseanss on galeriides seinavaaterežiimi korral saadaval", + "heading": "Slaidiseansi Viivitus (sekundites)" + }, + "studio_panel": { + "heading": "Studiovaade", + "options": { + "show_child_studio_content": { + "description": "Stuudiovaates kuvage ka alamstuudiote sisu", + "heading": "Kuvage alamstuudiote sisu" + } + } + }, + "tag_panel": { + "heading": "Sildivaade", + "options": { + "show_child_tagged_content": { + "description": "Sildivaates kuvage ka alammärgendite sisu", + "heading": "Kuva alamsildi sisu" + } + } + }, + "title": "Kasutajaliides" + } + }, + "configuration": "Konfiguratsioon", + "countables": { + "files": "{count, plural, one {Fail} other {Faili}}", + "galleries": "{count, plural, one {Galerii} other {Galleriid}}", + "images": "{count, plural, one {Pilt} other {Pilti}}", + "markers": "{count, plural, one {Marker} other {Markerit}}", + "movies": "{count, plural, one {Film} other {Filmi}}", + "performers": "{count, plural, one {Näitleja} other {Näitlejat}}", + "scenes": "{count, plural, one {Stseen} other {Stseeni}}", + "studios": "{count, plural, one {Stuudio} other {Stuudiot}}", + "tags": "{count, plural, one {Silt} other {Silti}}" + }, + "country": "Riik", + "cover_image": "Kaanepilt", + "created_at": "Loodud", + "criterion": { + "greater_than": "Suurem kui", + "less_than": "Väiksem kui", + "value": "Väärtus" + }, + "criterion_modifier": { + "between": "vahel", + "equals": "on", + "excludes": "välistab", + "format_string": "{criterion} {modifierString} {valueString}", + "greater_than": "on suurem kui", + "includes": "sisaldab", + "includes_all": "sisaldab kõiki", + "is_null": "on null", + "less_than": "on vähem kui", + "matches_regex": "katub regexiga", + "not_between": "ei ole vahemikus", + "not_equals": "ei ole", + "not_matches_regex": "ei kattu regexiga", + "not_null": "ei ole null" + }, + "custom": "Kohandatud", + "date": "Kuupäev", + "death_date": "Surmakuupäev", + "death_year": "Surma-aasta", + "descending": "Langev", + "description": "Kirjeldus", + "detail": "Detail", + "details": "Detailid", + "developmentVersion": "Arendusversioon", + "dialogs": { + "aliases_must_be_unique": "aliased peavad olema erilised", + "create_new_entity": "Loo uus {entity}", + "delete_alert": "Järgnev {count, plural, one {{singularEntity}} other {{pluralEntity}}} kustutatakse lõplikult:", + "delete_confirm": "Kas oled kindel, et soovid kustutada {entityName}?", + "delete_entity_desc": "{count, plural, one {Kas oled kindel, et soovid kustutada {singularEntity}? Kui sa faili ei kustutata, siis {singularEntity} lisatakse skaneerimise käigus uuesti.} other {Kas oled kindel, et soovid kustutada {pluralEntity}? Kui sa faile ei kustutata, siis {pluralEntity} lisatakse skaneerimise käigus uuesti.}}", + "delete_entity_simple_desc": "{count, plural, one {Kas oled kindel, et soovid kustutada {singularEntity}?} other {Kas oled kindel, et soovid kustutada {pluralEntity}?}}", + "delete_entity_title": "{count, plural, one {Kustuta {singularEntity}} other {Kustuta {pluralEntity}}}", + "delete_galleries_extra": "...lisaks kõik pildifailid, mida pole lisatud ühelegi teisele galeriile.", + "delete_gallery_files": "Kustutage galerii kaust/zip-fail ja kõik pildid, mis pole ühelegi teise galeriisse lisatud.", + "delete_object_desc": "Kas oled kindel, et soovid kustutada {count, plural, one {seda {singularEntity}} other {neid {pluralEntity}}}?", + "delete_object_overflow": "…ja {count} teist {count, plural, one {{singularEntity}} other {{pluralEntity}}}.", + "delete_object_title": "Kustuta {count, plural, one {{singularEntity}} other {{pluralEntity}}}", + "dont_show_until_updated": "Ära näita kuni järgmise värskenduseni", + "edit_entity_title": "Redigeeri {count, plural, one {{singularEntity}} other {{pluralEntity}}}", + "export_include_related_objects": "Kaasa seotud objektid eksporti", + "export_title": "Ekspordi", + "lightbox": { + "delay": "Viivitus (s)", + "display_mode": { + "fit_horizontally": "Mahuta horisontaalselt", + "fit_to_screen": "Mahuta ekraanile", + "label": "Kuvamisrežiim", + "original": "Originaal" + }, + "options": "Sätted", + "reset_zoom_on_nav": "Pildi muutumisel lähtesta suumi tase", + "scale_up": { + "description": "Suurendage väiksemaid pilte ekraani täitmiseks", + "label": "Suurenda ekraanile mahtumiseks" + }, + "scroll_mode": { + "description": "Teise režiimi ajutiselt kasutamiseks hoidke all shift klahvi.", + "label": "Kerimisrežiim", + "pan_y": "Liiguta Y", + "zoom": "Suum" + } + }, + "merge": { + "destination": "Siihtkoht", + "empty_results": "Sihtkoha välja väärtusi ei muudeta.", + "source": "Allikas" + }, + "merge_tags": { + "destination": "Sihtkoht", + "source": "Allikas" + }, + "overwrite_filter_confirm": "Oled kindel, et tahad üle kirjutada juba eksisteerivat päringut {entityName}?", + "reassign_entity_title": "{count, plural, one {Määra Ümber {singularEntity}} other {Määra Ümber {pluralEntity}-d/id}}", + "reassign_files": { + "destination": "Määra Ümber" + }, + "scene_gen": { + "force_transcodes": "Sunni Ümbertöötlemise genereerimine", + "force_transcodes_tooltip": "Vaikimisi genereeritakse ümbertöötlemisi ainult siis, kui brauser videofaili ei toeta. Kui see on lubatud, genereeritakse ümbertöötlemisi isegi siis, kui videofail näib olevat brauseris toetatud.", + "image_previews": "Animeeritud Piltide Eelvaated", + "image_previews_tooltip": "Animeeritud WebP eelvaated, nõutavad ainult siis, kui Eelvaate Tüüp on seatud väärtusele Animeeritud Pilt.", + "interactive_heatmap_speed": "Looge interaktiivsete stseenide jaoks soojuskaarte ja kiirusi", + "marker_image_previews": "Markeri Animeeritud Piltide Eelvaated", + "marker_image_previews_tooltip": "Animeeritud markeri WebP eelvaated, nõutavad ainult siis, kui Eelvaate Tüüp on seatud väärtusele Animeeritud Pilt.", + "marker_screenshots": "Markeri Ekraanipildid", + "marker_screenshots_tooltip": "Marker staatilised JPG-kujutised, nõutav ainult siis, kui Eelvaate Tüüp on seatud väärtusele Staatiline Pilt.", + "markers": "Markeri Eelvaated", + "markers_tooltip": "20-sekundilised videod, mis algavad etteantud ajakoodiga.", + "override_preview_generation_options": "Eelvaate Genereerimise Valikute Ülekirjutamine", + "override_preview_generation_options_desc": "Eelvaate Genereerimise Sätete üle kirjutamine selle operatsiooni jaoks. Vaikeseaded määratakse jaotises Süsteem -> Eelvaate Genereerimine.", + "overwrite": "Kirjuta üle olemasolevad genereeritud failid", + "phash": "Nähtavad hashid (duplikaatide eemaldamiseks)", + "preview_exclude_end_time_desc": "Välista stseeni eelvaadetest viimased x sekundid. See võib olla väärtus sekundites või protsent (nt 2%) stseeni kogukestusest.", + "preview_exclude_end_time_head": "Välista lõpuaeg", + "preview_exclude_start_time_desc": "Välista esimesed x sekundid stseeni eelvaadetest. See võib olla väärtus sekundites või protsent (nt 2%) stseeni kogukestusest.", + "preview_exclude_start_time_head": "Välista algusaeg", + "preview_generation_options": "Eelvaate Genereerimise Sätted", + "preview_options": "Eelvaate Sätted", + "preview_preset_desc": "Eelseadistus reguleerib eelvaate genereerimise suurust, kvaliteeti ja kodeerimisaega. Eelseadistused peale „aeglase” on väheneva tootlikkusega ja neid ei soovitata.", + "preview_preset_head": "Eelvaate kodeeringu eelseadistus", + "preview_seg_count_desc": "Eelvaatefailide segmentide arv.", + "preview_seg_count_head": "Eelvaates olevate segmentide arv", + "preview_seg_duration_desc": "Iga eelvaate lõigu kestus sekundites.", + "preview_seg_duration_head": "Eelvaate segmendi kestus", + "sprites": "Stseenipuhastuse Spraidid", + "sprites_tooltip": "Spraidid (stseenipuhasti jaoks)", + "transcodes": "Ümbertöötlemine", + "transcodes_tooltip": "Toetamata videovormingute MP4-konversioonid", + "video_previews": "Eelvaated", + "video_previews_tooltip": "Video eelvaated, mis esitatakse kursorit stseeni kohal hoides" + }, + "scenes_found": "Leiti {count} stseeni", + "scrape_entity_query": "{entity_type} Kraapija Päring", + "scrape_entity_title": "{entity_type} Kraapija Tulemused", + "scrape_results_existing": "Eksisteeriv", + "scrape_results_scraped": "Kraabitud", + "set_image_url_title": "Pildi URL", + "unsaved_changes": "Salvestamata muudatused. Kas soovid kindlasti lahkuda?" + }, + "dimensions": "Dimensioonid", + "director": "Režissöör", + "display_mode": { + "grid": "Võrgustik", + "list": "Nimekiri", + "tagger": "Sildistaja", + "unknown": "Teadmata", + "wall": "Sein" + }, + "donate": "Anneta", + "dupe_check": { + "description": "Täpsetest madalamate tasemete arvutamine võib võtta kauem aega. Valepositiivsed tulemused võidakse tagastada ka madalamal täpsustasemel.", + "found_sets": "{setCount, plural, one{# duplikaat leitud.} other {# duplikaati leitud.}}", + "options": { + "exact": "Täpselt", + "high": "Kõrge", + "low": "Madal", + "medium": "Keskmine" + }, + "search_accuracy_label": "Otsingu Täpsus", + "title": "Duplikaatstseenid" + }, + "duplicated_phash": "Duplikeeritud (phash)", + "duration": "Pikkus", + "effect_filters": { + "aspect": "Aspekt", + "blue": "Sinine", + "blur": "Hägusta", + "brightness": "Eredus", + "contrast": "Kontrast", + "gamma": "Gamma", + "green": "Roheline", + "hue": "Värvitoon", + "name": "Filtrid", + "name_transforms": "Muutused", + "red": "Punane", + "reset_filters": "Lähtesta Filtrid", + "reset_transforms": "Lähesta Muutused", + "rotate": "Pööra", + "rotate_left_and_scale": "Pööra Vasakule & Skaleeri", + "rotate_right_and_scale": "Pööra Paremale & Skaleeri", + "saturation": "Saturatsioon", + "scale": "Suurus", + "warmth": "Soojus" + }, + "empty_server": "Sellel lehel soovituste nägemiseks lisage oma serverisse mõned stseenid.", + "ethnicity": "Rahvus", + "existing_value": "eksisteeriv väärtus", + "eye_color": "Silmavärv", + "fake_tits": "Võltsrinnad", + "false": "Väär", + "favourite": "Lemmik", + "file": "fail", + "file_count": "Failide Arv", + "file_info": "Faili Info", + "file_mod_time": "Faili Muutmise Aeg", + "files": "failid", + "files_amount": "{value} faili", + "filesize": "Faili Suurus", + "filter": "Filter", + "filter_name": "Filtri nimi", + "filters": "Filtrid", + "folder": "Kaust", + "framerate": "Kaadrisagedus", + "frames_per_second": "{value} kaadrit sekundis", + "front_page": { + "types": { + "premade_filter": "Eelsätestatud Filter", + "saved_filter": "Salvestatud Filter" + } + }, + "galleries": "Galeriid", + "gallery": "Galerii", + "gallery_count": "Galeriide Arv", + "gender": "Sugu", + "gender_types": { + "FEMALE": "Naine", + "INTERSEX": "Intersooline", + "MALE": "Mees", + "NON_BINARY": "Mittebinaarne", + "TRANSGENDER_FEMALE": "Transnaine", + "TRANSGENDER_MALE": "Transmees" + }, + "hair_color": "Juuksevärv", + "handy_connection_status": { + "connecting": "Ühendan", + "disconnected": "Lahti ühendatud", + "error": "Handyga ühendamisel tekkis viga", + "missing": "Kadunud", + "ready": "Valmis", + "syncing": "Serveriga sünkroniseerimine", + "uploading": "Skripti üleslaadimine" + }, + "hasMarkers": "On Markereid", + "height": "Pikkus", + "height_cm": "Pikkus (cm)", + "help": "Abi", + "ignore_auto_tag": "Ignoneeri Automaatset Märkimist", + "image": "Pilt", + "image_count": "Pildiarv", + "images": "Pildid", + "include_parent_tags": "Kaasa vanem-silte", + "include_sub_studios": "Kaasa tütarstuudioid", + "include_sub_tags": "Kaasa alamsilte", + "instagram": "Instagram", + "interactive": "Interaktiivne", + "interactive_speed": "Interaktiivne kiirus", + "isMissing": "On Kadunud", + "last_played_at": "Viimati Esitatud", + "library": "Kogu", + "loading": { + "generic": "Laen…" + }, + "marker_count": "Markerite Arv", + "markers": "Markerid", + "measurements": "Mõõdud", + "media_info": { + "audio_codec": "Heli Koodek", + "checksum": "Kontrollsumma", + "downloaded_from": "Allalaetud Asukohast", + "hash": "Hash", + "interactive_speed": "Interaktiivne kiirus", + "performer_card": { + "age": "{age} {years_old}", + "age_context": "{age} selles stseenis {years_old}" + }, + "phash": "PHash", + "play_count": "Esituste Arv", + "play_duration": "Esitamisaeg", + "stream": "Striim", + "video_codec": "Video Koodek" + }, + "megabits_per_second": "{value} megabitti sekundis", + "metadata": "Metaandmed", + "movie": "Film", + "movie_scene_number": "Filmi Stseeninumber", + "movies": "Filmid", + "name": "Nimi", + "new": "Uus", + "none": "Puudub", + "o_counter": "O-Loendur", + "operations": "Operatsioonid", + "organized": "Organiseeritud", + "pagination": { + "first": "Esimene", + "last": "Viimane", + "next": "Järgmine", + "previous": "Eelmine" + }, + "parent_of": "{children} vanem-silt", + "parent_studios": "Vanem-stuudiod", + "parent_tag_count": "Vanem-siltide Arv", + "parent_tags": "Vanem-sildid", + "part_of": "Osa {parent}-st", + "path": "Failitee", + "perceptual_similarity": "Tajutav Sarnasus (phash)", + "performer": "Näitleja", + "performerTags": "Näitleja Sildid", + "performer_age": "Näitleja Vanus", + "performer_count": "Näitlejate Arv", + "performer_favorite": "Lemmiknäitleja", + "performer_image": "Näitleja Pilt", + "performer_tagger": { + "add_new_performers": "Lisa Uusi Näitlejaid", + "any_names_entered_will_be_queried": "Kõigi sisestatud nimede kohta päritakse Stash-kastii kaugeksemplari ja lisatakse, kui need leitakse. Vastena loetakse ainult täpseid vasteid.", + "batch_add_performers": "Lisa Näitlejaid Hunnikus", + "batch_update_performers": "Uuenda Näitlejaid Hunnikus", + "config": { + "active_stash-box_instance": "Aktiivne stash-kasti eksemplar:", + "edit_excluded_fields": "Muuda Välistatud Välju", + "excluded_fields": "Välistatud väljad:", + "no_fields_are_excluded": "Ühtegi välja ei ole välistatud", + "no_instances_found": "Eksemplare ei leitud", + "these_fields_will_not_be_changed_when_updating_performers": "Neid välju näitlejaid uuendades ei muudeta." + }, + "current_page": "Käesolev lehekülg", + "failed_to_save_performer": "Näitleja \"{performer}\" salvestamine ebaõnnestus", + "name_already_exists": "Nimi juba eksisteerib", + "network_error": "Võrguviga", + "no_results_found": "Tulemusi ei leitud.", + "number_of_performers_will_be_processed": "{performer_count} näitlejat töödeldakse", + "performer_already_tagged": "Näitleja juba märgitud", + "performer_names_separated_by_comma": "Esinejate nimed on eraldatud komaga", + "performer_selection": "Näitlejate valik", + "performer_successfully_tagged": "Näitleja edukalt märgitud:", + "query_all_performers_in_the_database": "Kõik andmebaasis olevad näitlejad", + "refresh_tagged_performers": "Värskenda märgitud näitlejaid", + "refreshing_will_update_the_data": "Värskendamine uuendab kõigi märgitud näitlejate informatsiooni stash-kasti eksemplaris.", + "status_tagging_job_queued": "Staatus: Märkimise töö lisatud järjekorda", + "status_tagging_performers": "Staatus: Märgin näitlejaid", + "tag_status": "Märgi Staatus", + "to_use_the_performer_tagger": "Näitleja märgistamise kasutamiseks peab stash-kasti eksemplar olema konfigureeritud.", + "untagged_performers": "Märkimata näitlejad", + "update_performer": "Uuenda Näitlejat", + "update_performers": "Uuenda Näitlejaid", + "updating_untagged_performers_description": "Märgistamata esinejate värskendamisel püütakse leida vasteid esinejatele, kellel puudub stashid, ja värskendatakse metaandmeid." + }, + "performers": "Näitlejad", + "piercings": "Augustused", + "play_count": "Esitamisarv", + "play_duration": "Esitamisaeg", + "primary_file": "Põhifail", + "queue": "Järjekord", + "random": "Suvaline", + "rating": "Hinnang", + "recently_added_objects": "Hiljuti Lisatud {objects}", + "recently_released_objects": "Hiljuti Avaldatud {objects}", + "release_notes": "Väljalaske Märkmed", + "resolution": "Resolutsioon", + "resume_time": "Jätkamisaeg", + "scene": "Stseen", + "sceneTagger": "Stseeni Sildistaja", + "sceneTags": "Stseeni Sildid", + "scene_code": "Stuudio Kood", + "scene_count": "Stseenide Arv", + "scene_created_at": "Stseen Loodud", + "scene_date": "Stseeni Kuupäev", + "scene_id": "Stseeni ID", + "scene_updated_at": "Stseen Uuendatud", + "scenes": "Stseenid", + "scenes_updated_at": "Stseen Uuendatud", + "search_filter": { + "add_filter": "Lisa Filter", + "name": "Filter", + "saved_filters": "Salvestatud filtrid", + "update_filter": "Uuenda Filtrit" + }, + "seconds": "Sekundit", + "settings": "Sätted", + "setup": { + "confirm": { + "almost_ready": "Oleme seadistamise lõpuleviimiseks peaaegu valmis. Vaata üle järgmised sätted. Valede väärtuste muutmiseks võite klõpsata tagasi. Kui kõik tundub õige, klõpsa süsteemi loomiseks nuppu Kinnita.", + "configuration_file_location": "Konfiguratsioonifaili asukoht:", + "database_file_path": "Andmebaasi faili failitee", + "default_db_location": "/stash-go.sqlite", + "default_generated_content_location": "/generated", + "generated_directory": "Genereeritud kaust", + "nearly_there": "Peaaegu kohal!", + "stash_library_directories": "Stashi kogu kaustad" + }, + "creating": { + "creating_your_system": "Loon sulle süsteemi", + "ffmpeg_notice": "Kui ffmpeg-i pole veel olemas, ole kannatlik, kuni stash selle alla laadib. Allalaadimise edenemise vaatamiseks vaata konsooli väljundit." + }, + "errors": { + "something_went_wrong": "Oi ei! Midagi läks valesti!", + "something_went_wrong_description": "Kui see näib olevat sisenditega seotud probleem, jätka ja klõpsa nende parandamiseks nuppu Tagasi. Vastasel juhul loo viga meie {githubLink}-s või otsi abi kanalist {discordLink}.", + "something_went_wrong_while_setting_up_your_system": "Süsteemi seadistamisel läks midagi valesti. Saime järgmise vea: {error}" + }, + "folder": { + "file_path": "Failitee", + "up_dir": "Kaust üles" + }, + "github_repository": "Githubi hoidla", + "migrate": { + "backup_database_path_leave_empty_to_disable_backup": "Andmebaasi varundamise tee (varundamise keelamiseks jäta tühjaks):", + "backup_recommended": "Enne migreerimist on soovitatav olemasolev andmebaas varundada. Saame seda sinu eest teha, tehes andmebaasist koopia kausta {defaultBackupPath}.", + "migrating_database": "Andmebaasi migreerimine", + "migration_failed": "Migreerimine ebaõnnestus", + "migration_failed_error": "Andmebaasi migreerimisel ilmnes järgmine tõrge:", + "migration_failed_help": "Te vajalikud parandused ja proovige uuesti. Vastasel juhul ava viga meie {githubLink}-is või otsi abi kanalist {discordLink}.", + "migration_irreversible_warning": "Skeemi migratsiooniprotsess ei ole taastatav. Kui migratsioon on tehtud, ei ühildu andmebaas enam stashi varasemate versioonidega.", + "migration_notes": "Migratsioonimärkmed", + "migration_required": "Migratsioon on nõutud", + "perform_schema_migration": "Skeemi migreerimine", + "schema_too_old": "Praegune stashi andmebaas on skeemi {databaseSchema} versioonil ja see tuleb üle viia versioonile {appSchema}. See Stashi versioon ei tööta ilma andmebaasi migreerimata. Kui sa ei soovi migreerida, pead üle minema versioonile, mis vastab teie andmebaasi skeemile." + }, + "paths": { + "database_filename_empty_for_default": "andmebaasi failinimi (vaikimisi tühi)", + "description": "Järgmisena peame kindlaks määrama, kust leida su pornokogu, kuhu salvestada stashi andmebaas ja loodud failid. Neid sätteid saab hiljem vajadusel muuta.", + "path_to_generated_directory_empty_for_default": "genereeritud kataloogi tee (vaikimisi tühi)", + "set_up_your_paths": "Seadista oma failiteed", + "stash_alert": "Ühtegi kogu teed pole valitud. Stash ei saa skannida mitte ühtegi meediumifaili. Oled sa kindel?", + "where_can_stash_store_its_database": "Kuhu saab Stash oma andmebaasi salvestada?", + "where_can_stash_store_its_database_description": "Stash kasutab su porno metaandmete salvestamiseks sqlite'i andmebaasi. Vaikimisi luuakse see konfiguratsioonifaili sisaldavasse kataloogi kui stash-go.sqlite. Kui soovid seda muuta, sisesta absoluutne või suhteline failinimi (praeguse töökataloogi suhtes).", + "where_can_stash_store_its_generated_content": "Kus saab Stash oma genereeritud sisu salvestada?", + "where_can_stash_store_its_generated_content_description": "Pisipiltide, eelvaadete ja spraitide pakkumiseks loob Stash pilte ja videoid. See hõlmab ka toetamata failivormingute ümbertöötlemist. Vaikimisi loob Stash konfiguratsioonifaili sisaldavas kaustas genereeritud kausta. Kui soovid muuta seda, kus see loodud meedium salvestatakse, sisesta absoluutne või suhteline failitee (praeguse töökataloogi suhtes). Stash loob selle kausta, kui seda veel pole.", + "where_is_your_porn_located": "Kus su porno asub?", + "where_is_your_porn_located_description": "Lisage oma pornovideoid ja pilte sisaldavad kataloogid. Stash kasutab neid katalooge skanimise ajal videote ja piltide otsimiseks." + }, + "stash_setup_wizard": "Stashi Ülessättimise Viisard", + "success": { + "getting_help": "Abi saamine", + "help_links": "Kui tekib probleeme või on küsimusi või soovitusi, ava viga lehel {githubLink} või küsi kogukonnalt abi kanalis {discordLink}.", + "in_app_manual_explained": "Soovitame tutvuda rakendusesisese juhendiga, millele pääseb juurde ekraani paremas ülanurgas olevast ikoonist, mis näeb välja järgmine: {icon}", + "next_config_step_one": "Järgmisena suuname su konfiguratsioonilehele. See leht võimaldab sul kohandada, milliseid faile lisada ja välja jätta, määrata oma süsteemi kaitsmiseks kasutajanime ja parooli ning palju muud.", + "next_config_step_two": "Kui oled seadetega rahul, võite alustada sisu Stashi skannimist, klõpsates valikul {localized_task} ja seejärel valikul {localized_scan}.", + "open_collective": "Vaadake meie {open_collective_link}, et näha, kuidas saad aidata kaasa Stashi jätkuvale arendamisele.", + "support_us": "Toeta meid", + "thanks_for_trying_stash": "Aitäh Stashi proovimise eest!", + "welcome_contrib": "Ootame ka panust koodi (veaparandused, täiustused ja uued funktsioonid), testimise, veaaruannete, parendus- ja funktsioonitaotluste ning kasutajatoe kujul. Üksikasjad leiad rakendusesisese juhendi jaotisest Contribution.", + "your_system_has_been_created": "Edukas! Su süsteem on loodud!" + }, + "welcome": { + "config_path_logic_explained": "Stash püüab esmalt leida oma konfiguratsioonifaili (config.yml) praegusest töökataloogist ja kui ta seda sealt ei leia, läheb tagasi kausta $HOME/.stash/config. yml (Windowsis on see %USERPROFILE%\\.stash\\config.yml). Samuti saad panna Stashi lugema konkreetsest konfiguratsioonifailist, käivitades selle suvanditega -c või --config .", + "in_current_stash_directory": "Kaustas $HOME/.stash", + "in_the_current_working_directory": "Praeguses töökaustas", + "next_step": "Kui oled valmis uue süsteemi seadistamisega alustama, vali, kuhu soovid oma konfiguratsioonifaili salvestada, ja klõpsa nuppu Edasi.", + "store_stash_config": "Kuhu soovid oma Stashi konfiguratsiooni salvestada?", + "unable_to_locate_config": "Kui sa seda näed, siis ei leidnud Stash olemasolevat konfiguratsiooni. See viisard juhendab sind uue konfiguratsiooni seadistamise protsessis.", + "unexpected_explained": "Kui said selle ekraani ootamatult, proovi Stash uuesti käivitada õiges töökataloogis või lipuga -c." + }, + "welcome_specific_config": { + "config_path": "Stash kasutab järgmist konfiguratsioonifaili teed: {path}", + "next_step": "Kui oled valmis uue süsteemi seadistamisega jätkama, klõpsa nuppu Edasi.", + "unable_to_locate_specified_config": "Kui seda näed, siis ei leidnud Stash käsureal ega keskkonnas määratud konfiguratsioonifaili. See viisard juhendab sind uue konfiguratsiooni seadistamise protsessis." + }, + "welcome_to_stash": "Teretulemast Stashi" + }, + "stash_id": "Stashi ID", + "stash_id_endpoint": "Stash ID Lõpp-punkt", + "stash_ids": "Stashi ID-d", + "stashbox": { + "go_review_draft": "Mustandi ülevaatamiseks mine saidile {endpoint_name}.", + "selected_stash_box": "Valitud Stash-Kasti lõpp-punkt", + "submission_failed": "Esitamine ebaõnnestus", + "submission_successful": "Esitamine õnnestus", + "submit_update": "On juba olemas kohas {endpoint_name}" + }, + "statistics": "Statistika", + "stats": { + "image_size": "Piltide suurus", + "scenes_duration": "Stseenide pikkus", + "scenes_size": "Stseenide suurus" + }, + "status": "Staatus: {statusText}", + "studio": "Stuudio", + "studio_depth": "Tasemed (tühi kõige jaoks)", + "studios": "Stuudiod", + "sub_tag_count": "Alam-Siltide Arv", + "sub_tag_of": "{parent}-i alam-silt", + "sub_tags": "Alam-Sildid", + "subsidiary_studios": "Tütarstuudiod", + "synopsis": "Sisukokkuvõte", + "tag": "Silt", + "tag_count": "Siltide Arv", + "tags": "Sildid", + "tattoos": "Tatoveeringud", + "title": "Pealkiri", + "toast": { + "added_entity": "Lisatud {count, plural, one {{singularEntity}} other {{pluralEntity}}}", + "added_generation_job_to_queue": "Genereerimistöö lisatud järjekorda", + "created_entity": "Loodud {entity}", + "default_filter_set": "Vaikimisi filtrikomplekt", + "delete_past_tense": "Kustutatud {count, plural, one {{singularEntity}} other {{pluralEntity}}}", + "generating_screenshot": "Ekraanipildi genereerimine…", + "merged_scenes": "Ühendatud stseenid", + "merged_tags": "Sildid ühendatud", + "reassign_past_tense": "Fail ümbermääratud", + "removed_entity": "Eemaldatud {count, plural, one {{singularEntity}} other {{pluralEntity}}}", + "rescanning_entity": "Skannin uuesti {count, plural, one {{singularEntity}} other {{pluralEntity}}}…", + "saved_entity": "Salvestatud {entity}", + "started_auto_tagging": "Alustasin automaatset märkimist", + "started_generating": "Alustasin genereerimist", + "started_importing": "Alustasin importimist", + "updated_entity": "Uuendatud {entity}" + }, + "total": "Kokku", + "true": "Tõene", + "twitter": "Twitter", + "type": "Tüüp", + "updated_at": "Viimati Uuendatud", + "url": "URL", + "videos": "Videod", + "view_all": "Vaata Kõiki", + "weight": "Kaal", + "weight_kg": "Kaal (kg)", + "years_old": "aastat vana", + "zip_file_count": "Zip Failide Arv" +} diff --git a/ui/v2.5/src/locales/fa-IR.json b/ui/v2.5/src/locales/fa-IR.json new file mode 100644 index 000000000..ed63121d2 --- /dev/null +++ b/ui/v2.5/src/locales/fa-IR.json @@ -0,0 +1,10 @@ +{ + "actions": { + "add": "افزودن", + "add_directory": "افزودن پوشه", + "add_entity": "افزودن {entityType}", + "add_to_entity": "اضافه‌کردن به {entityType}", + "allow": "اجازه دادن", + "allow_temporarily": "به طور موقت اجازه دهید" + } +} diff --git a/ui/v2.5/src/locales/fi-FI.json b/ui/v2.5/src/locales/fi-FI.json index c47739722..002c365ca 100644 --- a/ui/v2.5/src/locales/fi-FI.json +++ b/ui/v2.5/src/locales/fi-FI.json @@ -54,6 +54,7 @@ "import": "Tuo…", "import_from_file": "Tuo tiedostosta", "logout": "Kirjaudu ulos", + "make_primary": "Tee ensisijaikseksi", "merge": "Yhdistä", "merge_from": "Yhdistä kohteesta", "merge_into": "Yhdistä kohteeseen", @@ -66,6 +67,7 @@ "play_selected": "Toista valittu", "preview": "Esikatselu", "previous_action": "Takaisin", + "reassign": "Määritä uudelleen", "refresh": "Päivitä", "reload_plugins": "Lataa lisäosat uudelleen", "reload_scrapers": "Lataa kaapija uudelleen", @@ -98,10 +100,12 @@ "show": "Näytä", "show_configuration": "Näytä Asetus", "skip": "Ohita", + "split": "Jaa osiin", "stop": "Pysäytä", "submit": "Lähetä", "submit_stash_box": "Lähetä Stash-Boxiin", "submit_update": "Lähetä päivitys", + "swap": "Vaihda", "tasks": { "clean_confirm_message": "Haluatko varmasti puhdistaa? Tämä poistaa tietokannan tiedot ja poistaa kaikki generoidut tukitiedostot kaikista kohtauksista ja gallerioista, eikä niitä enää ole löydettävissä levyltä.", "dry_mode_selected": "Kuivatila käytössä. Poistoa ei oikeasti tehdä, vain lokikirjaus.", @@ -120,6 +124,7 @@ "also_known_as": "Esiintyy myös nimillä", "ascending": "Nouseva", "average_resolution": "Keskimääräinen resoluutio", + "between_and": "ja", "birth_year": "Syntymävuosi", "birthdate": "Syntymäpäivä", "bitrate": "Bittinopeus", @@ -189,6 +194,7 @@ }, "categories": { "about": "Tietoja", + "changelog": "Muutosloki", "interface": "Käyttöliittymä", "logs": "Lokit", "metadata_providers": "Metadatan tarjoajat", @@ -242,6 +248,10 @@ "username": "Käyttäjätunnus", "username_desc": "Käyttäjätunnus Stashiin. Jätä tyhjäksi, mikäli et halua autentikointia" }, + "backup_directory_path": { + "description": "SQLite -tietokannan varmuuskopiokansion sijainti", + "heading": "Varmuuskopiokansion polku" + }, "cache_location": "Välimuistin kansion sijainti", "cache_path_head": "Välimuistin polku", "calculate_md5_and_ohash_desc": "Laske MD5 -tarkistussumma oshashin lisäksi. Jos laitat tämän päälle, se tekee skannauksista hitaampia. Tiedostojen nimien tiivisteeksi pitää valita oshash MD5:n laskemisen laittamiseksi pois päältä.", @@ -414,6 +424,15 @@ "heading": "Mukautettu CSS", "option_label": "Mukautettu CSS käytössä" }, + "custom_javascript": { + "description": "Sivu täytyy ladata uudelleen, jotta muutokset astuvat voimaan.", + "heading": "Mukautettu Javascript", + "option_label": "Mukautettu Javascript käytössä" + }, + "custom_locales": { + "heading": "Mukautettu lokalisointi", + "option_label": "Mukautettu lokalisointi käytössä" + }, "delete_options": { "description": "Oletusasetukset kun poistetaan kuvia, gallerioita ja kohtauksia.", "heading": "Poistovalinnat", @@ -434,7 +453,23 @@ "description": "Poistaa mahdollisuuden luoda uusia kohteita alasvetovalikoista", "heading": "Poista luominen alasvetovalikoista" }, - "heading": "Muokkaaminen" + "heading": "Muokkaaminen", + "rating_system": { + "star_precision": { + "label": "Arvostelutähtien tarkkuus", + "options": { + "full": "Kokonainen", + "half": "Puolikas", + "quarter": "Neljäsosa" + } + }, + "type": { + "options": { + "decimal": "Desimaalit", + "stars": "Tähdet" + } + } + } }, "funscript_offset": { "description": "Viive millisekunneissa interaktiivisille skripteille kun toistetaan." @@ -486,6 +521,7 @@ "scene_player": { "heading": "Kohtauksien soitin", "options": { + "always_start_from_beginning": "Aloita video aina alusta", "auto_start_video": "Aloita video automaattisesti", "auto_start_video_on_play_selected": { "description": "Aloita video automaattisesti kun kohtaus valitaan tai käytetään satunnainen kohtaus -sivua", @@ -508,6 +544,24 @@ "description": "Kuvaesitys käytössä gallerioissa seinätilassa", "heading": "Kuvaesityksen viive (sekunteina)" }, + "studio_panel": { + "heading": "Studionäkymä", + "options": { + "show_child_studio_content": { + "description": "Näytä alistudiot studionäkymässä", + "heading": "Näytä alistudioiden sisältö" + } + } + }, + "tag_panel": { + "heading": "Tunnistenäkymä", + "options": { + "show_child_tagged_content": { + "description": "Näytä tunnistenäkymässä myös alitunnisteiden sisältö", + "heading": "Näytä alitunnisteiden sisältö" + } + } + }, "title": "Käyttöliittymä" } }, @@ -552,20 +606,24 @@ "death_date": "Kuolinpäivä", "death_year": "Kuolinvuosi", "descending": "Laskeva", + "description": "Kuvaus", "detail": "Lisätiedot", "details": "Lisätiedot", "developmentVersion": "Kehitysversio", "dialogs": { "aliases_must_be_unique": "Aliaksien pitää olla uniikkeja", + "create_new_entity": "Luo uus {entity}", "delete_alert": "{count, plural, one {{singularEntity}} other {{pluralEntity}}} poistetaan pysyvästi:", "delete_confirm": "Haluatko varmasti poistaa {entityName}?", "delete_entity_desc": "{count, plural, one {Haluatko varmasti, että {singularEntity} poistetaan? Jos tiedostoa ei poisteta {singularEntity} lisätään uudelleen heti kun uusi skannaus suoritetaan.} other {Haluatko varmasti, että {pluralEntity} poistetaan? Jos tiedostoja ei poisteta, {pluralEntity} lisätään uudelleen heti kun uusi skannaus suoritetaan.}}", + "delete_entity_simple_desc": "{count, plural, one {Haluatko varmasti poistaa tämän {singularEntity}?} other {Haluatko varmasti poistaa nämä {pluralEntity}?}}", "delete_entity_title": "{count, plural, one {Poista {singularEntity}} other {Poista {pluralEntity}}}", "delete_galleries_extra": "...ja kaikki kuvatiedostot, jotka eivät kuulu mihinkään galleriaan.", "delete_gallery_files": "Poista gallerian kansio/zip -tiedosto ja kaikki kuvat, jotka eivät kuulu mihinkään muuhun galleriaan.", "delete_object_desc": "Halutatko varmasti poistaa {count, plural, one {{singularEntity}} other {{pluralEntity}}}?", "delete_object_overflow": "…ja {count} muuta {count, plural, one {{singularEntity}} other {{pluralEntity}}}.", "delete_object_title": "Poista {count, plural, one {{singularEntity}} other {{pluralEntity}}}", + "dont_show_until_updated": "Elä näytä ennen seuraavaa päivitystä", "edit_entity_title": "Muokkaa {count, plural, one {{singularEntity}} other {{pluralEntity}}}", "export_include_related_objects": "Sisällytä liittyvät objektit vientiin", "export_title": "Vie", @@ -589,6 +647,10 @@ "zoom": "Zoomaus" } }, + "merge": { + "destination": "Kohde", + "source": "Lähde" + }, "merge_tags": { "destination": "Kohde", "source": "Lähde" @@ -682,15 +744,23 @@ "false": "Ei", "favourite": "Suosikki", "file": "tiedosto", + "file_count": "Tiedostojen määrä", "file_info": "Tiedoston tiedot", "file_mod_time": "Tiedostoa muokattu", "files": "tiedostoa", + "files_amount": "{value} tiedostoa", "filesize": "Tiedoston koko", "filter": "Suodatin", "filter_name": "Suodattimen nimi", "filters": "Suodattimet", + "folder": "Kansio", "framerate": "Kuvataajuus", "frames_per_second": "{value} kuvaa sekunnissa", + "front_page": { + "types": { + "saved_filter": "Tallennettu suodatin" + } + }, "galleries": "Galleriat", "gallery": "Galleria", "gallery_count": "Gallerioiden määrä", @@ -715,6 +785,7 @@ }, "hasMarkers": "On merkki", "height": "Pituus", + "height_cm": "Pituus (cm)", "help": "Apua", "image": "Kuva", "image_count": "Kuvien määrä", @@ -726,6 +797,7 @@ "interactive": "Interaktiivinen", "interactive_speed": "Interaktiivinen nopeus", "isMissing": "Puuttuu", + "last_played_at": "Viimeksi toistettu", "library": "Kirjasto", "loading": { "generic": "Ladataan…" @@ -744,6 +816,8 @@ "age_context": "{age} {years_old} tässä kohtauksessa" }, "phash": "PHash", + "play_count": "Toistokerrat", + "play_duration": "Toistettu aika", "stream": "Suoratoisto", "video_codec": "Videokodekki" }, @@ -799,6 +873,7 @@ "performer_selection": "Esiintyjän valinta", "performer_successfully_tagged": "Esiintyjälle on asetettu tunnisteet:", "query_all_performers_in_the_database": "Kaikki esiintyjät tietokannassa", + "refresh_tagged_performers": "Päivitä tunnistetut esiintyjät", "status_tagging_job_queued": "Tila: Tunnisteiden asettaminen laitettu jonoon", "status_tagging_performers": "Tila: Asetetaan esiintyjien tunnisteita", "untagged_performers": "Esiintyjät joille ei ole asetettu tunnisteita", @@ -810,12 +885,18 @@ "queue": "Jono", "random": "Satunnainen", "rating": "Arvio", + "recently_added_objects": "Viimeksi lisätyt {objects}", + "recently_released_objects": "Viimeksi julkaistut {objects}", "resolution": "Resoluutio", "scene": "Kohtaus", "sceneTagger": "Kohtauksien tunnistetila", "sceneTags": "Kohtauksen tunnisteet", + "scene_code": "Studiokoodi", "scene_count": "Kohtauksien määrä", + "scene_created_at": "Kohtaus luotu", + "scene_date": "Kohtauksen päiväys", "scene_id": "Kohtauksen ID", + "scene_updated_at": "Kohtaus päivitetty", "scenes": "Kohtaukset", "scenes_updated_at": "Kohtaus päivitetty", "search_filter": { @@ -947,5 +1028,6 @@ "videos": "Videot", "view_all": "Näytä kaikki", "weight": "Paino", + "weight_kg": "Paino (kg)", "years_old": "-vuotias" } diff --git a/ui/v2.5/src/locales/fr-FR.json b/ui/v2.5/src/locales/fr-FR.json index be877ecc6..c1a0bbe5c 100644 --- a/ui/v2.5/src/locales/fr-FR.json +++ b/ui/v2.5/src/locales/fr-FR.json @@ -7,7 +7,7 @@ "allow": "Autoriser", "allow_temporarily": "Autoriser temporairement", "apply": "Appliquer", - "auto_tag": "Étiquetage auto", + "auto_tag": "Étiquetage automatique", "backup": "Sauvegarde", "browse_for_image": "Sélectionner une image…", "cancel": "Annuler", @@ -36,7 +36,7 @@ "edit": "Éditer", "edit_entity": "Éditer {entityType}", "export": "Exporter…", - "export_all": "Exporter tout…", + "export_all": "Tout exporter…", "find": "Rechercher", "finish": "Terminer", "from_file": "A partir du fichier…", @@ -45,7 +45,7 @@ "full_import": "Import complet", "generate": "Générer", "generate_thumb_default": "Générer une vignette par défaut", - "generate_thumb_from_current": "Générer une vignette à partir de l'image courante", + "generate_thumb_from_current": "Générer une vignette à partir du contenu actuel", "hash_migration": "Migration des empreintes", "hide": "Masquer", "hide_configuration": "Masquer la configuration", @@ -54,6 +54,7 @@ "import": "Importer…", "import_from_file": "Importation depuis un fichier", "logout": "Déconnecter", + "make_primary": "Rendre principal", "merge": "Fusionner", "merge_from": "Fusionner depuis", "merge_into": "Fusionner dans", @@ -66,6 +67,7 @@ "play_selected": "Lire la sélection", "preview": "Aperçu", "previous_action": "Précédent", + "reassign": "Réaffecter", "refresh": "Rafraichir", "reload_plugins": "Recharger les plugins", "reload_scrapers": "Recharger les extracteurs de contenu", @@ -84,11 +86,11 @@ "scrape_scene_fragment": "Extraire par fragment", "scrape_with": "Extraire avec…", "search": "Recherche", - "select_all": "Sélectionner tout", + "select_all": "Tout sélectionner", "select_entity": "Sélectionner {entityType}", "select_folders": "Sélectionner des répertoires", "select_none": "Ne rien sélectionner", - "selective_auto_tag": "Étiquetage auto de la sélection", + "selective_auto_tag": "Étiquetage automatique sélectif", "selective_clean": "Nettoyage sélectif", "selective_scan": "Analyse sélective", "set_as_default": "Définir par défaut", @@ -98,10 +100,12 @@ "show": "Montrer", "show_configuration": "Afficher la configuration", "skip": "Passer", + "split": "Diviser", "stop": "Stop", "submit": "Soumettre", "submit_stash_box": "Soumettre à Stash-Box", "submit_update": "Soumettre une mise à jour", + "swap": "Permuter", "tasks": { "clean_confirm_message": "Êtes-vous sûr de vouloir nettoyer ? Cette opération supprimera les informations de la base de données et le contenu généré pour toutes les scènes et galeries qui ne se trouvent plus dans le système de fichiers.", "dry_mode_selected": "Essais à blanc. Aucune suppression réelle n'aura lieu, seulement une journalisation.", @@ -120,6 +124,7 @@ "also_known_as": "Également connu comme", "ascending": "Ascendant", "average_resolution": "Résolution moyenne", + "between_and": "et", "birth_year": "Année de naissance", "birthdate": "Date de naissance", "bitrate": "Débit", @@ -136,7 +141,7 @@ "query_mode_dir_desc": "Utilise uniquement le répertoire parent du fichier vidéo", "query_mode_filename": "Nom de fichier", "query_mode_filename_desc": "Utilise uniquement le nom du fichier", - "query_mode_label": "Mode de recherche", + "query_mode_label": "Mode de requête", "query_mode_metadata": "Métadonnées", "query_mode_metadata_desc": "Utilise uniquement les métadonnées", "query_mode_path": "Chemin", @@ -189,6 +194,7 @@ }, "categories": { "about": "A propos", + "changelog": "Journal des modifications", "interface": "Interface", "logs": "Journaux", "metadata_providers": "Fournisseurs de métadonnées", @@ -243,7 +249,11 @@ "username": "Nom d'utilisateur", "username_desc": "Nom d'utilisateur pour accéder à Stash. Laisser vide pour désactiver l'authentification utilisateur" }, - "cache_location": "Emplacement du répertoire du cache", + "backup_directory_path": { + "description": "Emplacement de sauvegarde des bases de données SQLite", + "heading": "Chemin du répertoire de sauvegarde" + }, + "cache_location": "Emplacement du cache", "cache_path_head": "Chemin du cache", "calculate_md5_and_ohash_desc": "Calculer la somme de contrôle MD5 en complément de oshash. Son activation entraîne un ralentissement des analyses initiales. Le hachage du nom de fichier doit être défini sur oshash pour désactiver le calcul MD5.", "calculate_md5_and_ohash_label": "Calculer le MD5 pour les vidéos", @@ -254,7 +264,7 @@ "create_galleries_from_folders_desc": "Coché, crée des galeries à partir de dossiers contenant des images.", "create_galleries_from_folders_label": "Créer des galeries à partir de dossiers contenant des images", "db_path_head": "Chemin de la base de données", - "directory_locations_to_your_content": "Emplacements du répertoire de votre contenu", + "directory_locations_to_your_content": "Emplacements de votre contenu", "excluded_image_gallery_patterns_desc": "Expression régulière de fichiers images et galeries ou de chemins d'accès à exclure de l'analyse et à ajouter au nettoyage", "excluded_image_gallery_patterns_head": "Modèles d'image ou galerie exclués", "excluded_video_patterns_desc": "Expressions régulières de fichiers vidéo ou de chemins d'accès à exclure de l'analyse et à ajouter au nettoyage", @@ -263,7 +273,7 @@ "gallery_ext_head": "Extensions zip de la galerie", "generated_file_naming_hash_desc": "Utilisez MD5 ou oshash pour le nommage des fichiers générés. Le modifier exige que toutes les scènes soient renseignées avec une valeur MD5/oshash appropriée. Après avoir modifié cette valeur, les fichiers générés existants devront être migrés ou régénérés. Voir la page Tâches pour la migration.", "generated_file_naming_hash_head": "Empreinte pour le nommage des fichiers générés", - "generated_files_location": "Emplacement du répertoire des fichiers générés (marqueurs de scène, aperçus de scène, sprites, etc.)", + "generated_files_location": "Emplacement des fichiers générés (marqueurs de scène, aperçus de scène, sprites, etc.)", "generated_path_head": "Chemin des fichiers générés", "hashing": "Hachage", "image_ext_desc": "Liste d'extensions de fichiers séparées par des virgules qui seront reconnues comme des images.", @@ -276,10 +286,10 @@ "maximum_transcode_size_desc": "Résolution maximale pour les transcodes générés", "maximum_transcode_size_head": "Résolution maximale de transcodage", "metadata_path": { - "description": "Emplacement du répertoire utilisé lors d'une exportation ou d'une importation complète", + "description": "Emplacement utilisé lors d'une exportation ou d'une importation complète", "heading": "Chemin des métadonnées" }, - "number_of_parallel_task_for_scan_generation_desc": "Définissez à 0 pour une détection automatique. Avertissement exécuter plus de tâches que ce qui est nécessaire pour atteindre une utilisation à 100% du processeur diminuera les performances et pourra causer d'autres problèmes.", + "number_of_parallel_task_for_scan_generation_desc": "Définir à 0 pour une détection automatique. Avertissement exécuter plus de tâches que ce qui est nécessaire pour atteindre une utilisation à 100% du processeur diminuera les performances et pourra causer d'autres problèmes.", "number_of_parallel_task_for_scan_generation_head": "Nombre de tâches parallèles pour l'analyse et la génération", "parallel_scan_head": "Analyse ou génération en parallèle", "preview_generation": "Génération d'aperçu", @@ -288,9 +298,9 @@ "heading": "Chemin de Python" }, "scraper_user_agent": "Agent utilisateur de l'extracteur", - "scraper_user_agent_desc": "Chaîne utilisateur utilisée dans les requêtes http lors de l'extraction de contenu", + "scraper_user_agent_desc": "Chaîne agent utilisateur utilisée par les requêtes http lors de l'extraction de contenu", "scrapers_path": { - "description": "Emplacement du répertoire des fichiers de configuration de l'extracteur de contenu", + "description": "Emplacement des fichiers de configuration des extracteurs de contenu", "heading": "Chemin des extracteurs de contenu" }, "scraping": "Extraction de données", @@ -312,9 +322,9 @@ "triggers_on": "Déclenche sur" }, "scraping": { - "entity_metadata": "{entityType} Métadonnées", - "entity_scrapers": "{entityType} extraits", - "excluded_tag_patterns_desc": "Expressions régulières de noms d'étiquettes à exclure des résultats de scraping", + "entity_metadata": "Métadonnées {entityType}", + "entity_scrapers": "Extracteurs de {entityType}s", + "excluded_tag_patterns_desc": "Expressions régulières de noms d'étiquettes à exclure des résultats de l'extraction", "excluded_tag_patterns_head": "Modèles d'étiquette excluse", "scraper": "Extracteur", "scrapers": "Extracteurs", @@ -325,11 +335,11 @@ "stashbox": { "add_instance": "Ajouter une instance Stash-Box", "api_key": "Clé API", - "description": "Stash-Box simplifie l'étiquetage automatique des scènes et des performeurs en se basant sur les empreintes digitales et les noms de fichiers.\nLe point de connexion et la clé API se trouvent sur la page de votre compte de l'instance de stash-box. Les noms sont requis lorsque plusieurs instances sont ajoutées.", - "endpoint": "Point de connexion", - "graphql_endpoint": "Point de connexion GraphQL", + "description": "Stash-Box simplifie l'étiquetage automatique des scènes et performeurs en se basant sur des empreintes numériques et noms de fichiers.\nLe point de terminaison et la clé API se trouvent sur la page de votre compte d'instance stash-box. Les noms sont requis lorsque plusieurs instances sont ajoutées.", + "endpoint": "Point de terminaison", + "graphql_endpoint": "Point de terminaison GraphQL", "name": "Nom", - "title": "Points de connexion Stash-Box" + "title": "Points de terminaison Stash-Box" }, "system": { "transcoding": "Transcodage" @@ -344,23 +354,23 @@ "auto_tagging": "Étiquetage automatique", "backing_up_database": "Sauvegarde de la base de données", "backup_and_download": "Effectue une sauvegarde de la base de données et télécharge le fichier résultant.", - "backup_database": "Effectue une sauvegarde de la base de données dans le même répertoire que celle-ci, avec le format de nom de fichier {filename_format}", + "backup_database": "Effectue un enregistrement de la base de données dans le répertoire de sauvegarde, avec le format de nom de fichier {filename_format}", "cleanup_desc": "Vérifier les fichiers manquants et les supprimer de la base de données. Cette action est destructive.", "data_management": "Gestion des données", "defaults_set": "Les valeurs par défaut ont été définies et seront utilisées en cliquant sur le bouton {action} de la page Tâches.", "dont_include_file_extension_as_part_of_the_title": "Ne pas inclure l'extension du fichier dans le titre", "empty_queue": "Aucune tâche en cours d'exécution.", - "export_to_json": "Exporte le contenu de la base de données au format JSON dans le répertoire des métadonnées.", + "export_to_json": "Exporter le contenu de la base de données au format JSON dans le répertoire des métadonnées.", "generate": { "generating_from_paths": "Génération pour les scènes à partir des chemins suivants", "generating_scenes": "Génération pour {num} {scene}" }, - "generate_desc": "Générer les images associées, images animées, vidéos, sous-titres *.vtt et autres fichiers.", + "generate_desc": "Générer les supports images, sprites, vidéos, vtt et autres fichiers associés.", "generate_phashes_during_scan": "Générer des empreintes perceptuelles", - "generate_phashes_during_scan_tooltip": "Pour la déduplication et l'identification de scènes.", + "generate_phashes_during_scan_tooltip": "Pour la déduplication et l'identification des scènes.", "generate_previews_during_scan": "Générer des aperçus d'images animées", - "generate_previews_during_scan_tooltip": "Générer des aperçus WebP animés, requis uniquement si le type d'aperçu est défini sur Image animée.", - "generate_sprites_during_scan": "Générer les images animées de progression", + "generate_previews_during_scan_tooltip": "Générer des aperçus WebP animés, requis uniquement si le mode d'aperçu est défini sur Image animée.", + "generate_sprites_during_scan": "Générer les sprites de progression", "generate_thumbnails_during_scan": "Générer des vignettes pour les images", "generate_video_previews_during_scan": "Générer les aperçus", "generate_video_previews_during_scan_tooltip": "Générer des aperçus vidéo joués lors du survol d'une scène", @@ -369,7 +379,7 @@ "and_create_missing": "et créer les manquants", "create_missing": "Créer les manquants", "default_options": "Options par défaut", - "description": "Définissez automatiquement les métadonnées de la scène en utilisant les sources Stash-Box et extracteur de contenu.", + "description": "Définir automatiquement les métadonnées des scènes en utilisant les sources Stash-Box et extracteur de contenu.", "explicit_set_description": "Les options suivantes seront utilisées si elles ne sont pas remplacées par les options spécifiques à la source.", "field": "Champ", "field_behaviour": "{strategy} {field}", @@ -388,7 +398,7 @@ "import_from_exported_json": "Importation à partir du JSON exporté dans le répertoire des métadonnées. Efface la base de données existante.", "incremental_import": "Importation incrémentielle à partir d'un fichier zip d'exportation fourni.", "job_queue": "File d'attente des tâches", - "maintenance": "Entretien", + "maintenance": "Maintenance", "migrate_hash_files": "Utilisé après modification de l'empreinte des fichiers générés pour renommer les existants au nouveau format.", "migrations": "Migrations", "only_dry_run": "Effectuer un essai à blanc. Ne supprime rien", @@ -420,24 +430,38 @@ "scene_tools": "Outils de scène" }, "ui": { + "abbreviate_counters": { + "description": "Abréger les compteurs sur les fiches et pages de détails, par exemple \"1831\" sera formaté en \"1.8K\".", + "heading": "Abréger les compteurs" + }, "basic_settings": "Paramètres de base", "custom_css": { "description": "La page doit être rafraichie pour que les changements prennent effet.", "heading": "CSS personnalisé", "option_label": "Activer le CSS personnalisé" }, + "custom_javascript": { + "description": "La page doit être actualisée pour que les changements prennent effet.", + "heading": "Javascript personnalisé", + "option_label": "Activer le Javascript personnalisé" + }, + "custom_locales": { + "description": "Remplacer individuellement des chaines linguistiques. Voir https://github.com/stashapp/stash/blob/develop/ui/v2.5/src/locales/fr-FR.json pour la liste principale. La page doit être actualisée pour que les changements prennent effet.", + "heading": "Traduction personnalisée", + "option_label": "Activer la traduction personnalisée" + }, "delete_options": { "description": "Réglages par défaut lors de la suppression d'images, galeries, et scènes.", "heading": "Options de suppression", "options": { "delete_file": "Supprimer le fichier par défaut", - "delete_generated_supporting_files": "Supprimer par défaut les fichiers associés générés" + "delete_generated_supporting_files": "Supprimer par défaut les fichiers générés associés" } }, "desktop_integration": { "desktop_integration": "Intégration au bureau", "notifications_enabled": "Activer les notifications", - "send_desktop_notifications_for_events": "Envoyer des notifications au bureau en cas d'événements", + "send_desktop_notifications_for_events": "Envoyer des notifications sur le bureau en cas d'événements", "skip_opening_browser": "Ne pas ouvrir de navigateur", "skip_opening_browser_on_startup": "Ignorer l'ouverture automatique du navigateur lors du démarrage" }, @@ -446,7 +470,24 @@ "description": "Supprimer la possibilité de créer de nouveaux objets à partir des sélecteurs de liste déroulante", "heading": "Désactiver la création depuis la liste déroulante" }, - "heading": "Édition" + "heading": "Édition", + "rating_system": { + "star_precision": { + "label": "Précision de la notation", + "options": { + "full": "Complète", + "half": "Moitié", + "quarter": "Quart" + } + }, + "type": { + "label": "Mode de notation", + "options": { + "decimal": "Décimal", + "stars": "Étoiles" + } + } + } }, "funscript_offset": { "description": "Décalage temporel en millisecondes pour la lecture des scripts interactifs.", @@ -470,10 +511,10 @@ "heading": "Visionneuse d'images" }, "images": { - "heading": "Photos", + "heading": "Images", "options": { "write_image_thumbnails": { - "description": "Écrire les vignettes des images sur le disque lorsqu'elles sont générées à la volée", + "description": "Écrire les vignettes des images sur disque lorsqu'elles sont générées à la volée", "heading": "Enregistrer les vignettes des images" } } @@ -490,17 +531,21 @@ "description": "Afficher ou masquer différents types de contenus dans la barre de navigation", "heading": "Éléments de la barre de navigation" }, + "minimum_play_percent": { + "description": "Pourcentage de temps pendant lequel une scène doit être jouée avant que son compteur de lecture ne soit incrémenté.", + "heading": "Pourcentage de lecture minimum" + }, "performers": { "options": { "image_location": { - "description": "Chemin personnalisé pour les images par défaut de performeur. Laisser vide pour utiliser les valeurs par défaut intégrées", - "heading": "Chemin de l'image du performeur personnalisé" + "description": "Emplacement personnalisé pour les images de performeurs par défaut. Laisser vide pour utiliser les réglages intégrés par défaut", + "heading": "Chemin de l'image personnalisée du performeur" } } }, "preview_type": { "description": "Configuration des éléments du mur", - "heading": "Type d'aperçu", + "heading": "Mode d'aperçu", "options": { "animated": "Image animée", "static": "Image fixe", @@ -508,24 +553,26 @@ } }, "scene_list": { - "heading": "Liste de scène", + "heading": "Liste de scènes", "options": { "show_studio_as_text": "Afficher les studios sous format texte" } }, "scene_player": { - "heading": "Lecteur de scène", + "heading": "Lecteur de scènes", "options": { + "always_start_from_beginning": "Toujours démarrer la vidéo depuis le début", "auto_start_video": "Démarrer automatiquement la vidéo", "auto_start_video_on_play_selected": { - "description": "Démarrer automatiquement les scènes vidéo lorsque lecture est sélectionnée ou aléatoire à partir de la page Scènes", + "description": "Démarrage automatique des scènes vidéo lorsqu'elles sont lues à partir de la file d'attente, d'une sélection ou aléatoires à partir de la page Scènes", "heading": "Démarrer automatiquement la vidéo lorsque lecture est sélectionnée" }, "continue_playlist_default": { - "description": "Lire la scène suivante dans la file d'attente lorsque la vidéo se termine", + "description": "Lire la scène suivante de la file d'attente lorsque une vidéo se termine", "heading": "Continuer la liste de lecture par défaut" }, - "show_scrubber": "Montrer la barre de progression" + "show_scrubber": "Montrer la barre de progression", + "track_activity": "Suivre l'activité" } }, "scene_wall": { @@ -536,13 +583,35 @@ } }, "scroll_attempts_before_change": { - "description": "Nombre de tentatives de défilement avant de passer à l'élément suivant/précédent. S'applique uniquement au mode de défilement Pan Y.", + "description": "Nombre de tentatives de défilement avant de passer à l'élément suivant/précédent. S'applique uniquement au mode de défilement Panoramique Y.", "heading": "Tentatives de défilement avant transition" }, + "show_tag_card_on_hover": { + "description": "Afficher une fiche d'identification lors du survol des badges d'étiquettes", + "heading": "Infobulles d'identification" + }, "slideshow_delay": { "description": "Le diaporama est disponible dans galerie en mode de vue mural", "heading": "Délai du diaporama (secondes)" }, + "studio_panel": { + "heading": "Vue studios", + "options": { + "show_child_studio_content": { + "description": "Dans la vue studios, afficher également le contenu des studios affiliés", + "heading": "Afficher le contenu des studios affiliés" + } + } + }, + "tag_panel": { + "heading": "Vue étiquettes", + "options": { + "show_child_tagged_content": { + "description": "Dans la vue étiquettes, afficher également le contenu des étiquettes affiliées", + "heading": "Afficher le contenu des étiquettes affiliées" + } + } + }, "title": "Interface utilisateur" } }, @@ -550,16 +619,16 @@ "countables": { "files": "{count, plural, one {Fichier} other {Fichiers}}", "galleries": "{count, plural, one {Galerie} other {Galeries}}", - "images": "{count, plural, one {Image} sur {Images}}", + "images": "{count, plural, one {Image} other {Images}}", "markers": "{count, plural, one {Marqueur} other {Marqueurs}}", "movies": "{count, plural, one {Film} other {Films}}", "performers": "{count, plural, one {Performeur} other {Performeurs}}", "scenes": "{count, plural, one {Scène} other {Scènes}}", - "studios": "{count, plural, one {Studio} sur {Studios}}", + "studios": "{count, plural, one {Studio} other {Studios}}", "tags": "{count, plural, one {Étiquette} other {Étiquettes}}" }, "country": "Pays", - "cover_image": "Image de couverture", + "cover_image": "Vignette", "created_at": "Créé le", "criterion": { "greater_than": "Supérieur à", @@ -587,20 +656,24 @@ "death_date": "Date du décès", "death_year": "Année du décès", "descending": "Descendant", + "description": "Description", "detail": "Détail", "details": "Détails", "developmentVersion": "Version de développement", "dialogs": { "aliases_must_be_unique": "Les alias doivent être uniques", - "delete_alert": "Le·s {count, plural, one {{singularEntity}} suivant·s sur {{pluralEntity}}} sera·ont supprimé·s définitivement :", + "create_new_entity": "Créer un nouveau {entity}", + "delete_alert": "{count, plural, one {Le {singularEntity} suivant sera supprimé} other {Les {pluralEntity} suivants seront supprimés}} définitivement :", "delete_confirm": "Êtes-vous sûr de vouloir supprimer {entityName} ?", - "delete_entity_desc": "{count, plural, one {Êtes-vous sûr de vouloir supprimer cette {singularEntity} ? À moins que le fichier ne soit également supprimé, cette {singularEntity} sera ajoutée à nouveau lors de la prochaine analyse.} other {Êtes-vous sûr de vouloir supprimer ces {pluralEntity} ? À moins que les fichiers ne soient également supprimés, ces {pluralEntity} seront ajoutées à nouveau lors de la prochaine analyse.}}", - "delete_entity_title": "{count, plural, one {Delete {singularEntity}} sur {Delete {pluralEntity}}}", + "delete_entity_desc": "{count, plural, one {Êtes-vous sûr de vouloir supprimer cette {singularEntity} ? Si le fichier n'est pas également supprimé, cette {singularEntity} sera ajoutée à nouveau lors de la prochaine analyse.} other {Êtes-vous sûr de vouloir supprimer ces {pluralEntity} ? Si les fichiers ne sont pas également supprimés, ces {pluralEntity} seront ajoutées à nouveau lors de la prochaine analyse.}}", + "delete_entity_simple_desc": "{count, plural, one {Êtes-vous sûr de vouloir supprimer ce {singularEntity}?} other {Êtes-vous sûr de vouloir supprimer ces {pluralEntity}?}}", + "delete_entity_title": "Supprimer {count, plural, one {{singularEntity}} other {{pluralEntity}}}", "delete_galleries_extra": "…ainsi que tous fichiers image qui ne sont pas associés à une autre galerie.", "delete_gallery_files": "Supprime le répertoire ou l'archive zip de la galerie et toutes images qui ne sont pas associées à une autre galerie.", - "delete_object_desc": "Êtes-vous sûr de vouloir supprimer {count, plural, one {this {singularEntity}} other {these {pluralEntity}}} ?", + "delete_object_desc": "Êtes-vous sûr de vouloir supprimer {count, plural, one {ce {singularEntity}} other {ces {pluralEntity}}} ?", "delete_object_overflow": "…et {count} {count, plural, one {autre {singularEntity}} other {autres {pluralEntity}}}.", "delete_object_title": "Supprimer {count, plural, one {{singularEntity}} other {{pluralEntity}}}", + "dont_show_until_updated": "Ne pas montrer avant la prochaine mise à jour", "edit_entity_title": "Éditer {count, plural, one {{singularEntity}} other {{pluralEntity}}}", "export_include_related_objects": "Inclure les objets liés dans l'exportation", "export_title": "Exporter", @@ -625,11 +698,20 @@ "zoom": "Zoom" } }, + "merge": { + "destination": "Destination", + "empty_results": "Les valeurs des champs de destination seront inchangées.", + "source": "Source" + }, "merge_tags": { "destination": "Destination", "source": "Source" }, "overwrite_filter_confirm": "Êtes-vous sûr de vouloir remplacer la requête sauvegardée existante {entityName} ?", + "reassign_entity_title": "{count, plural, one {Réaffecté {singularEntity}} other {Réaffectés {pluralEntity}}}", + "reassign_files": { + "destination": "Réaffecter à" + }, "scene_gen": { "force_transcodes": "Forcer la génération du transcodage", "force_transcodes_tooltip": "Par défaut, les transcodes ne sont générés que lorsque le fichier vidéo n'est pas pris en charge par le navigateur. Activé, les transcodes seront générés même si le fichier vidéo semble être pris en charge par le navigateur.", @@ -684,7 +766,7 @@ }, "donate": "Faire un don", "dupe_check": { - "description": "Les niveaux en-deça de \"Exact\" peuvent prendre plus de temps à calculer. Des faux positifs peuvent également être retournés à de faibles précisions.", + "description": "Les niveaux en-deça de \"Exacte\" peuvent prendre plus de temps à calculer. Des faux positifs peuvent également être retournés à de faibles précisions.", "found_sets": "{setCount, plural, one{# ensemble de doublons trouvé.} other {# ensembles de doublons trouvés.}}", "options": { "exact": "Exacte", @@ -692,7 +774,7 @@ "low": "Basse", "medium": "Moyenne" }, - "search_accuracy_label": "La pertinence de la recherche", + "search_accuracy_label": "Pertinence de recherche", "title": "Scènes dupliquées" }, "duplicated_phash": "Empreinte dupliquée", @@ -726,18 +808,21 @@ "false": "Faux", "favourite": "Favoris", "file": "fichier", + "file_count": "Nombre de fichiers", "file_info": "Infos fichier", "file_mod_time": "Date de modification du fichier", "files": "fichiers", - "filesize": "Taille du fichier", + "files_amount": "{value} fichiers", + "filesize": "Poids du fichier", "filter": "Filtre", "filter_name": "Nom du filtre", "filters": "Filtres", + "folder": "Répertoire", "framerate": "Fréquence", "frames_per_second": "{value} images par seconde", "front_page": { "types": { - "premade_filter": "Filtre primaire", + "premade_filter": "Filtre prédéfini", "saved_filter": "Filtre sauvegardé" } }, @@ -765,18 +850,20 @@ }, "hasMarkers": "Dispose de marqueurs", "height": "Taille", + "height_cm": "Taille (cm)", "help": "Aide", "ignore_auto_tag": "Ignorer l'étiquetage automatique", "image": "Image", "image_count": "Nombre d'Images", - "images": "Photos", + "images": "Images", "include_parent_tags": "Inclure les étiquettes parentes", "include_sub_studios": "Inclure les studios affiliés", - "include_sub_tags": "Inclure les sous-étiquettes", + "include_sub_tags": "Inclure les étiquettes affiliées", "instagram": "Instagram", "interactive": "Interactif", "interactive_speed": "Vitesse interactive", "isMissing": "Est manquant", + "last_played_at": "Dernière lecture le", "library": "Bibliothèque", "loading": { "generic": "Chargement…" @@ -795,13 +882,15 @@ "age_context": "{age} {years_old} dans cette scène" }, "phash": "Empreinte", + "play_count": "Compteur de lecture", + "play_duration": "Temps de lecture", "stream": "Flux", "video_codec": "Codec vidéo" }, "megabits_per_second": "{value} mégabits par seconde", "metadata": "Métadonnées", "movie": "Film", - "movie_scene_number": "Nombre de Scènes de films", + "movie_scene_number": "Numéro de scène du film", "movies": "Films", "name": "Nom", "new": "Nouveau", @@ -822,12 +911,12 @@ "part_of": "Fait partie de {parent}", "path": "Chemin", "perceptual_similarity": "Similitude perceptuelle (empreinte)", - "performer": "Performeur", + "performer": "Performeurs", "performerTags": "Étiquettes de performeur", "performer_age": "Âge du performeur", - "performer_count": "Nombre de performeur", + "performer_count": "Nombre de performeurs", "performer_favorite": "Performeur favori", - "performer_image": "Photo du performeur", + "performer_image": "Image du performeur", "performer_tagger": { "add_new_performers": "Ajouter de nouveaux performeurs", "any_names_entered_will_be_queried": "Tout nom saisi sera demandé à l'instance distante StashBox et ajouté si trouvé. Seules les correspondances exactes seront considérées comme équivalentes.", @@ -865,24 +954,33 @@ }, "performers": "Performeurs", "piercings": "Perçages", + "play_count": "Compteur de lecture", + "play_duration": "Temps de lecture", + "primary_file": "Fichier principal", "queue": "Liste de lecture", "random": "Aléatoire", "rating": "Note", "recently_added_objects": "{objects} récemment ajoutés", "recently_released_objects": "{objects} récemment ajoutées", + "release_notes": "Notes de publication", "resolution": "Résolution", + "resume_time": "Reprendre le temps", "scene": "Scène", "sceneTagger": "Étiqueteuse de scènes", "sceneTags": "Étiquettes de scène", + "scene_code": "Code studio", "scene_count": "Nombre de scènes", + "scene_created_at": "Scène créée le", + "scene_date": "Date de la scène", "scene_id": "ID de scène", + "scene_updated_at": "Scène mise à jour le", "scenes": "Scènes", "scenes_updated_at": "Scène actualisée le", "search_filter": { "add_filter": "Ajouter un filtre", "name": "Filtre", "saved_filters": "Filtres sauvegardés", - "update_filter": "Mise à jour du filtre" + "update_filter": "Filtre actualisé" }, "seconds": "Secondes", "settings": "Paramètres", @@ -912,16 +1010,17 @@ }, "github_repository": "Dépôt Github", "migrate": { - "backup_database_path_leave_empty_to_disable_backup": "Chemin de la base de données de sauvegarde (laissez vide pour désactiver la sauvegarde) :", + "backup_database_path_leave_empty_to_disable_backup": "Chemin de sauvegarde de la base de données (laisser vide pour désactiver la sauvegarde) :", "backup_recommended": "Il est recommandé de sauvegarder votre base de données existante avant de procéder à la migration. Nous pouvons le faire pour vous, en faisant une copie de votre base de données dans {defaultBackupPath}.", "migrating_database": "Migration de la base de données", "migration_failed": "Migration échouée", "migration_failed_error": "L'erreur suivante a été rencontrée lors de la migration de la base de données :", "migration_failed_help": "Veuillez apporter les corrections nécessaires et réessayer. Sinon, signalez un bogue sur {githubLink} ou demandez de l'aide sur {discordLink}.", - "migration_irreversible_warning": "Le processus de migration des schémas n'est pas réversible. Une fois la migration effectuée, votre base de données sera incompatible avec les versions précédentes de Stash.", + "migration_irreversible_warning": "Le processus de migration des schémas est irréversible. Une fois la migration effectuée, votre base de données sera incompatible avec les versions précédentes de Stash.", + "migration_notes": "Notes de migration", "migration_required": "Migration requise", "perform_schema_migration": "Procéder à la migration du schéma", - "schema_too_old": "La version du schéma de votre base de données Stash actuelle est {databaseSchema} et doit être migrée vers la version {appSchema}. Cette version de Stash ne fonctionnera pas sans migration de la base de données." + "schema_too_old": "La version du schéma de votre base de données Stash actuelle est {databaseSchema} et doit être migrée vers la version {appSchema}. Cette version de Stash ne fonctionnera pas sans migration de la base de données. Si vous souhaitez ne pas migrer, vous devrez rétrograder vers une version qui correspond au schéma de votre base de données." }, "paths": { "database_filename_empty_for_default": "Nom de fichier de la base de données (vide par défaut)", @@ -932,7 +1031,7 @@ "where_can_stash_store_its_database": "Où Stash peut-il stocker sa base de données ?", "where_can_stash_store_its_database_description": "Stash utilise une base de données sqlite pour stocker vos métadonnées pornographiques. Par défaut, cette base sera créée en tant que stash-go.sqlite dans le répertoire contenant votre fichier de configuration. Si vous souhaitez modifier cela, saisissez un nom de fichier absolu ou relatif ( vers le répertoire de travail actuel).", "where_can_stash_store_its_generated_content": "Où Stash peut-il stocker son contenu généré ?", - "where_can_stash_store_its_generated_content_description": "Afin de produire les vignettes, les aperçus et les images animées, Stash génère des images et des vidéos. Cela inclut également les transcodes pour les formats de fichiers non pris en charge. Par défaut, Stash crée un répertoire generated dans le répertoire contenant votre fichier de configuration. Si vous souhaitez modifier l'emplacement où seront stockés les médias générés, veuillez saisir un chemin absolu ou relatif ( vers le répertoire de travail actuel). Stash créera ce répertoire s'il n'existe pas déjà.", + "where_can_stash_store_its_generated_content_description": "Afin de produire les vignettes, aperçus et sprites, Stash génère des images et des vidéos. Cela inclut également les transcodes pour les formats de fichiers non pris en charge. Par défaut, Stash crée un répertoire generated dans le répertoire contenant votre fichier de configuration. Si vous souhaitez modifier l'emplacement où seront stockés les médias générés, veuillez saisir un chemin absolu ou relatif ( vers le répertoire de travail actuel). Stash créera ce répertoire s'il n'existe pas déjà.", "where_is_your_porn_located": "Où se trouve votre porno ?", "where_is_your_porn_located_description": "Ajoutez des répertoires contenant vos vidéos et images pornographiques. Stash utilisera ces répertoires pour rechercher les vidéos et les images lors de l'analyse." }, @@ -966,43 +1065,46 @@ "welcome_to_stash": "Bienvenue sur Stash" }, "stash_id": "ID Stash", - "stash_ids": "IDs Stash", + "stash_id_endpoint": "Point de terminaison Stash ID", + "stash_ids": "ID Stash", "stashbox": { - "go_review_draft": "Allez à {endpoint_name} pour revoir le document.", - "selected_stash_box": "Point de connexion Stash-Box sélectionné", + "go_review_draft": "Allez sur {endpoint_name} pour examiner l'ébauche.", + "selected_stash_box": "Point de terminaison Stash-Box sélectionné", "submission_failed": "Envoi échoué", "submission_successful": "Envoi réussi", "submit_update": "Existe déjà dans {endpoint_name}" }, "statistics": "Statistiques", "stats": { - "image_size": "Taille des images", + "image_size": "Poids des images", "scenes_duration": "Durée des scènes", - "scenes_size": "Taille des scènes" + "scenes_size": "Poids des scènes" }, "status": "Statut : {statusText}", "studio": "Studio", "studio_depth": "Niveaux (vides pour tous)", "studios": "Studios", - "sub_tag_count": "Nombre de sous-étiquettes", - "sub_tag_of": "Sous-étiquette de {parent}", - "sub_tags": "Sous-étiquettes", + "sub_tag_count": "Nombre d'étiquettes affiliées", + "sub_tag_of": "Étiquette affiliée de {parent}", + "sub_tags": "Étiquettes affiliées", "subsidiary_studios": "Studios affiliés", "synopsis": "Résumé", - "tag": "Étiquette", + "tag": "Étiquettes", "tag_count": "Nombre d'étiquettes", "tags": "Étiquettes", "tattoos": "Tatouages", "title": "Titre", "toast": { - "added_entity": "{count, plural, one {{singularEntity}} ajouté·e·s sur {{pluralEntity}}}", + "added_entity": "{count, plural, one {{singularEntity} ajouté} other {{pluralEntity} ajoutés}}", "added_generation_job_to_queue": "Ajout d'une tâche de génération en file d'attente", "created_entity": "{entity} créé·e", "default_filter_set": "Filtre par défaut défini", - "delete_past_tense": "{count, plural, one {{singularEntity}} sur {{pluralEntity}}} supprimé·e·s", + "delete_past_tense": "{count, plural, one {{singularEntity} supprimé} other {{pluralEntity} supprimés}}", "generating_screenshot": "Génération de la capture d'écran…", + "merged_scenes": "Scènes fusionnées", "merged_tags": "Étiquettes fusionnées", - "removed_entity": "{count, plural, one {{singularEntity}} sur {{pluralEntity}}} supprimé·e·s", + "reassign_past_tense": "Fichier réaffecté", + "removed_entity": "{count, plural, one {{singularEntity} retiré} other {{pluralEntity} retirés}}", "rescanning_entity": "Réanalyse de {count, plural, one {{singularEntity}} other {{pluralEntity}}}…", "saved_entity": "{entity} sauvegardé·e", "started_auto_tagging": "Démarrage de l'étiquetage automatique", @@ -1019,5 +1121,7 @@ "videos": "Vidéos", "view_all": "Tout voir", "weight": "Poids", - "years_old": "ans" + "weight_kg": "Poids (kg)", + "years_old": "ans", + "zip_file_count": "Nombre de fichiers Zip" } diff --git a/ui/v2.5/src/locales/index.ts b/ui/v2.5/src/locales/index.ts index bf6fd19b4..55d220b48 100644 --- a/ui/v2.5/src/locales/index.ts +++ b/ui/v2.5/src/locales/index.ts @@ -1,3 +1,47 @@ +import Countries from "i18n-iso-countries"; + +export const localeCountries = { + bn: () => import("i18n-iso-countries/langs/bn.json"), + cs: () => import("i18n-iso-countries/langs/cs.json"), + da: () => import("i18n-iso-countries/langs/da.json"), + de: () => import("i18n-iso-countries/langs/de.json"), + en: () => import("i18n-iso-countries/langs/en.json"), + es: () => import("i18n-iso-countries/langs/es.json"), + et: () => import("i18n-iso-countries/langs/et.json"), + fa: () => import("i18n-iso-countries/langs/fa.json"), + fi: () => import("i18n-iso-countries/langs/fi.json"), + fr: () => import("i18n-iso-countries/langs/fr.json"), + hu: () => import("i18n-iso-countries/langs/hu.json"), + hr: () => import("i18n-iso-countries/langs/hr.json"), + it: () => import("i18n-iso-countries/langs/it.json"), + ja: () => import("i18n-iso-countries/langs/ja.json"), + ko: () => import("i18n-iso-countries/langs/ko.json"), + nl: () => import("i18n-iso-countries/langs/nl.json"), + pl: () => import("i18n-iso-countries/langs/pl.json"), + pt: () => import("i18n-iso-countries/langs/pt.json"), + ro: () => import("i18n-iso-countries/langs/ro.json"), + ru: () => import("i18n-iso-countries/langs/ru.json"), + sv: () => import("i18n-iso-countries/langs/sv.json"), + th: () => import("i18n-iso-countries/langs/th.json"), + tr: () => import("i18n-iso-countries/langs/tr.json"), + uk: () => import("i18n-iso-countries/langs/uk.json"), + zh: () => import("i18n-iso-countries/langs/zh.json"), + tw: () => import("src/locales/countryNames/zh-TW.json"), + // eslint-disable-next-line @typescript-eslint/no-explicit-any +} as { [key: string]: any }; + +export const getLocaleCode = (code: string) => { + if (code === "zh-CN") return "zh"; + if (code === "zh-TW") return "tw"; + return code.slice(0, 2); +}; + +export async function registerCountry(locale: string) { + const localeCode = getLocaleCode(locale); + const countries = await localeCountries[localeCode](); + Countries.registerLocale(countries); +} + export const localeLoader = { deDE: () => import("./de-DE.json"), enGB: () => import("./en-GB.json"), @@ -18,6 +62,14 @@ export const localeLoader = { plPL: () => import("./pl-PL.json"), daDK: () => import("./da-DK.json"), koKR: () => import("./ko-KR.json"), + ukUA: () => import("./uk-UA.json"), + bnBD: () => import("./bn-BD.json"), + csCZ: () => import("./cs-CZ.json"), + etEE: () => import("./et-EE.json"), + faIR: () => import("./fa-IR.json"), + huHU: () => import("./hu-HU.json"), + roRO: () => import("./ro-RO.json"), + thTH: () => import("./th-TH.json"), // eslint-disable-next-line @typescript-eslint/no-explicit-any } as { [key: string]: any }; diff --git a/ui/v2.5/src/locales/it-IT.json b/ui/v2.5/src/locales/it-IT.json index 430b059c5..ce4a78385 100644 --- a/ui/v2.5/src/locales/it-IT.json +++ b/ui/v2.5/src/locales/it-IT.json @@ -54,6 +54,7 @@ "import": "Importa…", "import_from_file": "Importa dal file", "logout": "Esci", + "make_primary": "Rendi Primario", "merge": "Unisci", "merge_from": "Unisci da", "merge_into": "Unisci in", @@ -66,6 +67,7 @@ "play_selected": "Avvia selezionato", "preview": "Anteprima", "previous_action": "Precedente", + "reassign": "Riassegna", "refresh": "Aggiorna", "reload_plugins": "Ricarica plugin", "reload_scrapers": "Ricarica scraper", @@ -98,10 +100,12 @@ "show": "Mostra", "show_configuration": "Mostra Configurazione", "skip": "Salta", + "split": "Dividi", "stop": "Stop", "submit": "Invia", "submit_stash_box": "Invia a Stash-Box", "submit_update": "Invia aggiornamento", + "swap": "Scambia", "tasks": { "clean_confirm_message": "Sei sicuro di voler Pulire? Questa azione cancellerà informazioni e contenuto creato dal database per tutte le scene e gallerie che non si trovano più nel file system.", "dry_mode_selected": "Dry Mode selezionato. Nessuna cancellazione avverrà, solo log.", @@ -120,6 +124,7 @@ "also_known_as": "Anche conosciuto/a come", "ascending": "Ascendente", "average_resolution": "Risoluzione Media", + "between_and": "e", "birth_year": "Anno di Nascita", "birthdate": "Compleanno", "bitrate": "Bit Rate", @@ -189,6 +194,7 @@ }, "categories": { "about": "Chi siamo", + "changelog": "Changelog", "interface": "Interfaccia", "logs": "Log", "metadata_providers": "Provider dei Metadata", @@ -243,6 +249,10 @@ "username": "Nome Utente", "username_desc": "Nome Utente per accedere a Stash. Lasciare vuoto per disabilitare l'autenticazione" }, + "backup_directory_path": { + "description": "Percorso della directory per i file di backup del database SQLite", + "heading": "Percorso Directory di Backup" + }, "cache_location": "Percorso della Cartella cache", "cache_path_head": "Percorso Cache", "calculate_md5_and_ohash_desc": "Calcola l'MD5 checksum oltre l'oshash. Attivare la funzione causerà una prima scansione più lenta. L'hash dei nomi file dev'essere impostata su oshash per disabilitare il calcolo MD5.", @@ -344,7 +354,7 @@ "auto_tagging": "Tag Automatico", "backing_up_database": "Backup del database", "backup_and_download": "Esegue il backup del database e scarica il file risultante.", - "backup_database": "Esegue il backup del database nella stessa cartella del database, con il formato nome file {filename_format}", + "backup_database": "Esegue il backup del database nella cartella dei backup, nel formato {filename_format}", "cleanup_desc": "Controlla il database per file mancanti e li rimuove. Questa è un'azione distruttiva.", "data_management": "Gestione dati", "defaults_set": "I default sono stati scelti e saranno usati quando si clicca il bottone {action} nella pagina Attività.", @@ -420,12 +430,26 @@ "scene_tools": "Strumenti Scena" }, "ui": { + "abbreviate_counters": { + "description": "Abbrevia i contatori nelle carte e dettagli delle pagine, per esempio \"1831\" sarà formattato in \"1.8K\".", + "heading": "Abbrevia contatori" + }, "basic_settings": "Opzioni Base", "custom_css": { "description": "La pagina dev'essere ricaricata per applicare i cambiamenti.", "heading": "CSS Personalizzato", "option_label": "CSS Personalizzato Attivo" }, + "custom_javascript": { + "description": "La pagina dev'essere ricaricata per applicare i cambiamenti.", + "heading": "Javascript Personalizzato", + "option_label": "Javascript personalizzato attivo" + }, + "custom_locales": { + "description": "Sovrascrive stringhe individuali locali. Vedere https://github.com/stashapp/stash/blob/develop/ui/v2.5/src/locales/en-GB.json per la lista principale. La pagina dev'essere ricaricata per far sì che i cambiamenti abbiano effetto.", + "heading": "Localizzazione personalizzata", + "option_label": "Localizzazione personalizzata attiva" + }, "delete_options": { "description": "Opzioni predefinite quando si cancellano immagini, gallerie e scene.", "heading": "Opzioni di Cancellazione", @@ -446,7 +470,24 @@ "description": "Rimuove la possibilità di creare nuovi oggetti dai menù a tendina", "heading": "Disabilita crezione menù a tendina" }, - "heading": "Modifiche" + "heading": "Modifiche", + "rating_system": { + "star_precision": { + "label": "Precisione Stelle Valutazione", + "options": { + "full": "Pieno", + "half": "Metà", + "quarter": "Un quarto" + } + }, + "type": { + "label": "Tipo di Sistema di Valutazione", + "options": { + "decimal": "Decimale", + "stars": "Stelle" + } + } + } }, "funscript_offset": { "description": "Offset in millisecondi per gli script interattivi.", @@ -490,6 +531,10 @@ "description": "Mostra o nasconde differenti tipi di contenuti nella barra navigazione", "heading": "Oggetti Menù" }, + "minimum_play_percent": { + "description": "Percentuale di tempo in cui una scena dev'essere letta prima che il contatore visualizzazioni aumenti.", + "heading": "Minima Percentuale Lettura" + }, "performers": { "options": { "image_location": { @@ -516,16 +561,18 @@ "scene_player": { "heading": "Lettore Scene", "options": { + "always_start_from_beginning": "Inizia sempre il video dall'inizio", "auto_start_video": "Inizia a Leggere Automaticamente il video", "auto_start_video_on_play_selected": { - "description": "Avvio automatico dei video \"Avvia selezionato\" o casuale dalla pagina Scene", + "description": "Avvio automatico dei video quando avviati dalla coda, quando \"Avvia selezionato\" o casuale dalla pagina Scene", "heading": "Avvio automatico del video 'Avvia selezionato'" }, "continue_playlist_default": { "description": "Avvia la prossima scena in coda quando il video finisce", "heading": "Continua la playlist per impostazione predefinita" }, - "show_scrubber": "Mostra Scrubber" + "show_scrubber": "Mostra Scrubber", + "track_activity": "Traccia l'Attività" } }, "scene_wall": { @@ -539,10 +586,32 @@ "description": "Numero di tentativi di scorrimento prima di passare al prossimo/precedente oggetto. Si applica solo al modo di scorrimento Pan Y.", "heading": "Tentativi di scorrimento prima della transizione" }, + "show_tag_card_on_hover": { + "description": "Mostra la carta della tag quando si passa il mouse sopra le badge tag", + "heading": "Carta tag popup" + }, "slideshow_delay": { "description": "La presentazione è disponibile nelle gallerie quando in modalità muro", "heading": "Ritardo Presentazione (secondi)" }, + "studio_panel": { + "heading": "Vista Studio", + "options": { + "show_child_studio_content": { + "description": "Nella vista studio, visualizza anche il contenuto dei sub-studio", + "heading": "Visualizza contenuto sub-studio" + } + } + }, + "tag_panel": { + "heading": "Vista Tag", + "options": { + "show_child_tagged_content": { + "description": "Nella vista tag, visualizza anche il contenuto delle subtag", + "heading": "Visualizza contenuto subtag" + } + } + }, "title": "Interfaccia Utente" } }, @@ -587,20 +656,24 @@ "death_date": "Data Morte", "death_year": "Anno della Morte", "descending": "Discendente", + "description": "Descrizione", "detail": "Dettaglio", "details": "Dettagli", "developmentVersion": "Versione Sviluppo", "dialogs": { "aliases_must_be_unique": "gli alias devono essere univoci", + "create_new_entity": "Crea nuovo/a {entity}", "delete_alert": "Il seguente/I seguenti {count, plural, one {{singularEntity}} other {{pluralEntity}}} sarà/saranno cancellati permanentemente:", "delete_confirm": "Sei sicuro di voler cancellare {entityName}?", "delete_entity_desc": "{count, plural, one {Sei sicuro di voler cancellare questo/a {singularEntity}? A meno che anche il file venga cancellato, questo/a {singularEntity} sarà riaggiunto quando la scansione verrà effettuata.} other {Sei sicuro di voler cancellare questi/e {pluralEntity}? A meno che anche i file vengano cancellati, questi/e {pluralEntity} verranno riaggiunti quando la scansione verrà effettuata.}}", + "delete_entity_simple_desc": "{count, plural, one {Sei sicuro di voler cancellare questa/o {singularEntity}?} other {Sei sicuro di voler cancellare questi/e {pluralEntity}?}}", "delete_entity_title": "{count, plural, one {Cancellazione {singularEntity}} other {Cancellazione {pluralEntity}}}", "delete_galleries_extra": "...in più qualsiasi immagine non inclusa in nessun'altra galleria.", "delete_gallery_files": "Cancella gallerie cartella/zip e qualsiasi immagino non inclusa in altre gallerie.", "delete_object_desc": "Sei sicuro di voler cancellare {count, plural, one {questo/a {singularEntity}} other {questi/e {pluralEntity}}}?", "delete_object_overflow": "…e {count} other {count, plural, one {{singularEntity}} other {{pluralEntity}}}.", "delete_object_title": "Cancellazione {count, plural, one {{singularEntity}} other {{pluralEntity}}}", + "dont_show_until_updated": "Non mostrare fino al prossimo aggiornamento", "edit_entity_title": "Modifica {count, plural, one {{singularEntity}} other {{pluralEntity}}}", "export_include_related_objects": "Include gli oggetti collegati nell'esportazione", "export_title": "Esportazione", @@ -625,11 +698,20 @@ "zoom": "Zoom" } }, + "merge": { + "destination": "Destinazione", + "empty_results": "Il valore del campo destinazione rimarranno inalterati.", + "source": "Sorgente" + }, "merge_tags": { "destination": "Destinazione", "source": "Origine" }, "overwrite_filter_confirm": "Sei sicuro di voler sovrascrivere le esistenti query salvate {entityName}?", + "reassign_entity_title": "{count, plural, one {Riassegna {singularEntity}} other {Riassegna {pluralEntity}}}", + "reassign_files": { + "destination": "Riassegna a" + }, "scene_gen": { "force_transcodes": "Forza la creazione delle Transcodifiche", "force_transcodes_tooltip": "Per standard, le transcodifiche vengono generate solamente quando il file video non è supportato dal browser. Quando abilitato, le transcodifiche verranno generate anche quando il file sembra essere supportato dal browser.", @@ -726,13 +808,16 @@ "false": "Falso", "favourite": "Favorita", "file": "file", + "file_count": "Numero File", "file_info": "Informazioni File", "file_mod_time": "Tempo Modifica del File", "files": "file", + "files_amount": "{value} file", "filesize": "Dimensione File", "filter": "Filtro", "filter_name": "Nome Filtro", "filters": "Filtri", + "folder": "Cartella", "framerate": "Frequenza dei Fotogrammi", "frames_per_second": "{value} fotogrammi per secondo", "front_page": { @@ -765,6 +850,7 @@ }, "hasMarkers": "Ha Marcatori", "height": "Altezza", + "height_cm": "Altezza (cm)", "help": "Aiuto", "ignore_auto_tag": "Ignora Auto Tag", "image": "Immagine", @@ -777,6 +863,7 @@ "interactive": "Interattivo", "interactive_speed": "Velocità interattiva", "isMissing": "Manca di", + "last_played_at": "Ultima Visualizzazione Al", "library": "Libreria", "loading": { "generic": "Caricamento…" @@ -795,6 +882,8 @@ "age_context": "{age} {years_old} in questa scena" }, "phash": "PHash", + "play_count": "Contatore Visualizzazioni", + "play_duration": "Durata Visualizzazione", "stream": "Flusso", "video_codec": "Codec Video" }, @@ -865,17 +954,26 @@ }, "performers": "Attori", "piercings": "Piercing", + "play_count": "Contatore Visualizzazioni", + "play_duration": "Durata Visualizzazioni", + "primary_file": "File primario", "queue": "Coda", "random": "Casuale", "rating": "Classif.", "recently_added_objects": "{objects} Aggiunto Recentemente", "recently_released_objects": "{objects} Recentemente Distribuito", + "release_notes": "Note Versione", "resolution": "Risoluzione", + "resume_time": "Tempo Continuazione", "scene": "Scena", "sceneTagger": "Tagger Scena", "sceneTags": "Tag Scena", + "scene_code": "Codice dello Studio", "scene_count": "Numero Scene", + "scene_created_at": "Scena Creata Al", + "scene_date": "Data della Scena", "scene_id": "ID Scena", + "scene_updated_at": "Scena Aggiornata Al", "scenes": "Scene", "scenes_updated_at": "Scena Aggiornata Al", "search_filter": { @@ -919,9 +1017,10 @@ "migration_failed_error": "Il seguente errore è stato riscontrato mentre si migrava il database:", "migration_failed_help": "Per favore fate ogni correzione necessaria e provate di nuovo. Altrimenti, compilate un bug report su {githubLink} o cercate aiuto su {discordLink}.", "migration_irreversible_warning": "La migrazione 'schema' non è reversibile. Una volta attuata, il vostro database sarà incompatibile con le versioni precedenti di Stash.", + "migration_notes": "Note Migrazione", "migration_required": "Migrazione richiesta", "perform_schema_migration": "Esegue la migrazione 'schema'", - "schema_too_old": "Lo 'schema version' del tuo attuale database di Stash è {databaseSchema} e dev'essere migrato alla versione {appSchema}. Questa versione di Stash non funzionerà senza aver migrato il database." + "schema_too_old": "Lo 'schema version' del tuo attuale database di Stash è {databaseSchema} e dev'essere migrato alla versione {appSchema}. Questa versione di Stash non funzionerà senza aver migrato il database. Se non si desidera migrare, si dovrà fare il downgrade ad una versione che corrisponda allo schema attuale." }, "paths": { "database_filename_empty_for_default": "nome file del database (vuoto per predefinito)", @@ -966,6 +1065,7 @@ "welcome_to_stash": "Benvenuti su Stash" }, "stash_id": "ID Stash", + "stash_id_endpoint": "Stash Endpoint ID", "stash_ids": "ID Stash", "stashbox": { "go_review_draft": "Vai al {endpoint_name} per revisionare la bozza.", @@ -1001,7 +1101,9 @@ "default_filter_set": "Filtro predefinito impostato", "delete_past_tense": "Cancellato/a {count, plural, one {{singularEntity}} other {{pluralEntity}}}", "generating_screenshot": "Sto creando la schermata…", + "merged_scenes": "Scene unite", "merged_tags": "Tag unite", + "reassign_past_tense": "File riassegnato", "removed_entity": "Rimosso {count, plural, one {{singularEntity}} other {{pluralEntity}}}", "rescanning_entity": "Riscansionando {count, plural, one {{singularEntity}} other {{pluralEntity}}}…", "saved_entity": "Salvato/a {entity}", @@ -1019,5 +1121,7 @@ "videos": "Video", "view_all": "Vedi Tutto", "weight": "Peso", - "years_old": "anni" + "weight_kg": "Peso (kg)", + "years_old": "anni", + "zip_file_count": "Numero File Zip" } diff --git a/ui/v2.5/src/locales/ja-JP.json b/ui/v2.5/src/locales/ja-JP.json index 9e0cca1e9..9958dc1f6 100644 --- a/ui/v2.5/src/locales/ja-JP.json +++ b/ui/v2.5/src/locales/ja-JP.json @@ -54,6 +54,7 @@ "import": "インポート…", "import_from_file": "ファイルからインポート", "logout": "ログアウト", + "make_primary": "メインに設定", "merge": "マージ", "merge_from": "次からマージ:", "merge_into": "次へマージ:", @@ -66,6 +67,7 @@ "play_selected": "選択したものを再生", "preview": "プレビュー", "previous_action": "戻る", + "reassign": "入れ替える", "refresh": "更新", "reload_plugins": "プラグインを再読み込み", "reload_scrapers": "スクレイパーを再読み込み", @@ -98,6 +100,7 @@ "show": "表示", "show_configuration": "設定を表示", "skip": "スキップ", + "split": "分割", "stop": "停止", "submit": "送信", "submit_stash_box": "Stash-Boxに送信", @@ -120,6 +123,7 @@ "also_known_as": "A.K.A", "ascending": "昇順", "average_resolution": "平均的な解像度", + "between_and": "と", "birth_year": "誕生年", "birthdate": "誕生日", "bitrate": "ビットレート", @@ -189,6 +193,7 @@ }, "categories": { "about": "Stashについて", + "changelog": "変更履歴", "interface": "インターフェース", "logs": "ログ", "metadata_providers": "メタデータのプロバイダー", @@ -243,6 +248,10 @@ "username": "ユーザー名", "username_desc": "Stashにアクセスするためのユーザー名です。空白にすると、ユーザー認証を無効にします" }, + "backup_directory_path": { + "description": "SQLiteデータベースファイルのバックアップ場所", + "heading": "バックアップディレクトリパス" + }, "cache_location": "キャッシュのディレクトリ", "cache_path_head": "キャッシュのパス", "calculate_md5_and_ohash_desc": "oshashに加えてMD5チェックサムを計算します。有効にすると、初期スキャンが少々遅くなります。MD5計算を無効にするには、ファイル名のハッシュをoshashに設定する必要があります。", @@ -344,7 +353,7 @@ "auto_tagging": "自動タグ付け", "backing_up_database": "データベースをバックアップ", "backup_and_download": "データベースのバックアップを実施し、結果ファイルをダウンロードします。", - "backup_database": "{filename_format}形式で、データベースと同じフォルダーにデータベースをバックアップします", + "backup_database": "{filename_format}形式で、バックアップ先にデータベースをバックアップします", "cleanup_desc": "不明なファイルを確認し、データベースから削除します。この操作はもとに戻せません。", "data_management": "データ管理", "defaults_set": "デフォルトが設定されており、タスクページの{action}ボタンをクリックした際に使用されます。", @@ -420,12 +429,25 @@ "scene_tools": "シーンツール" }, "ui": { + "abbreviate_counters": { + "description": "カードと詳細表示ページのカウンターを短縮します。有効にすると、\"1831\"という値が\"1.8K\"のように短縮して表示されます。", + "heading": "カウンターを短縮" + }, "basic_settings": "基本設定", "custom_css": { "description": "変更を適用するにはページを更新する必要があります。", "heading": "カスタムCSS", "option_label": "カスタムCSSを有効にする" }, + "custom_javascript": { + "heading": "カスタムJavascript", + "option_label": "カスタムJavascriptを有効化する" + }, + "custom_locales": { + "description": "個別の言語文字列を上書きします。マスターとなっているリストについては、https://github.com/stashapp/stash/blob/develop/ui/v2.5/src/locales/en-GB.json をご確認ください。変更を反映するには、ページを再読み込みする必要があります。", + "heading": "カスタム翻訳", + "option_label": "カスタム翻訳が有効です" + }, "delete_options": { "description": "画像、ギャラリー、シーンを削除するときのデフォルト設定です。", "heading": "削除オプション", @@ -446,7 +468,23 @@ "description": "ドロップダウンセレクターからの新規オブジェクトの生成を禁止する", "heading": "ドロップダウンの生成を無効にする" }, - "heading": "編集中" + "heading": "編集中", + "rating_system": { + "star_precision": { + "label": "評価の精度", + "options": { + "full": "全て", + "half": "半分", + "quarter": "4分の1" + } + }, + "type": { + "options": { + "decimal": "数値", + "stars": "星" + } + } + } }, "funscript_offset": { "description": "インタラクティブスクリプトの実行までのオフセットをミリ秒で指定できます。", @@ -516,9 +554,10 @@ "scene_player": { "heading": "シーンプレーヤー", "options": { + "always_start_from_beginning": "毎回最初から動画をスタートさせる", "auto_start_video": "動画を自動再生", "auto_start_video_on_play_selected": { - "description": "選択したものまたはシーンページからのランダム再生時に動画を自動再生します", + "description": "キュー、選択されたものからの再生またはシーンページからのランダム再生時に動画を自動再生します", "heading": "選択したものを再生した際に動画を自動再生" }, "continue_playlist_default": { @@ -539,10 +578,32 @@ "description": "前後のアイテムに移動する前にスクロールを試行する回数を指定できます。Y座標のスクロールモードにのみ適用されます。", "heading": "遷移前のスクロール試行" }, + "show_tag_card_on_hover": { + "description": "タグバッジにカーソルをホバーしているときにタグカードを表示する", + "heading": "タグカードヒント" + }, "slideshow_delay": { "description": "ウォールビューモードの際にギャラリーのスライドショーが利用できます", "heading": "スライドショーの遅延時間 (秒)" }, + "studio_panel": { + "heading": "スタジオビュー", + "options": { + "show_child_studio_content": { + "description": "スタジオビューで、サブスタジオのコンテンツが表示されるようになります", + "heading": "サブスタジオのコンテンツを表示する" + } + } + }, + "tag_panel": { + "heading": "タグビュー", + "options": { + "show_child_tagged_content": { + "description": "タグビューで、サブタグのコンテンツを表示されるようになります", + "heading": "サブタグのコンテンツを表示する" + } + } + }, "title": "ユーザーインターフェース" } }, @@ -587,6 +648,7 @@ "death_date": "没日", "death_year": "没年", "descending": "降順", + "description": "概要", "detail": "詳細", "details": "詳細", "developmentVersion": "開発者バージョン", @@ -595,12 +657,14 @@ "delete_alert": "次の{count, plural, one {{singularEntity}} other {{pluralEntity}}}は完全に削除されます:", "delete_confirm": "本当に{entityName}を削除してよろしいですか?", "delete_entity_desc": "{count, plural, one {Are you sure you want to delete this {singularEntity}? Unless the file is also deleted, this {singularEntity} will be re-added when scan is performed.} other {Are you sure you want to delete these {pluralEntity}? Unless the files are also deleted, these {pluralEntity} will be re-added when scan is performed.}}", + "delete_entity_simple_desc": "{count, plural, one {本当にこの{singularEntity}を削除してもよろしいですか?} other {本当にこれらの{pluralEntity}を削除してもよろしいですか?}}", "delete_entity_title": "{count, plural, one {Delete {singularEntity}} other {Delete {pluralEntity}}}", "delete_galleries_extra": "...加えて、画像ファイルが他のどのギャラリーにも属していません。", "delete_gallery_files": "ギャラリーフォルダー/zipファイルとギャラリーに属していない全ての画像を削除します。", "delete_object_desc": "本当に{count, plural, one {this {singularEntity}} other {these {pluralEntity}}}を削除してもよろしいですか?", "delete_object_overflow": "…加えて、{count}件とその他{count, plural, one {{singularEntity}} other {{pluralEntity}}}が含まれます。", "delete_object_title": "{count, plural, one {{singularEntity}} other {{pluralEntity}}}を削除", + "dont_show_until_updated": "次の更新まで表示しない", "edit_entity_title": "{count, plural, one {{singularEntity}} other {{pluralEntity}}}を編集", "export_include_related_objects": "関連するオブジェクトをエクスポートに含める", "export_title": "エクスポート", @@ -726,6 +790,7 @@ "false": "無効", "favourite": "お気に入り", "file": "ファイル", + "file_count": "ファイルカウント", "file_info": "ファイル情報", "file_mod_time": "ファイル変更日時", "files": "ファイル", @@ -733,6 +798,7 @@ "filter": "フィルター", "filter_name": "フィルター名", "filters": "フィルター", + "folder": "フォルダー", "framerate": "フレームレート", "frames_per_second": "{value}FPS", "front_page": { @@ -765,6 +831,7 @@ }, "hasMarkers": "マーカーあり?", "height": "身長", + "height_cm": "身長(cm)", "help": "ヘルプ", "ignore_auto_tag": "自動タグを無視する", "image": "画像", @@ -777,6 +844,7 @@ "interactive": "インタラクティブ", "interactive_speed": "インタラクティブ速度", "isMissing": "見つからない?", + "last_played_at": "前回再生した日時", "library": "ライブラリー", "loading": { "generic": "読み込み中…" @@ -865,11 +933,13 @@ }, "performers": "出演者", "piercings": "ピアス", + "primary_file": "メインファイル", "queue": "キュー", "random": "ランダム", "rating": "評価", "recently_added_objects": "最近追加された{objects}", "recently_released_objects": "最近リリースされた{objects}", + "release_notes": "リリースノート", "resolution": "解像度", "scene": "シーン", "sceneTagger": "シーン一括タグ付け", @@ -919,9 +989,10 @@ "migration_failed_error": "データベースの移行中に次のエラーが発生しました:", "migration_failed_help": "必要な修正を加えてから再度実行してみてください。それでも問題が起きる場合は、{githubLink}にバグを報告するか、{discordLink}で質問してみてください。", "migration_irreversible_warning": "スキーマの移行作業は元に戻せません。移行を開始した後は、お使いのデータベースは以前のバージョンのStashと互換性がなくなります。", + "migration_notes": "移行ノート", "migration_required": "移行が必要です", "perform_schema_migration": "スキーマ移行を実施する", - "schema_too_old": "お使いのStashデータベースのスキーマバージョンは、{databaseSchema} であり、バージョン{appSchema}への移行が必要です。このバージョンのStashは、データベースの移行を実施しないと動作しません。" + "schema_too_old": "お使いのStashデータベースのスキーマバージョンは、{databaseSchema} であり、バージョン{appSchema}への移行が必要です。このバージョンのStashは、データベースの移行を実施しないと動作しません。移行を実施しない場合、お使いのデータベーススキーマに合致するバージョンにダウングレードする必要があります。" }, "paths": { "database_filename_empty_for_default": "データベースファイル名 (空白でデフォルトを使用)", @@ -1001,6 +1072,7 @@ "default_filter_set": "デフォルトのフィルターセット", "delete_past_tense": "{count, plural, one {{singularEntity}} other {{pluralEntity}}}を削除しました", "generating_screenshot": "スクリーンショットを生成中…", + "merged_scenes": "マージされたシーン", "merged_tags": "マージされたタグ", "removed_entity": "{count, plural, one {{singularEntity}}と、その他{{pluralEntity}}}を削除しました", "rescanning_entity": "{count, plural, one {{singularEntity}} other {{pluralEntity}}}を再スキャン中…", @@ -1019,5 +1091,7 @@ "videos": "動画", "view_all": "全て表示", "weight": "幅", - "years_old": "歳" + "weight_kg": "体重(kg)", + "years_old": "歳", + "zip_file_count": "zipファイルカウント" } diff --git a/ui/v2.5/src/locales/ko-KR.json b/ui/v2.5/src/locales/ko-KR.json index 10c47739d..2357c73a7 100644 --- a/ui/v2.5/src/locales/ko-KR.json +++ b/ui/v2.5/src/locales/ko-KR.json @@ -54,6 +54,7 @@ "import": "불러오기…", "import_from_file": "파일 불러오기", "logout": "로그아웃", + "make_primary": "첫 번째로 만들기", "merge": "합치기", "merge_from": "...에서 합치기", "merge_into": "...로 합치기", @@ -66,6 +67,7 @@ "play_selected": "선택된 영상 재생", "preview": "미리보기", "previous_action": "뒤로", + "reassign": "재할당", "refresh": "새로고침", "reload_plugins": "플러그인 새로고침", "reload_scrapers": "스크레이퍼 다시 불러오기", @@ -98,10 +100,12 @@ "show": "보여주기", "show_configuration": "설정 보여주기", "skip": "건너뛰기", + "split": "나누기", "stop": "정지", "submit": "제출", "submit_stash_box": "Stash-Box에 제출하기", "submit_update": "업데이트 제출하기", + "swap": "바꾸기", "tasks": { "clean_confirm_message": "정말로 데이터베이스 정리를 하시겠습니까? 파일 시스템에 존재하지 않는 파일의 데이터베이스 정보와 컨텐츠가 삭제될 것입니다.", "dry_mode_selected": "삭제하지 않기 모드가 선택되었습니다. 삭제를 진행하지 않고, 로깅만 할 것입니다.", @@ -120,6 +124,7 @@ "also_known_as": "별명", "ascending": "오름차순", "average_resolution": "평균 해상도", + "between_and": "그리고", "birth_year": "태어난 년도", "birthdate": "생년월일", "bitrate": "비트레이트", @@ -189,6 +194,7 @@ }, "categories": { "about": "프로그램 정보", + "changelog": "패치노트", "interface": "인터페이스", "logs": "로그", "metadata_providers": "메타데이터", @@ -243,6 +249,10 @@ "username": "아이디", "username_desc": "Stash에 접속하기 위한 아이디입니다. 로그인을 생략하려면 빈 칸으로 두십시오" }, + "backup_directory_path": { + "description": "SQLite 데이터베이스 백업 파일을 위한 폴더 경로", + "heading": "백업 폴더 경로" + }, "cache_location": "캐시 폴더 경로", "cache_path_head": "캐쉬 경로", "calculate_md5_and_ohash_desc": "oshash 외에 MD5 체크섬도 계산합니다. 활성화하면 초기 스캔을 더 느리게 만들 것입니다. MD5 계산을 사용하지 않으려면 파일 이름 해쉬를 oshash로 설정해야 합니다.", @@ -344,7 +354,7 @@ "auto_tagging": "자동 태깅", "backing_up_database": "데이터베이스 백업 중", "backup_and_download": "데이터베이스를 백업하고 결과 파일을 다운로드합니다.", - "backup_database": "데이터베이스가 있는 폴더에 {filename_format}의 파일 이름 형식으로 데이터베이스를 백업합니다", + "backup_database": "백업 폴더에 {filename_format}의 파일 이름 형식으로 데이터베이스를 백업합니다", "cleanup_desc": "있어야 할 곳에 없는 파일이 있는지 검사한 후, 데이터베이스에서 삭제합니다. 데이터베이스의 일부 내용이 삭제될 수 있습니다.", "data_management": "데이터 관리", "defaults_set": "기본값이 설정되었습니다. '작업' 페이지의 {action} 버튼을 클릭할 때 사용됩니다.", @@ -420,12 +430,26 @@ "scene_tools": "영상 도구" }, "ui": { + "abbreviate_counters": { + "description": "카드와 세부 페이지에서 숫자를 축약하여 나타냅니다(예시: \"1831\"이 \"1.8K\"로 표현됩니다).", + "heading": "숫자 축약" + }, "basic_settings": "기초 설정", "custom_css": { "description": "변화된 사항을 확인하려면 페이지를 새로고침해야 합니다.", "heading": "커스텀 CSS", "option_label": "커스텀 CSS 활성화" }, + "custom_javascript": { + "description": "변화된 사항을 확인하려면 페이지를 새로고침해야 합니다.", + "heading": "커스텀 JavaScript", + "option_label": "커스텀 JavaScript 활성화" + }, + "custom_locales": { + "description": "커스텀 번역으로 기존 번역을 대신합니다. https://github.com/stashapp/stash/blob/develop/ui/v2.5/src/locales/en-GB.json 에서 단어 리스트를 확인하세요. 페이지를 새로고침해야 변동사항이 적용됩니다.", + "heading": "커스텀 번역", + "option_label": "커스텀 번역 활성화" + }, "delete_options": { "description": "이미지, 갤러리, 영상을 삭제할 때의 설정 기본값입니다.", "heading": "옵션 삭제", @@ -446,7 +470,19 @@ "description": "dropdown selector에서 새로운 오브젝트를 추가하지 않습니다", "heading": "Dropdown 생성 비활성화" }, - "heading": "수정하기" + "heading": "수정하기", + "rating_system": { + "star_precision": { + "label": "평점별 정확도" + }, + "type": { + "label": "평정 시스템 종류", + "options": { + "decimal": "소수점", + "stars": "별" + } + } + } }, "funscript_offset": { "description": "대화형 스크립트 재생의 시간 오프셋(밀리초)입니다.", @@ -518,7 +554,7 @@ "options": { "auto_start_video": "비디오 자동 재생", "auto_start_video_on_play_selected": { - "description": "'영상' 페이지에서 선택하거나 랜덤 재생한 영상을 자동 시작", + "description": "대기열, 또는 '영상' 페이지에서 (랜덤)선택한 영상을 자동 재생", "heading": "선택한 항목을 재생할 때 비디오 자동 시작" }, "continue_playlist_default": { @@ -539,10 +575,32 @@ "description": "이전/다음 항목으로 이동하기 전에 스크롤을 시도하는 횟수입니다. Y축 스크롤 허용 모드에만 적용됩니다.", "heading": "전환 전 스크롤 시도 횟수" }, + "show_tag_card_on_hover": { + "description": "태그 뱃지 위에 마우스 커서를 올리면 태그 카드를 보여줍니다", + "heading": "태그 카드 툴팁" + }, "slideshow_delay": { "description": "월 보기 모드일 때 갤러리에서 슬라이드 쇼를 사용할 수 있습니다", "heading": "슬라이드쇼 딜레이 (단위: 초)" }, + "studio_panel": { + "heading": "스튜디오 창", + "options": { + "show_child_studio_content": { + "description": "스튜디오 창에서, 하위 스튜디오의 컨텐츠도 보여줍니다", + "heading": "하위 스튜디오의 컨텐츠 보이기" + } + } + }, + "tag_panel": { + "heading": "태그 창", + "options": { + "show_child_tagged_content": { + "description": "태그 창에서, 하위 태그들도 보여줍니다", + "heading": "서브태그 컨텐츠 보이기" + } + } + }, "title": "UI" } }, @@ -587,6 +645,7 @@ "death_date": "사망 날짜", "death_year": "사망 년도", "descending": "내림차순", + "description": "설명", "detail": "세부사항", "details": "세부사항", "developmentVersion": "개발 버전", @@ -595,12 +654,14 @@ "delete_alert": "다음 {count, plural, one {{singularEntity}} other {{pluralEntity}}}이(가) 영구 삭제될 것입니다:", "delete_confirm": "정말 {entityName}을 삭제하시겠습니까?", "delete_entity_desc": "{정말로 {count, plural, one {singularEntity} other {pluralEntity}}을(를) 삭제하시겠습니까? 원본 파일 또한 삭제하지 않으면 스캔을 할 때 {count, plural, one {singularEntity} other {pluralEntity}}이(가) 다시 추가될 것입니다.}", + "delete_entity_simple_desc": "{count, plural, one {정말 이 {singularEntity}을(를) 삭제하시겠습니까?} other {정말 이 {pluralEntity}을(를) 삭제하시겠습니까?}}", "delete_entity_title": "{count, plural, one {{singularEntity} 삭제} other {{pluralEntity} 삭제}}", "delete_galleries_extra": "…그리고 다른 어떤 갤러리에도 없는 이미지 파일들까지.", "delete_gallery_files": "갤러리 폴더/zip 파일 및 다른 어떤 갤러리에도 존재하지 않는 이미지를 삭제합니다.", "delete_object_desc": "정말로 {count, plural, one {this {singularEntity}} other {these {pluralEntity}}}을(를) 삭제하시겠습니까?", "delete_object_overflow": "...그리고 {count} 개의 {count, plural, one {{singularEntity}} other {{pluralEntity}}}.", "delete_object_title": "{count, plural, one {{singularEntity}} other {{pluralEntity}}} 삭제", + "dont_show_until_updated": "다음 업데이트까지 보지 않기", "edit_entity_title": "{count, plural, one {{singularEntity}} other {{pluralEntity}}} 수정", "export_include_related_objects": "내보내기 할 때 관련된 개체를 포합합니다", "export_title": "내보내기", @@ -630,6 +691,7 @@ "source": "다른 태그로 합쳐질 태그" }, "overwrite_filter_confirm": "정말 원래 저장되어 있었던 쿼리 {entityName}을 덮어쓰시겠습니까?", + "reassign_entity_title": "{count, plural, one {{singularEntity} 재할당} other {{pluralEntity} 재할당}}", "scene_gen": { "force_transcodes": "강제 트랜스코드 생성", "force_transcodes_tooltip": "기본적으로 트랜스코드는 비디오 파일이 브라우저에서 지원되지 않는 경우에만 생성됩니다. 이 옵션을 선택하면 비디오 파일이 브라우저에서 지원되는 것으로 보이는 경우에도 트랜스코드가 생성됩니다.", @@ -695,7 +757,7 @@ "search_accuracy_label": "검색 정밀도", "title": "중복된 영상" }, - "duplicated_phash": "중복됨 (perceptual hash)", + "duplicated_phash": "중복됨 (phash)", "duration": "길이", "effect_filters": { "aspect": "방향", @@ -726,18 +788,21 @@ "false": "거짓", "favourite": "즐겨찾기", "file": "파일", + "file_count": "파일 개수", "file_info": "파일 정보", "file_mod_time": "파일 변경 시간", "files": "파일", + "files_amount": "{value} 파일", "filesize": "파일 크기", "filter": "필터", "filter_name": "필터 이름", "filters": "필터", + "folder": "폴더", "framerate": "프레임 레이트", "frames_per_second": "초당 프레임: {value}", "front_page": { "types": { - "premade_filter": "생성된 필터", + "premade_filter": "사전에 생성된 필터", "saved_filter": "저장된 필터" } }, @@ -765,6 +830,7 @@ }, "hasMarkers": "마커 유무", "height": "키", + "height_cm": "키 (cm)", "help": "도움말", "ignore_auto_tag": "자동 태깅 무시하기", "image": "이미지", @@ -777,6 +843,7 @@ "interactive": "인터렉티브", "interactive_speed": "인터랙티브 속도", "isMissing": "데이터 누락됨", + "last_played_at": "마지막으로 재생", "library": "라이브러리", "loading": { "generic": "로드 중…" @@ -795,6 +862,8 @@ "age_context": "작품에서 {age} {years_old}" }, "phash": "PHash", + "play_count": "재생 횟수", + "play_duration": "재생 길이", "stream": "스트림", "video_codec": "비디오 코덱" }, @@ -821,7 +890,7 @@ "parent_tags": "상위 태그", "part_of": "{parent}의 하위 태그", "path": "경로", - "perceptual_similarity": "영상 정렬에서 선택할 수 있습니다.", + "perceptual_similarity": "유사도 (phash)", "performer": "배우", "performerTags": "배우 태그", "performer_age": "배우 나이", @@ -865,11 +934,15 @@ }, "performers": "배우", "piercings": "피어싱", + "play_count": "재생 횟수", + "play_duration": "재생 길이", + "primary_file": "대표 파일", "queue": "대기열", "random": "랜덤", "rating": "별점", "recently_added_objects": "최근 추가된 {objects}", "recently_released_objects": "최근 발매된 {objects}", + "release_notes": "업데이트 내역", "resolution": "해상도", "scene": "영상", "sceneTagger": "영상 태거", @@ -919,9 +992,10 @@ "migration_failed_error": "데이터베이스를 마이그레이션 하는 동안 다음 에러가 발생했습니다:", "migration_failed_help": "올바른 내용을 입력했는지 확인하고 수정한 뒤 다시 시도해보세요. 그렇지 않다면, {githubLink}에 버그를 제보하거나 {discordLink}에서 도움이 될 만한 정보를 찾아보세요.", "migration_irreversible_warning": "스키마 마이그레이션 작업은 돌이킬 수 없습니다. 마이그레이션이 진행된 이후에는, 데이터베이스가 이전 버전의 Stash와 호환되지 않을 것입니다.", + "migration_notes": "마이그레이션 내역", "migration_required": "마이그레이션 필요", "perform_schema_migration": "스키마 마이그레이션 실행", - "schema_too_old": "현재 Stash 데이터베이스의 스키마 버전은 {databaseSchema}이고, {appSchema} 버전으로 마이그레이션되어야 합니다.이 Stash 버전은 데이터베이스 마이그레이션 없이는 동작하지 않을 것입니다." + "schema_too_old": "현재 Stash 데이터베이스의 스키마 버전은 {databaseSchema}이고, {appSchema} 버전으로 마이그레이션되어야 합니다.이 Stash 버전은 데이터베이스 마이그레이션 없이는 동작하지 않을 것입니다. 마이그레이션을 원하지 않는다면, 데이터베이스 스키마와 일치하는 버전으로 Stash를 다운그레이드해야 합니다." }, "paths": { "database_filename_empty_for_default": "데이터베이스 파일 이름 (빈 칸으로 두면 기본값을 사용합니다)", @@ -995,13 +1069,14 @@ "tattoos": "문신", "title": "제목", "toast": { - "added_entity": "{entity}를 추가했습니다", + "added_entity": "{singularEntity}을(를) 추가했습니다", "added_generation_job_to_queue": "생성 작업을 대기열에 추가했습니다", "created_entity": "{entity}를 생성했습니다", "default_filter_set": "기본 필터 셋", "delete_past_tense": "{count, plural, one {{singularEntity}} other {{pluralEntity}}}이(가) 삭제되었습니다", "generating_screenshot": "스크린샷을 생성하는 중…", "merged_tags": "병합된 태그", + "removed_entity": "{singularEntity}을(를) 제거했습니다", "rescanning_entity": "{count, plural, one {{singularEntity}} other {{pluralEntity}}} 다시 스캔하는 중…", "saved_entity": "{entity}를 저장했습니다", "started_auto_tagging": "자동 태깅을 시작했습니다", @@ -1018,5 +1093,7 @@ "videos": "비디오", "view_all": "모두 보기", "weight": "몸무게", - "years_old": "살" + "weight_kg": "무게 (kg)", + "years_old": "살", + "zip_file_count": "zip 파일 개수" } diff --git a/ui/v2.5/src/locales/nl-NL.json b/ui/v2.5/src/locales/nl-NL.json index 15bc05b5e..cdbaa1c89 100644 --- a/ui/v2.5/src/locales/nl-NL.json +++ b/ui/v2.5/src/locales/nl-NL.json @@ -344,7 +344,7 @@ "auto_tagging": "Automatisch taggen", "backing_up_database": "Database back-uppen", "backup_and_download": "Voert een backup uit van de database en download het backup bestand.", - "backup_database": "Voert een backup uit naar hetzelfde pad als de database, met het bestandsformaat {filename_format}", + "backup_database": "Voert een backup van de database uit naar de backup map, met het bestandsformaat {filename_format}", "cleanup_desc": "Controleer op missende bestanden en verwijder deze uit de database. Dit is een destructieve handeling.", "data_management": "Opslagbeheer", "defaults_set": "Standaarden zijn ingesteld en zullen gebruikt worden wanneer de {action} knop op de Taken pagina ingedrukt wordt.", diff --git a/ui/v2.5/src/locales/pl-PL.json b/ui/v2.5/src/locales/pl-PL.json index dc35356cc..0f9c8562e 100644 --- a/ui/v2.5/src/locales/pl-PL.json +++ b/ui/v2.5/src/locales/pl-PL.json @@ -7,12 +7,12 @@ "allow": "Zezwól", "allow_temporarily": "Zezwól tymczasowo", "apply": "Zastosuj", - "auto_tag": "Automatyczne tagowanie", + "auto_tag": "Automatyczne oznaczanie", "backup": "Kopia zapasowa", "browse_for_image": "Przeglądaj w poszukiwaniu obrazu…", "cancel": "Anuluj", - "clean": "Czyszczenie", - "clear": "Wyczyść", + "clean": "Wyczyść", + "clear": "Usuń", "clear_back_image": "Usuń tylną okładkę", "clear_front_image": "Usuń przednią okładkę", "clear_image": "Usuń obraz", @@ -35,30 +35,31 @@ "download_backup": "Pobierz kopię zapasową", "edit": "Edytuj", "edit_entity": "Edytuj {entityType}", - "export": "Eksport…", - "export_all": "Wyeksportuj wszystko…", + "export": "Eksportuj…", + "export_all": "Eksportuj wszystko…", "find": "Znajdź", "finish": "Zakończ", "from_file": "Z pliku…", - "from_url": "Z adresu URL…", + "from_url": "Z URL…", "full_export": "Pełny eksport", "full_import": "Pełny import", - "generate": "Generowanie", + "generate": "Generuj", "generate_thumb_default": "Generuj domyślną miniaturkę", "generate_thumb_from_current": "Wygeneruj miniaturkę z bieżącego podglądu", - "hash_migration": "migracja hashy", + "hash_migration": "migracja skrótów", "hide": "Ukryj", "hide_configuration": "Ukryj konfigurację", "identify": "Identyfikuj", "ignore": "Ignoruj", - "import": "Importowanie…", - "import_from_file": "Import z pliku", - "logout": "Wyloguj się", - "merge": "Połącz", + "import": "Importuj…", + "import_from_file": "Importuj z pliku", + "logout": "Wyloguj", + "make_primary": "Ustaw jako główny", + "merge": "Scal", "merge_from": "Scal z", - "merge_into": "Scal w", + "merge_into": "Scal do", "next_action": "Dalej", - "not_running": "nie uruchomiony", + "not_running": "nieuruchomiony", "open_in_external_player": "Otwórz w odtwarzaczu zewnętrznym", "open_random": "Otwórz losowo", "overwrite": "Nadpisz", @@ -66,23 +67,24 @@ "play_selected": "Odtwórz wybrane", "preview": "Podgląd", "previous_action": "Wstecz", + "reassign": "Przypisz ponownie", "refresh": "Odśwież", - "reload_plugins": "Przeładuj wtyczki", - "reload_scrapers": "Przeładuj scrapery", + "reload_plugins": "Załaduj wtyczki ponownie", + "reload_scrapers": "Załaduj zbieracze ponownie", "remove": "Usuń", "remove_from_gallery": "Usuń z galerii", "rename_gen_files": "Zmień nazwy wygenerowanych plików", "rescan": "Skanuj ponownie", - "reshuffle": "Przetasuj", + "reshuffle": "Wylosuj porządek", "running": "uruchomiony", "save": "Zapisz", - "save_delete_settings": "Te opcje są domyślnie używane przy usuwaniu", + "save_delete_settings": "Użyj tych opcji jako domyślnych podczas usuwania", "save_filter": "Zapisz filtr", - "scan": "Skanowanie", - "scrape": "Scrapowanie", - "scrape_query": "Zapytania scrapowania", - "scrape_scene_fragment": "Scrapowanie po fragmencie", - "scrape_with": "Scrapuj z…", + "scan": "Skanuj", + "scrape": "Zbierz", + "scrape_query": "Zapytanie zbierania", + "scrape_scene_fragment": "Zbieranie po fragmencie", + "scrape_with": "Zbierz za pomocą…", "search": "Szukaj", "select_all": "Zaznacz wszystko", "select_entity": "Wybierz {entityType}", @@ -98,10 +100,12 @@ "show": "Pokaż", "show_configuration": "Pokaż konfigurację", "skip": "Pomiń", + "split": "Rozdziel", "stop": "Zatrzymaj", "submit": "Wyślij", "submit_stash_box": "Wyślij do Stash-Box", "submit_update": "Prześlij aktualizację", + "swap": "Zamień", "tasks": { "clean_confirm_message": "Czy na pewno chcesz przeprowadzić oczyszczanie? Spowoduje to usunięcie informacji o bazie danych i wygenerowanej zawartości dla wszystkich scen i galerii, które nie znajdują się już w systemie plików.", "dry_mode_selected": "Wybrano tryb próby na sucho. Nie nastąpi faktyczne usunięcie, a jedynie zapisanie w dzienniku.", @@ -120,6 +124,7 @@ "also_known_as": "Znany/a również jako", "ascending": "Rosnąco", "average_resolution": "Średnia rozdzielczość", + "between_and": "i", "birth_year": "Rok urodzenia", "birthdate": "Data urodzenia", "bitrate": "Szybkość transmisji", @@ -165,7 +170,7 @@ }, "verb_match_fp": "Dopasuj odciski palców", "verb_matched": "Dopasowane", - "verb_scrape_all": "Scrapuj wszystko", + "verb_scrape_all": "Zbierz wszystko", "verb_submit_fp": "Wyślij {fpCount, plural, one{# odcisk palca} other{# odciski palców}}", "verb_toggle_config": "{toggle} {configuration}", "verb_toggle_unmatched": "{toggle} niedopasowane sceny" @@ -189,6 +194,7 @@ }, "categories": { "about": "O aplikacji", + "changelog": "Lista zmian", "interface": "Interfejs", "logs": "Logi", "metadata_providers": "Dostawcy metadanych", @@ -243,6 +249,10 @@ "username": "Nazwa użytkownika", "username_desc": "Nazwa użytkownika umożliwiająca dostęp do Stash. Pozostaw puste, aby wyłączyć uwierzytelnianie użytkownika" }, + "backup_directory_path": { + "description": "Lokalizacja katalogu kopii zapasowych plików bazy danych SQLite", + "heading": "Ścieżka katalogu kopii zapasowych" + }, "cache_location": "Lokalizacja katalogu pamięci podręcznej", "cache_path_head": "Ścieżka pamięci podręcznej", "calculate_md5_and_ohash_desc": "Oblicz sumę kontrolną MD5 jako dodatek do oshash. Włączenie spowoduje, że początkowe skanowanie będzie wolniejsze. Aby wyłączyć obliczanie MD5, hash nazwy pliku musi być ustawiony na oshash.", @@ -287,11 +297,11 @@ "description": "Lokalizacja pliku wykonywalnego Pythona. Używane dla skryptów i wtyczek. Jeśli jest puste, python zostanie pobrany ze środowiska", "heading": "Ścieżka Pythona" }, - "scraper_user_agent": "User Agent scrapera", - "scraper_user_agent_desc": "Ciąg User-Agent używany podczas scrapowania zapytań http", + "scraper_user_agent": "Agent użytkownika dla zbieracza", + "scraper_user_agent_desc": "Ciąg „User-Agent” używany podczas zapytań HTTP zbierania", "scrapers_path": { - "description": "Lokalizacja katalogu w którym znajdują się pliki konfiguracyjne scraperów", - "heading": "Ścieżka scraperów" + "description": "Położenie katalogu z plikami konfiguracyjnymi zbieracza", + "heading": "Ścieżka do zbieraczy" }, "scraping": "Scrapowanie", "sqlite_location": "Lokalizacja pliku dla bazy danych SQLite (wymaga ponownego uruchomienia)", @@ -308,16 +318,16 @@ "log_level": "Poziom rejestrowania" }, "plugins": { - "hooks": "Haki", + "hooks": "Punkty zaczepienia", "triggers_on": "Wyzwalacze \"w przypadku\"" }, "scraping": { "entity_metadata": "Metadane - {entityType}", - "entity_scrapers": "Scrapery - {entityType}", + "entity_scrapers": "Zbieracze – {entityType}", "excluded_tag_patterns_desc": "Wyrażenia regularne nazw tagów do wykluczenia z wyników scrapowania", "excluded_tag_patterns_head": "Wykluczone wzorce tagów", - "scraper": "Scraper", - "scrapers": "Scrapery", + "scraper": "Zbieracz", + "scrapers": "Zbieracze", "search_by_name": "Wyszukiwanie według nazwy", "supported_types": "Obsługiwane typy", "supported_urls": "Adresy URL" @@ -344,7 +354,7 @@ "auto_tagging": "Automatyczne tagowanie", "backing_up_database": "Tworzenie kopii zapasowej bazy danych", "backup_and_download": "Wykonuje kopię zapasową bazy danych i pobiera plik wynikowy.", - "backup_database": "Wykonuje kopię zapasową bazy danych w tym samym katalogu, w którym znajduje się baza danych, z nazwą pliku w formacie {filename_format}", + "backup_database": "Wykonuje kopię zapasową bazy danych do katalogu kopii zapasowych, z nazwą pliku w formacie {filename_format}", "cleanup_desc": "Sprawdza, czy nie ma brakujących plików i usuwa je z bazy danych. Jest to działanie destrukcyjne.", "data_management": "Zarządzanie danymi", "defaults_set": "Zostały ustawione wartości domyślne, które będą używane po kliknięciu przycisku {action} na stronie Zadania.", @@ -369,7 +379,7 @@ "and_create_missing": "i utwórz brakujące", "create_missing": "Utwórz brakujące", "default_options": "Ustawienia domyślne", - "description": "Automatyczne ustawianie metadanych sceny przy użyciu źródeł typu stash-box i scraper.", + "description": "Automatycznie ustaw metadane sceny ze źródeł stash-box i zbieraczy.", "explicit_set_description": "Następujące opcje będą używane, jeśli nie zostały nadpisane w opcjach specyficznych dla źródła.", "field": "Pole", "field_behaviour": "{strategy} {field}", @@ -420,12 +430,26 @@ "scene_tools": "Narzędzia scen" }, "ui": { + "abbreviate_counters": { + "description": "Skracanie liczników na kartach i stronach podglądu szczegółów, na przykład \"1831\" zostanie skrócone do \"1.8K\".", + "heading": "Skracaj liczby" + }, "basic_settings": "Ustawienia podstawowe", "custom_css": { "description": "Strona musi zostać ponownie załadowana, aby zmiany zaczęły obowiązywać.", "heading": "Własny CSS", "option_label": "Włączony własny CSS" }, + "custom_javascript": { + "description": "Strona musi zostać ponownie załadowana, aby zmiany zaczęły obowiązywać.", + "heading": "Własny Javascript", + "option_label": "Własny Javascript włączony" + }, + "custom_locales": { + "description": "Nadpisywanie poszczególnych ciągów lokalnie. Zobacz https://github.com/stashapp/stash/blob/develop/ui/v2.5/src/locales/en-GB.json, aby uzyskać główną listę. Aby zmiany weszły w życie, strona musi zostać ponownie załadowana.", + "heading": "Niestandardowa lokalizacja", + "option_label": "Niestandardowa lokalizacja włączona" + }, "delete_options": { "description": "Ustawienia domyślne przy usuwaniu obrazów, galerii i scen.", "heading": "Opcje usuwania", @@ -446,7 +470,24 @@ "description": "Usunięcie możliwości tworzenia nowych obiektów z poziomu listy rozwijanej", "heading": "Wyłącz tworzenie za pomocą rozwijanej listy" }, - "heading": "Edytowanie" + "heading": "Edytowanie", + "rating_system": { + "star_precision": { + "label": "Precyzja oceniania gwiazdkami", + "options": { + "full": "Cała", + "half": "Pół", + "quarter": "Ćwierć" + } + }, + "type": { + "label": "Rodzaj systemu oceny", + "options": { + "decimal": "Liczby", + "stars": "Gwiazdki" + } + } + } }, "funscript_offset": { "description": "Przesunięcie czasowe w milisekundach dla odtwarzania skryptów interaktywnych.", @@ -490,6 +531,10 @@ "description": "Pokaż lub ukryj różne typy zawartości na pasku nawigacyjnym", "heading": "Pozycje menu" }, + "minimum_play_percent": { + "description": "Procent czasu, w którym scena musi zostać odtworzona, zanim jej licznik zostanie zwiększony.", + "heading": "Minimalny czas odtwarzania w procentach" + }, "performers": { "options": { "image_location": { @@ -516,16 +561,18 @@ "scene_player": { "heading": "Odtwarzacz scen", "options": { + "always_start_from_beginning": "Zawsze odtwarzaj film od początku", "auto_start_video": "Automatyczne odtwarzanie wideo", "auto_start_video_on_play_selected": { - "description": "Automatyczne odtwarzaj wideo podczas odtwarzania wybranych lub losowych scen ze strony Sceny", + "description": "Automatyczne odtwarzaj wideo z kolejki albo z wybranych lub losowych scen ze strony Sceny", "heading": "Automatyczne odtwarzanie wideo podczas odtwarzania wybranych" }, "continue_playlist_default": { "description": "Odtwarzaj następną scenę w kolejce po zakończeniu odtwarzania bieżącej", "heading": "Domyślnie kontynuuj odtwarzanie playlisty" }, - "show_scrubber": "Pokaż Scrubber" + "show_scrubber": "Pokaż Scrubber", + "track_activity": "Śledzenie aktywności" } }, "scene_wall": { @@ -539,10 +586,32 @@ "description": "Ilość prób przewijania przed przejściem do następnej/poprzedniej pozycji. Ma zastosowanie tylko w trybie Pan Y.", "heading": "Próby przewijania przed przejściem" }, + "show_tag_card_on_hover": { + "description": "Pokazuj kartę tagu po najechaniu na plakietkę tagu", + "heading": "Karty tagów" + }, "slideshow_delay": { "description": "Pokaz slajdów jest dostępny w galeriach w trybie widoku ściany", "heading": "Opóźnienie pokazu slajdów (sekundy)" }, + "studio_panel": { + "heading": "Widok studia", + "options": { + "show_child_studio_content": { + "description": "W widoku studia wyświetlaj także zawartość z podstudiów", + "heading": "Wyświetlaj zawartość z podstudiów" + } + } + }, + "tag_panel": { + "heading": "Widok tagu", + "options": { + "show_child_tagged_content": { + "description": "W widoku tagu wyświetlaj również zawartość z podtagów", + "heading": "Wyświetlaj zawartość z podtagów" + } + } + }, "title": "Interfejs użytkownika" } }, @@ -587,20 +656,24 @@ "death_date": "Data śmierci", "death_year": "Rok śmierci", "descending": "Malejąco", + "description": "Opis", "detail": "Szczegół", "details": "Szczegóły", "developmentVersion": "Wersja deweloperska", "dialogs": { "aliases_must_be_unique": "aliasy muszą być unikatowe", + "create_new_entity": "Dodaj {entity}", "delete_alert": "Następujące elementy {count, plural, one {{singularEntity}} other {{pluralEntity}}} zostaną trwale usunięte:", "delete_confirm": "Czy na pewno chcesz usunąć {entityName}?", "delete_entity_desc": "{count, plural, one {Czy na pewno chcesz usunąć {singularEntity}? Jeśli plik nie zostanie usunięty, {singularEntity} zostanie ponownie dodane podczas skanowania.} other {Czy na pewno chcesz usunąć {pluralEntity}? Jeśli te pliki nie zostaną usunięte, {pluralEntity} zostaną ponownie dodane podczas skanowania.}}", + "delete_entity_simple_desc": "{count, plural, one {Czy na pewno chcesz usunąć {singularEntity}?} other {Czy na pewno chcesz usunąć {pluralEntity}?}}", "delete_entity_title": "{count, plural, one {Usuń {singularEntity}} other {Usuń {pluralEntity}}}", "delete_galleries_extra": "…oraz wszystkie pliki obrazów, które nie są dołączone do żadnej innej galerii.", "delete_gallery_files": "Usuń folder galerii/plik zip i wszystkie obrazy, które nie są dołączone do żadnej innej galerii..", "delete_object_desc": "Czy na pewno chcesz usunąć {count, plural, one {{singularEntity}} other {{pluralEntity}}}?", "delete_object_overflow": "…i {count} other {count, plural, one {{singularEntity}} other {{pluralEntity}}}.", "delete_object_title": "Usuń {count, plural, one {{singularEntity}} other {{pluralEntity}}}", + "dont_show_until_updated": "Nie pokazuj do następnej aktualizacji", "edit_entity_title": "Edytuj {count, plural, one {{singularEntity}} other {{pluralEntity}}}", "export_include_related_objects": "Uwzględnij powiązane obiekty w eksporcie", "export_title": "Eksportuj", @@ -625,6 +698,9 @@ "zoom": "Powiększenie" } }, + "merge": { + "source": "Źródło" + }, "merge_tags": { "destination": "Cel", "source": "Źródło" @@ -666,10 +742,10 @@ "video_previews_tooltip": "Podgląd wideo odtwarzany po najechaniu kursorem myszy na scenę" }, "scenes_found": "Znaleziono {count} scen", - "scrape_entity_query": "{entity_type} Zapytanie scrapera", - "scrape_entity_title": "{entity_type} Wyniki scrapowania", + "scrape_entity_query": "Zapytanie zbieracza – {entity_type}", + "scrape_entity_title": "Wyniki zbierania {entity_type}", "scrape_results_existing": "Istniejący", - "scrape_results_scraped": "Zescrapowany", + "scrape_results_scraped": "Zebrany", "set_image_url_title": "Adres URL obrazu", "unsaved_changes": "Niezapisane zmiany. Czy na pewno chcesz wyjść?" }, @@ -726,13 +802,16 @@ "false": "Nie", "favourite": "Ulubione", "file": "plik", + "file_count": "Liczba plików", "file_info": "Info o pliku", "file_mod_time": "Czas modyfikacji pliku", "files": "pliki", + "files_amount": "{value} plików", "filesize": "Rozmiar pliku", "filter": "Filtr", "filter_name": "Nazwa filtra", "filters": "Filtry", + "folder": "Folder", "framerate": "Liczba klatek na sekundę", "frames_per_second": "{value} klatek na sekundę", "front_page": { @@ -765,6 +844,7 @@ }, "hasMarkers": "Ma znaczniki", "height": "Wzrost", + "height_cm": "Wzrost (cm)", "help": "Help", "ignore_auto_tag": "Ignoruj automatyczne tagowanie", "image": "Obraz", @@ -777,6 +857,7 @@ "interactive": "Interaktywny", "interactive_speed": "Prędkość interaktywna", "isMissing": "Brakuje", + "last_played_at": "Ostatnio odtwarzano", "library": "Biblioteka", "loading": { "generic": "Ładowanie…" @@ -795,6 +876,8 @@ "age_context": "{age} {years_old} w tej scenie" }, "phash": "PHash", + "play_count": "Liczba odtworzeń", + "play_duration": "Czas odtwarzania", "stream": "Stream", "video_codec": "Kodek wideo" }, @@ -865,17 +948,25 @@ }, "performers": "Aktorzy", "piercings": "Kolczyki", + "play_count": "Liczba odtworzeń", + "play_duration": "Czas odtwarzania", + "primary_file": "Główny plik", "queue": "Kolejka", "random": "Losowo", "rating": "Ocena", "recently_added_objects": "Ostatnio dodane {objects}", "recently_released_objects": "Ostatnio wydane {objects}", + "release_notes": "Informacje o wydaniu", "resolution": "Rozdzielczość", "scene": "Scena", "sceneTagger": "Otagowywacz scen", "sceneTags": "Tagi sceny", + "scene_code": "Kod studia", "scene_count": "Liczba scen", + "scene_created_at": "Scena utworzona", + "scene_date": "Data sceny", "scene_id": "ID sceny", + "scene_updated_at": "Scena zaktualizowana", "scenes": "Sceny", "scenes_updated_at": "Scena aktualizowana", "search_filter": { @@ -919,9 +1010,10 @@ "migration_failed_error": "Podczas migracji bazy danych napotkano następujący błąd:", "migration_failed_help": "Wprowadź wszelkie niezbędne poprawki i spróbuj ponownie. W przeciwnym razie zgłoś błąd na stronie {githubLink} lub poszukaj pomocy na {discordLink}.", "migration_irreversible_warning": "Proces migracji schematu jest nieodwracalny. Po przeprowadzeniu migracji baza danych będzie niekompatybilna z poprzednimi wersjami programu stash.", + "migration_notes": "Uwagi dotyczące migracji", "migration_required": "Wymagana migracja", "perform_schema_migration": "Wykonaj migrację schematów", - "schema_too_old": "Twoja obecna baza danych Stash to schemat w wersji {databaseSchema} i należy ją przenieść do wersji {appSchema}. Ta wersja Stash nie będzie działać bez migracji bazy danych." + "schema_too_old": "Twoja obecna baza danych Stash to schemat w wersji {databaseSchema} i należy ją przenieść do wersji {appSchema}. Ta wersja Stash nie będzie działać bez migracji bazy danych. Jeśli nie chcesz migrować, musisz obniżyć wersję do wersji zgodnej ze schematem bazy danych." }, "paths": { "database_filename_empty_for_default": "nazwa pliku bazy danych (domyślnie puste)", @@ -1001,6 +1093,7 @@ "default_filter_set": "Domyślny zestaw filtrów", "delete_past_tense": "Usuń {count, plural, one {{singularEntity}} other {{pluralEntity}}}", "generating_screenshot": "Generowanie zrzutu ekranu…", + "merged_scenes": "Połączone sceny", "merged_tags": "Połączone tagi", "removed_entity": "Usunięto {count, plural, one {{singularEntity}} other {{pluralEntity}}}", "rescanning_entity": "Ponowne skanowanie {count, plural, one {{singularEntity}} other {{pluralEntity}}}…", @@ -1017,7 +1110,9 @@ "updated_at": "Zaktualizowano", "url": "URL", "videos": "Filmy wideo", - "view_all": "Pokaż Wszystko", + "view_all": "Pokaż wszystko", "weight": "Waga", - "years_old": "lat(a)" + "weight_kg": "Waga (kg)", + "years_old": "lat(a)", + "zip_file_count": "Liczba plików zip" } diff --git a/ui/v2.5/src/locales/pt-BR.json b/ui/v2.5/src/locales/pt-BR.json index e43ffcb18..c32f4dc60 100644 --- a/ui/v2.5/src/locales/pt-BR.json +++ b/ui/v2.5/src/locales/pt-BR.json @@ -189,6 +189,7 @@ }, "categories": { "about": "Sobre", + "changelog": "Registro de alterações", "interface": "Interface", "logs": "Registros", "metadata_providers": "Provedores de Meta-dados", @@ -518,7 +519,7 @@ "options": { "auto_start_video": "Começar vídeos automaticamente", "auto_start_video_on_play_selected": { - "description": "Começar reprodução de vídeos de cenas automaticamente quando reproduzindo selecionado ou aleatório a partir da página de Cenas", + "description": "Começar reprodução de vídeos de cenas automaticamente quando reproduzindo da fila, reproduzindo o selecionado ou aleatório a partir da página de Cenas", "heading": "Começar vídeo automaticamente quando reproduzindo selecionado" }, "continue_playlist_default": { @@ -601,6 +602,7 @@ "delete_object_desc": "Tem certeza de que deseja excluir {count, plural, one {este(a) {singularEntity}} other {estes(as) {pluralEntity}}}?", "delete_object_overflow": "…e {count} other {count, plural, one {{singularEntity}} other {{pluralEntity}}}.", "delete_object_title": "Excluir {count, plural, one {{singularEntity}} other {{pluralEntity}}}", + "dont_show_until_updated": "Não mostrar novamente até a próxima atualização", "edit_entity_title": "Editar {count, plural, one {{singularEntity}} other {{pluralEntity}}}", "export_include_related_objects": "Inclua objetos relacionados na exportação", "export_title": "Exportar", @@ -726,6 +728,7 @@ "false": "Falso", "favourite": "Favorito(a)", "file": "arquivo", + "file_count": "Contagem de arquivos", "file_info": "Informações do arquivo", "file_mod_time": "Tempo de modificação do arquivo", "files": "arquivos", @@ -733,6 +736,7 @@ "filter": "Filtro", "filter_name": "Nome do filtro", "filters": "Filtros", + "folder": "Diretório", "framerate": "Taxa de quadros", "frames_per_second": "{value} quadros por segundo", "front_page": { @@ -870,6 +874,7 @@ "rating": "Avaliação", "recently_added_objects": "{objects} Recentemente Adicionados", "recently_released_objects": "{objects} Recentemente Lançados", + "release_notes": "Notas de lançamento", "resolution": "Resolução", "scene": "Cena", "sceneTagger": "Etiquetador de cena", @@ -919,9 +924,10 @@ "migration_failed_error": "Foi encontrado o seguinte erro durante a migração do banco de dados:", "migration_failed_help": "Por favor, faça qualquer correção necessária e tente novamente. Caso contrário, reporte um bug em {githubLink} ou busque ajuda em {discordLink}.", "migration_irreversible_warning": "O processo de migração não é reversível. Uma vez que a migração seja concluída, seu banco de dados será incompatível com versões anteriores do Stash.", + "migration_notes": "Notas de migração", "migration_required": "Migração necessária", "perform_schema_migration": "Fazer migração", - "schema_too_old": "A versão atual de seu banco de dados é {databaseSchema} e necessita ser migrada para a versão {appSchema}. Esta versão do Stash não funcionará sem migrar o banco de dados." + "schema_too_old": "A versão atual de seu banco de dados é {databaseSchema} e necessita ser migrada para a versão {appSchema}. Esta versão do Stash não funcionará sem migrar o banco de dados. Se você não deseja efetuar a migração, você irá precisar rebaixar para uma versão do Stash que corresponde à sua versão do banco de dados." }, "paths": { "database_filename_empty_for_default": "nome do arquivo do banco de dados (em branco para usar padrão)", diff --git a/ui/v2.5/src/locales/ru-RU.json b/ui/v2.5/src/locales/ru-RU.json index d711e6692..5a8170859 100644 --- a/ui/v2.5/src/locales/ru-RU.json +++ b/ui/v2.5/src/locales/ru-RU.json @@ -7,15 +7,15 @@ "allow": "Разрешить", "allow_temporarily": "Временно разрешить", "apply": "Применить", - "auto_tag": "Авто Тэг", + "auto_tag": "Автоматически пометить тегом", "backup": "Резервная копия", "browse_for_image": "Открыть изображение…", "cancel": "Отмена", "clean": "Очистить", "clear": "Очистить", "clear_back_image": "Очистить заднее изображение", - "clear_front_image": "Очистить лицевое изображение", - "clear_image": "Очистить Изображение", + "clear_front_image": "Убрать переднее изображение", + "clear_image": "Удалить изображение", "close": "Закрыть", "confirm": "Подтвердить", "continue": "Продолжить", @@ -27,7 +27,7 @@ "delete": "Удалить", "delete_entity": "Удалить {entityType}", "delete_file": "Удалить файл", - "delete_file_and_funscript": "Удалить файл (и funscript)", + "delete_file_and_funscript": "Удалить файл (вместе с funscript)", "delete_generated_supporting_files": "Удалить сгенерированные вспомогательные файлы", "delete_stashid": "Удалить StashID", "disallow": "Запретить", @@ -54,18 +54,20 @@ "import": "Импорт…", "import_from_file": "Импорт из файла", "logout": "Выйти", + "make_primary": "Сделать основным", "merge": "Слияние", "merge_from": "Слияние из", "merge_into": "Слияние в", "next_action": "Вперёд", - "not_running": "не выполняется", + "not_running": "не запущен", "open_in_external_player": "Открыть во внешнем проигрывателе", "open_random": "Открыть Случайный", "overwrite": "Перезаписать", - "play_random": "Воспроизвести Случайный", - "play_selected": "Воспроизвести выбранный", + "play_random": "Воспроизвести случайный файл", + "play_selected": "Воспроизвести выбранные файлы", "preview": "Предпросмотр", "previous_action": "Назад", + "reassign": "Переназначить", "refresh": "Обновить", "reload_plugins": "Перезагрузить плагины", "reload_scrapers": "Перезагрузить скрейперы", @@ -87,23 +89,25 @@ "select_all": "Выбрать все", "select_entity": "Выбрать {entityType}", "select_folders": "Выбрать папки", - "select_none": "Ничего не выбрать", - "selective_auto_tag": "Выборочный автоматический тэгинг", + "select_none": "Отменить выделение", + "selective_auto_tag": "Выборочная автоматическая метка тегами", "selective_clean": "Выборочная чистка", "selective_scan": "Выборочнное сканирование", "set_as_default": "Установить по умолчанию", "set_back_image": "Заднее изображение…", "set_front_image": "Лицевое изображение…", - "set_image": "Установить изображение…", + "set_image": "Выбрать изображение…", "show": "Показать", "show_configuration": "Показать конфигурацию", "skip": "Пропустить", + "split": "Разделить", "stop": "Остановить", "submit": "Отправить", "submit_stash_box": "Отправить в Stash-Box", "submit_update": "Отправить изменение", + "swap": "Заменить", "tasks": { - "clean_confirm_message": "Вы уверены что хотите очистить? Это удалит информацию из базы данных и созданное содержимое о всех сценах и галереях которых больше нет в файловой системе.", + "clean_confirm_message": "Вы уверены что хотите провести очистку? Это удалит информацию из базы данных, вместе с созданным содержимым о всех сценах и галереях, которые больше не находятся в файловой системе.", "dry_mode_selected": "Выбран режим симуляции. Фактического удаления не будет, только запись в журнал.", "import_warning": "Вы уверены, что хотите импортировать? Это приведет к удалению базы данных и повторному импорту из экспортированных метаданных." }, @@ -117,9 +121,10 @@ "age": "Возраст", "aliases": "Псевдонимы", "all": "все", - "also_known_as": "Так же известный как", + "also_known_as": "Также известна/-ен как", "ascending": "По возрастанию", "average_resolution": "Среднее разрешение", + "between_and": "и", "birth_year": "Год рождения", "birthdate": "Дата рождения", "bitrate": "Битрейт", @@ -143,16 +148,16 @@ "query_mode_path_desc": "Используя полный путь до файла", "set_cover_desc": "Заменить обложку сцены, если она будет найдена.", "set_cover_label": "Выставить обложку для данной сцены", - "set_tag_desc": "Прикрепите теги к сцене, либо перезаписав их, либо объединив с существующими тегами в сцене.", + "set_tag_desc": "Прикрепить теги к сцене, перезаписав или соединив с существующими тегами на сцене.", "set_tag_label": "Установить теги", - "show_male_desc": "Переключите, будут ли актеры-мужчины доступны для пометки тегами.", + "show_male_desc": "Включить или выключить доступность пометки тегами мужских актёров.", "show_male_label": "Показывать актеров мужского пола", "source": "Источник" }, "noun_query": "Запрос", "results": { "duration_off": "Продолжительность отличается не менее чем на {number} сек", - "duration_unknown": "Длительность неизвестна", + "duration_unknown": "Продолжительность неизвестна", "fp_found": "{fpCount, plural, =0 {Новых совпадений не найдено} other {# новых совпадений найдено}}", "fp_matches": "Продолжительность совпадает", "fp_matches_multi": "Продолжительность совпадает с {matchCount}/{durationsLength} отпечатками файла(-ов)", @@ -163,9 +168,9 @@ "phash_matches": "{count} PHashes совпадают", "unnamed": "Безымянный" }, - "verb_match_fp": "Сопоставить отпечаток файла", + "verb_match_fp": "Сопоставить отпечатки файла", "verb_matched": "Совпавший", - "verb_scrape_all": "Скрейпить всё", + "verb_scrape_all": "Убрать всё", "verb_submit_fp": "Отправить {fpCount, plural, one{# Совпадение} other{# совпадения}}", "verb_toggle_config": "{toggle} {configuration}", "verb_toggle_unmatched": "{toggle} не назначенные сцены" @@ -173,7 +178,7 @@ "config": { "about": { "build_hash": "Контрольная сумма сборки:", - "build_time": "Время сборки:", + "build_time": "Дата сборки:", "check_for_new_version": "Проверить наличие обновления", "latest_version": "Последняя версия", "latest_version_build_hash": "Контрольная сумма последний версии сборки:", @@ -185,13 +190,14 @@ "version": "Версия" }, "application_paths": { - "heading": "Пути программы" + "heading": "Пути программы и приложений" }, "categories": { "about": "О программе", + "changelog": "Список изменений", "interface": "Интерфейс", "logs": "Журнал", - "metadata_providers": "Источники метадаты", + "metadata_providers": "Поставщик метаданных", "plugins": "Плагины", "scraping": "Скрейпинг", "security": "Безопасность", @@ -202,8 +208,8 @@ }, "dlna": { "allow_temp_ip": "Разрешить {tempIP}", - "allowed_ip_addresses": "Разрешенные IP адреса", - "allowed_ip_temporarily": "Временно разрешенные IP", + "allowed_ip_addresses": "Разрешенные IP-адреса", + "allowed_ip_temporarily": "Временно разрешенные IP-адреса", "default_ip_whitelist": "Белый список IP по умолчанию", "default_ip_whitelist_desc": "Разрешенные IP адреса для доступа к DLNA. Используйте {wildcard}, чтобы разрешить все IP-адреса.", "disabled_dlna_temporarily": "DLNA временно отключен", @@ -211,11 +217,11 @@ "enabled_by_default": "Включено по умолчанию", "enabled_dlna_temporarily": "DLNA временно включен", "network_interfaces": "Интерфейсы", - "network_interfaces_desc": "Интерфейсы для доступа к серверу DLNA. Пустой список приведёт к запуску на всех интерфейсах. Требуется перезапуск DLNA после изменения.", + "network_interfaces_desc": "Сетевые интерфейсы на которых сервер DLNA будет доступен. Пустой список приведёт к запуску на всех интерфейсах. Требуется перезапуск DLNA после изменения.", "recent_ip_addresses": "Последние IP адреса", - "server_display_name": "Наименование сервера", + "server_display_name": "Отображаемое имя сервера", "server_display_name_desc": "Отображаемое название сервера DLNA. По умолчанию {server_name}, если не задано.", - "successfully_cancelled_temporary_behaviour": "Успешно отменено временное поведение", + "successfully_cancelled_temporary_behaviour": "Временно запущенный сервис DLNA, успешно отключен", "until_restart": "до перезагрузки" }, "general": { @@ -226,13 +232,13 @@ "clear_api_key": "Стереть ключ API", "credentials": { "description": "Учетные данные для ограничения доступа к программе.", - "heading": "Реквизиты для входа" + "heading": "Учетные данные пользователя для входа" }, "generate_api_key": "Сгенерировать ключ API", "log_file": "Файл журнала", "log_file_desc": "Путь к файлу журнала. Оставьте пустым, чтобы отключить ведение журнала. Требуется перезапуск.", "log_http": "Вести учет http доступа", - "log_http_desc": "Вести учет http запросов в командную строку. Необходим перезапуск.", + "log_http_desc": "Ведет учет http запросов в окне командной строки. Необходим перезапуск.", "log_to_terminal": "Вести журнал в командной строке", "log_to_terminal_desc": "Вывод журнала событий в командную строку помимо сохранения в файл. Всегда включено если ведение журнала в файл отключено. Необходим перезапуск.", "maximum_session_age": "Максимальное время активной сессии", @@ -243,56 +249,62 @@ "username": "Имя пользователя", "username_desc": "Имя для доступа к Stash. Оставьте пустым для отключения аутентификации" }, + "backup_directory_path": { + "description": "Местоположение каталога для резервных копий базы данных SQLite", + "heading": "Путь к каталогу резервного копирования" + }, "cache_location": "Папка для расположения кэша", "cache_path_head": "Путь кэша", - "calculate_md5_and_ohash_desc": "Вычислить контрольные суммы MD5 в дополнение к oshash. Включение опции приведет к замедлению изначальных сканирований. Oshash хэширование названий файлов должно быть выбрано, чтобы выключить расчёт MD5.", + "calculate_md5_and_ohash_desc": "Рассчитать контрольные суммы MD5 в дополнение к oshash. Включение опции приведет к замедлению изначальных сканирований. Для хеширования названий файлов должно быть выбрано - oshash, чтобы выключить расчёт MD5.", "calculate_md5_and_ohash_label": "Просчитать MD5 для видеофайлов", "check_for_insecure_certificates": "Проверить на незащищённость сертификата", "check_for_insecure_certificates_desc": "Некоторые сайты используют небезопасные ssl-сертификаты. Если флажок снят, скрейпер пропускает проверку небезопасных сертификатов и разрешает сбор данных с этих сайтов. Если вы получаете ошибку сертификата при сборе, снимите этот флажок.", "chrome_cdp_path": "Chrome CDP путь", - "chrome_cdp_path_desc": "Путь к исполняемого файла Chrome, или удаленный адрес (начинающийся с http:// или https://, к примеру http://localhost:9222/json/version) к экземпляру Chrome.", + "chrome_cdp_path_desc": "Путь к исполняемому файлу Chrome, или удаленный адрес (начинающийся с http:// или https://, к примеру http://localhost:9222/json/version) к экземпляру Chrome.", "create_galleries_from_folders_desc": "Если включено, генерирует галлереи из папок с картинками.", "create_galleries_from_folders_label": "Генерация галлереи из папок с картинками", "db_path_head": "Путь к базе данных", "directory_locations_to_your_content": "Адреса папок с вашим контентом", - "excluded_image_gallery_patterns_desc": "Регулярные выражения файлов/путей изображений и галерей которые будет исключены при Санировании и добавлении в Очистку", - "excluded_image_gallery_patterns_head": "Исключенные шаблоны изображений/галерей", - "excluded_video_patterns_head": "Исключенные шаблоны видео", + "excluded_image_gallery_patterns_desc": "Регулярные выражения файлов изображений и галерей/путей которые будут исключены при Сканировании, и добавлены в Очистку", + "excluded_image_gallery_patterns_head": "Шаблоны исключений изображений/галерей", + "excluded_video_patterns_desc": "Регулярные выражения видеофайлов/путей которые будут исключены при Сканировании, и добавлены в Очистку", + "excluded_video_patterns_head": "Шаблоны исключенные видео", "gallery_ext_desc": "Список расширений через запятую, которые будут распознаны как архивы с картинками.", "gallery_ext_head": "Расширения архивов галерей", - "generated_file_naming_hash_desc": "Использовать MD5 или oshash для сгенерированных имен файлов. Меняя это, необходимо чтобы у всех сцен были соответствующие MD5/oshash значение. После изменения значения, существующие сгенерированные файлы нужно будет перенести или сгенерировать повторно. Подробнее о переносе на странице Задач.", - "generated_file_naming_hash_head": "Использовать хеш значения для имен сгенерированных файлов", + "generated_file_naming_hash_desc": "Использовать MD5 или oshash для имен сгенерированных файлов. Меняя это, необходимо чтобы у всех сцен имелись соответствующие MD5/oshash значение. После изменения значения, существующие сгенерированные файлы нужно будет перенести или сгенерировать повторно. Подробнее о переносе на странице Задач.", + "generated_file_naming_hash_head": "Хеш значения для имен сгенерированных файлов", "generated_files_location": "Директория для сгенерированных файлов (маркеры сцен, превью сцен, спрайты, и т. п.)", "generated_path_head": "Путь к сгенерированному контенту", "hashing": "Хэширование", "image_ext_desc": "Список расширений через запятую, которые будут распознаны как картинки.", "image_ext_head": "Расширения изображений", - "include_audio_desc": "Включает аудио при генерации превью.", + "include_audio_desc": "Добавляет аудио дорожку для сгенерированных превью файлов.", "include_audio_head": "Включать аудио", "logging": "Ведение журнала", "maximum_streaming_transcode_size_desc": "Максимальный размер транскодируемых потоков", - "maximum_streaming_transcode_size_head": "Максимальный размер потокового транскодирования", + "maximum_streaming_transcode_size_head": "Максимальное разрешение потокового транскодирования", "maximum_transcode_size_desc": "Максимальный размер генерируемых транскодов", - "maximum_transcode_size_head": "Максимальный размер перекодирования", + "maximum_transcode_size_head": "Максимальное разрешение транскодирования", "metadata_path": { "description": "Местоположение каталога, используемое при выполнении полного экспорта или импорта", "heading": "Путь к метаданным" }, "number_of_parallel_task_for_scan_generation_desc": "Установите 0 для автоматического определения. Предупреждение: запуск большего количества задач, чем требуется для достижения 100% загрузки процессора, снизит производительность и может вызвать другие проблемы.", - "number_of_parallel_task_for_scan_generation_head": "Количество параллельных задач сканирования\\генерации", + "number_of_parallel_task_for_scan_generation_head": "Количество параллельных задач сканирования/генерации", "parallel_scan_head": "Параллельное сканирование/генерация", "preview_generation": "Создание превью", "python_path": { "description": "Путь исполняемого файл Python. Используется для скриптов скрейперов и плагиов. Если оставить пустым, python путь будет взят из переменной окружения", - "heading": "Python путь" + "heading": "Путь Python" }, "scraper_user_agent": "User Agent скрейпера", + "scraper_user_agent_desc": "User-Agent используемый во время сбора через http запросы", "scrapers_path": { "description": "Путь к директории, где находятся файлы конфигураций скрейперов", "heading": "Путь скрейперов" }, "scraping": "Скрейпинг", - "sqlite_location": "Путь к SQLite файлу базы данных (требуется перезапуск)", + "sqlite_location": "Путь к файлу базы данных SQLite (требуется перезапуск)", "video_ext_desc": "Список расширений через запятую, которые будут распознаны как видео.", "video_ext_head": "Расширения видео", "video_head": "Видео" @@ -306,43 +318,61 @@ "log_level": "Уровень ведения журнала" }, "plugins": { - "hooks": "Триггеры" + "hooks": "Триггеры", + "triggers_on": "Срабатывает при" }, "scraping": { "entity_metadata": "{entityType} метаданные", + "entity_scrapers": "{entityType} Scraper", + "excluded_tag_patterns_desc": "Регулярные выражения для исключения тегов при сборе информации", + "excluded_tag_patterns_head": "Шаблон исключаемых тегов", "scraper": "Скрейпер", "scrapers": "Скрейперы", "search_by_name": "Поиск по названию", + "supported_types": "Поддерживаемые типы", "supported_urls": "Ссылки" }, "stashbox": { "add_instance": "Добавить stash-box instance экземпляр", "api_key": "API ключ", - "description": "Stash-box обеспечивает автоматическую расстановку тегов для сцен и актеров основываясь на данных о файлы и его названии.\nКонечная точка и API ключ можете найти на странице вашего профиля в stash-box. Название необходимо, если добавляется более одного экземпляра.", + "description": "Stash-box обеспечивает автоматическую расстановку тегов для сцен и актеров используя данные о файле и его название.\nКонечная точка и API ключ может быть найдены на странице вашего профиля в stash-box. Название необходимо, если добавляется более одного экземпляра.", "endpoint": "Конечная точка", - "name": "Имя" + "graphql_endpoint": "Конечная точка GraphQL", + "name": "Имя", + "title": "Конечные точки Stash-box" }, "system": { "transcoding": "Транскодирование" }, "tasks": { "added_job_to_queue": "Добавлено {operation_name} в очередь задач", + "auto_tag": { + "auto_tagging_all_paths": "Автоматическая расстановка тегов для всех путей", + "auto_tagging_paths": "Автоматическая расстановка тегов для следующих путей" + }, "auto_tag_based_on_filenames": "Автоматически помечать тегами контент на основе имен файлов.", - "backup_and_download": "Выполняет резервную копию базы данных, и скачивает созданный файл.", - "backup_database": "Выполняет резервную копию базы данных в ту же директорию где находится и оригинал, в формате {filename_format}", + "auto_tagging": "Автоматическая расстановка тегов", + "backing_up_database": "Резервное копирование базы данных", + "backup_and_download": "Создает резервную копию базы данных, и скачивает файл.", + "backup_database": "Выполняет резервное копирование базы данных в каталог резервных копий , в формате {filename_format}", "cleanup_desc": "Проверить наличие отсутствующих файлов и удалить их из базы данных. Данное действие безвозвратно.", + "data_management": "Управление данными", "defaults_set": "Значения по умолчанию установлены и будут использоваться при нажатии кнопки {action} на странице Задач.", "dont_include_file_extension_as_part_of_the_title": "Не включать расширение файла в название", - "empty_queue": "В данный момент никакие задачи не выполняются.", + "empty_queue": "В настоящее время задачи не выполняются.", "export_to_json": "Экспортировать содержимое базы данных в JSON формате в директорию метаданных.", "generate": { + "generating_from_paths": "Генерация для сцен из следующих путей", "generating_scenes": "Генерация для {num} {scene}" }, "generate_desc": "Создавать вспомогательные изображения, спрайты, видео, vtt и другие файлы.", + "generate_phashes_during_scan": "Генерировать воспринимаемые хэши (phash)", "generate_phashes_during_scan_tooltip": "Для дедупликации и идентификации сцен.", - "generate_previews_during_scan": "Создавать превью анимированных изображений", + "generate_previews_during_scan": "Создавать превью в виде анимированных изображений", "generate_previews_during_scan_tooltip": "Создавать анимированные WebP превью, необходимы если тип предпросмотра выбран - Анимированные изображения.", - "generate_thumbnails_during_scan": "Создание миниатюр для изображений", + "generate_sprites_during_scan": "Генерировать спрайты для полосы перемотки видео", + "generate_thumbnails_during_scan": "Создать миниатюры для изображений", + "generate_video_previews_during_scan": "Создавать превью", "generate_video_previews_during_scan_tooltip": "Создавать превью видео, которые воспроизводятся при наведении курсора на сцену", "generated_content": "Сгенерированный контент", "identify": { @@ -352,72 +382,158 @@ "description": "Автоматически добавлять метаданные к сценам используя stash-box и источники скрейперов.", "explicit_set_description": "Следующие параметры будут использоваться там, где они не переопределены в параметрах источника.", "field": "Поле", + "field_behaviour": "{strategy} {field}", + "field_options": "Параметры поля", "heading": "Идентифицировать", + "identifying_from_paths": "Определение сцен по следующим путям", + "identifying_scenes": "Определение {num} {scene}", + "include_male_performers": "Включать мужских участников", + "set_cover_images": "Установить изображения обложки", + "set_organized": "Установить флаг \"Упорядочен\"", "source": "Источник", + "source_options": "{source} Опции", "sources": "Источники", "strategy": "Стратегия" }, "import_from_exported_json": "Импортировать экспортированный JSON в каталог метаданных. Сотрет существующую базу данных.", + "incremental_import": "Инкрементальный импорт из предоставленного ZIP-файла экспорта.", + "job_queue": "Очередь задач", "maintenance": "Техническое обслуживание", - "migrate_hash_files": "Используется после изменения параметра Generated file naming hash для переименовывания сгенерированных файлов в новый хеш формат.", + "migrate_hash_files": "Используется после изменения параметра \"Хеш значения для имен сгенерированных файлов\" для изменения названий уже сгенерированных файлов в новый хеш формат.", "migrations": "Миграции", - "only_dry_run": "Выполнить только пробный запуск. Ничего не будет удалено", + "only_dry_run": "Выполнить только пробный прогон. Пользовательские файлы останутся нетронутыми, ничего не будет удалено", + "plugin_tasks": "Задачи плагинов", + "scan": { + "scanning_all_paths": "Сканирование всех путей", + "scanning_paths": "Сканирование следующих путей" + }, "scan_for_content_desc": "Поиск нового контента и добавление его в базу данных.", "set_name_date_details_from_metadata_if_present": "Задать имя, дату, детали из встроенных метаданных файла" }, "tools": { + "scene_duplicate_checker": "Проверка сцен на дубликаты", "scene_filename_parser": { + "add_field": "Добавить поле", + "capitalize_title": "Название с заглавной буквы", + "display_fields": "Отобразить поля", "escape_chars": "Используйте \\ для экранирования буквенных символов", "filename": "Имя файла", + "filename_pattern": "Шаблон имени файла", + "ignore_organized": "Игнорировать уже упорядоченные сцены", + "ignored_words": "Игнорируемые слова", + "matches_with": "Совпадает с {i}", + "select_parser_recipe": "Выбрать \"рецепт\" анализатора", + "title": "Анализатор названий файлов для сцен", + "whitespace_chars": "Символы пробелов", "whitespace_chars_desc": "Эти символы будут заменены пробелами в названии" }, "scene_tools": "Инструменты видео" }, "ui": { + "abbreviate_counters": { + "description": "Сократить счетчики на страницах карточек и детального просмотра, например, «1831» сокращенно будет отображено как «1,8K».", + "heading": "Сокращенные счетчиков количества" + }, "basic_settings": "Основные настройки", "custom_css": { "description": "Чтобы изменения вступили в силу, необходимо перезагрузить страницу.", "heading": "Пользовательский CSS", "option_label": "Пользовательский CSS включен" }, + "custom_javascript": { + "description": "Страница должна быть перезагружена для применений изменений.", + "heading": "Пользовательский Javascript", + "option_label": "Пользовательский Javascript включен" + }, + "custom_locales": { + "description": "Замена отдельных строк локализации. Смотрите https://github.com/stashapp/stash/blob/develop/ui/v2.5/src/locales/en-GB.json для основного списка. Чтобы изменения вступили в силу, необходимо перезагрузить страницу.", + "heading": "Пользовательская локализация", + "option_label": "Пользовательская локализация включена" + }, "delete_options": { "description": "Настройки по умолчанию при удалении изображений, галерей и сцен.", + "heading": "Опции удаления", "options": { - "delete_file": "По умолчанию удалить файлы" + "delete_file": "По умолчанию удалить файлы", + "delete_generated_supporting_files": "По умолчанию удалять сгенерированные вспомогательные файлы" } }, "desktop_integration": { "desktop_integration": "Интеграция с рабочим столом", "notifications_enabled": "Включить уведомления", "send_desktop_notifications_for_events": "Отправка уведомлений на рабочий стол о событиях", - "skip_opening_browser": "Не открывать браузер", - "skip_opening_browser_on_startup": "Пропустить автоматическое открытие браузера при запуске" + "skip_opening_browser": "Не открывать страницу в браузере", + "skip_opening_browser_on_startup": "Пропустить автоматическое открытие страницы в браузере при запуске" }, "editing": { "disable_dropdown_create": { - "description": "Убрать возможность создавать новые объекты из выпадающих селекторов" + "description": "Убрать возможность создавать новые объекты из выпадающих селекторов", + "heading": "Отключить создание из раскрывающегося списка" }, - "heading": "Редактирование" + "heading": "Редактирование", + "rating_system": { + "star_precision": { + "label": "Точность системы оценок", + "options": { + "full": "Полный", + "half": "Половина", + "quarter": "Четверть" + } + }, + "type": { + "label": "Тип рейтинговой системы", + "options": { + "decimal": "Десятичный", + "stars": "Звезды" + } + } + } }, "funscript_offset": { - "description": "Смещение времени в миллисекундах для воспроизведения интерактивных скриптов." + "description": "Смещение времени в миллисекундах для воспроизведения интерактивных скриптов.", + "heading": "Funscript смещение (миллисекунды)" + }, + "handy_connection": { + "connect": "Подключить", + "server_offset": { + "heading": "Серверная компенсация" + }, + "status": { + "heading": "Статус соединения Handy" + }, + "sync": "Синхронизировать" + }, + "handy_connection_key": { + "description": "Ключ соединения с сервисом Handy используемый для интерактивных сцен. Установка этого ключа позволит Stash делится информацией о вашей текущий сценой с сайтом handyfeeling.com", + "heading": "Ключ подключения Handy" + }, + "image_lightbox": { + "heading": "Окно просмотра изображений" }, "images": { "heading": "Изображения", "options": { "write_image_thumbnails": { - "description": "Записывать эскизы изображений на диск сразу при создании на лету" + "description": "Записывать эскизы изображений на диск сразу при создании на лету", + "heading": "Сохранить миниатюры изображений" } } }, + "interactive_options": "Интерактивные опции", "language": { "heading": "Язык" }, "max_loop_duration": { - "description": "Максимальная продолжительность сцены, при которой проигрыватель сцен будет зацикливать видео - 0 для отключения" + "description": "Максимальная продолжительность сцены, при которой проигрыватель сцен будет зацикливать видео - 0 для отключения", + "heading": "Максимальная продолжительность повторяющегося сегмента" }, "menu_items": { - "description": "Показать или скрыть различные типы контента на панели навигации" + "description": "Показать или скрыть различные типы контента на панели навигации", + "heading": "Пункты меню" + }, + "minimum_play_percent": { + "description": "Процентная часть сцены, после которой счетчик воспроизведения будет увеличен.", + "heading": "Минимальный процент проигрывания" }, "performers": { "options": { @@ -428,38 +544,74 @@ } }, "preview_type": { - "description": "Конфигурация для элементов стены", + "description": "Конфигурация отображения для элементов для вида \"Стена\"", + "heading": "Тип предварительного просмотра", "options": { + "animated": "Анимированное изображение", + "static": "Статичное изображение", "video": "Видео" } }, "scene_list": { + "heading": "Список сцен", "options": { - "show_studio_as_text": "Показывать студии текстом" + "show_studio_as_text": "Отображать названия студии только в виде текста" } }, "scene_player": { + "heading": "Проигрыватель видео", "options": { + "always_start_from_beginning": "Всегда начинать воспроизведение видео сначало", + "auto_start_video": "Автозапуск видео", + "auto_start_video_on_play_selected": { + "description": "Автоматически запускать видео из очереди или при воспроизведении выбранных или случайных видео со страницы \"Сцены\"", + "heading": "Автоматическое воспроизведение выбранных видео" + }, "continue_playlist_default": { "description": "Запускать следующую сцену в очереди после окончания видео", "heading": "Продолжить плейлист по умолчанию" - } + }, + "show_scrubber": "Показывать полосу перемотки видео с превью (Show Scrubber)", + "track_activity": "Отслеживать активность" } }, "scene_wall": { "heading": "Стена сцен / маркеров", "options": { - "display_title": "Отображать название и тэги", + "display_title": "Отображать названия и теги", "toggle_sound": "Включить звук" } }, "scroll_attempts_before_change": { - "description": "Количество попыток прокрутки перед переходом к следующему/предыдущему элементу. Применяется только для режима прокрутки наклона Y." + "description": "Количество попыток прокрутки перед переходом к следующему/предыдущему элементу. Применяется только для режима прокрутки наклона Y.", + "heading": "Количество попыток прокрутки до перехода" + }, + "show_tag_card_on_hover": { + "description": "Показывать карточку тега, при наведении курсора на значок тега", + "heading": "Подсказки к карточкам тегов" }, "slideshow_delay": { "description": "Слайд-шоу доступно в галереях, в режиме просмотра стены", "heading": "Задержка показа слайдов (сек.)" }, + "studio_panel": { + "heading": "Просмотр Студий", + "options": { + "show_child_studio_content": { + "description": "В режиме просмотра студий, показывать контент и из под-студий", + "heading": "Показывать контент под-студий" + } + } + }, + "tag_panel": { + "heading": "Вид тегов", + "options": { + "show_child_tagged_content": { + "description": "В режиме тегов, также будет показан контент и от подтегов", + "heading": "Отображать контент под-тегов" + } + } + }, "title": "Пользовательский интерфейс" } }, @@ -473,42 +625,55 @@ "performers": "{count, plural, one {Актер} other {Актеры}}", "scenes": "{count, plural, one {Сцена} other {Сцены}}", "studios": "{count, plural, one {Студия} other {Студии}}", - "tags": "{count, plural, one {Тэг} other {Тэги}}" + "tags": "{count, plural, one {Тег} other {Теги}}" }, "country": "Страна", "cover_image": "Изображение обложки", + "created_at": "Создано", "criterion": { + "greater_than": "Больше чем", + "less_than": "Меньше чем", "value": "Значение" }, "criterion_modifier": { "between": "между", "equals": "есть", "excludes": "исключает", + "format_string": "{criterion} {modifierString} {valueString}", + "greater_than": "больше, чем", "includes": "включает", + "includes_all": "включает все", + "is_null": "is null", + "less_than": "меньше чем", "matches_regex": "соответствует регулярному выражению", "not_between": "не между", "not_equals": "является не", - "not_matches_regex": "не соответствует регулярному выражению" + "not_matches_regex": "не соответствует регулярному выражению", + "not_null": "не равняется ничему" }, "custom": "Пользовательский", "date": "Дата", "death_date": "Дата смерти", "death_year": "Год смерти", "descending": "По убыванию", + "description": "Описание", "detail": "Дополнительная информация", "details": "Подробности", "developmentVersion": "Версия разработки", "dialogs": { "aliases_must_be_unique": "псевдонимы должны быть уникальными", + "create_new_entity": "Создать новую запись в {entity}", "delete_alert": "Следующие {count, plural, one {{singularEntity}} other {{pluralEntity}}} будут удалены безвозвратно:", "delete_confirm": "Вы уверены что хотите удалить {entityName}?", "delete_entity_desc": "{count, plural, one {Вы уверены, что хотите удалить этот {singularEntity}? Если файл также не будет удален, этот {singularEntity} будет повторно добавлен при сканировании.} other {Вы уверены, что хотите удалить эти {pluralEntity}? Если файлы также не будут удалены, эти {pluralEntity} будут повторно добавлены при выполнении сканирования.}}", + "delete_entity_simple_desc": "{count, plural, one {Вы уверены, что хотите удалить {singularEntity}?} other {Вы уверены, что хотите удалить эти {pluralEntity}?}}", "delete_entity_title": "{count, plural, one {Удалить {singularEntity}} other {Удалить {pluralEntity}}}", "delete_galleries_extra": "…плюс любые файлы изображений, не прикрепленные ни к какой другой галерее.", "delete_gallery_files": "Удалите папку/архив галереи и любые изображения, не прикрепленные к какой-либо другой галерее.", "delete_object_desc": "Вы уверены что хотите удалить {count, plural, one {эту {singularEntity}} other {эти{pluralEntity}}}?", "delete_object_overflow": "…и {count} other {count, plural, one {{singularEntity}} other {{pluralEntity}}}.", "delete_object_title": "Удалить {count, plural, one {{singularEntity}} other {{pluralEntity}}}", + "dont_show_until_updated": "Не показывать до следующего обновления", "edit_entity_title": "Редактировать {count, plural, one {{singularEntity}} other {{pluralEntity}}}", "export_include_related_objects": "Включить связанные объекты в экспорт", "export_title": "Экспорт", @@ -533,17 +698,26 @@ "zoom": "Увеличить" } }, + "merge": { + "destination": "Цель", + "empty_results": "Значения в полях целевого файла останутся без изменений.", + "source": "Источник" + }, "merge_tags": { "destination": "Назначение", "source": "Источник" }, "overwrite_filter_confirm": "Вы уверены, что хотите перезаписать существующий сохраненный запрос {entityName}?", + "reassign_entity_title": "{count, plural, one {Переназначить {singularEntity}} other {Переназначить {pluralEntity}}}", + "reassign_files": { + "destination": "Переназначить на" + }, "scene_gen": { "force_transcodes": "Принудительно создавать перекодированные файлы", "force_transcodes_tooltip": "По умолчанию перекодированные файлы генерируется только в том случае, если видеофайл не поддерживается браузером. Если этот параметр включен, перекодирование будет генерироваться, даже если видеофайл поддерживается браузером.", "image_previews": "Анимированные изображения как превью", "image_previews_tooltip": "Анимированные WebP превью, необходимы если тип предпросмотра выбран - Анимированные изображения.", - "interactive_heatmap_speed": "Создание тепловых карт и скоростей для интерактивных сцен", + "interactive_heatmap_speed": "Создание тепловых карт и скоростей скриптов (funscript) для интерактивных сцен", "marker_image_previews": "Анимированный предварительный просмотр для маркеров", "marker_image_previews_tooltip": "Анимированные WebP превью для маркеров, необходимы если тип предпросмотра выбран - Анимированные изображения.", "marker_screenshots": "Скриншоты для маркеров", @@ -553,20 +727,22 @@ "override_preview_generation_options": "Пользовательские параметры превью видео файлов", "override_preview_generation_options_desc": "Пользовательские параметры для создания превью. Значения по умолчанию находятся в Система -> Создание Превью.", "overwrite": "Перезаписать существующие сгенерированные файлы", - "phash": "Perceptual хэши (для дедупликации)", + "phash": "Воспринимаемые хэши (для дедупликации файлов)", "preview_exclude_end_time_desc": "Исключить последние x секунд из предварительного просмотра сцен. Это может быть значение в секундах или процент (например, 2%) от общей продолжительности сцены.", "preview_exclude_end_time_head": "Исключенное время с конца файла", "preview_exclude_start_time_desc": "Исключить первые x секунд из предварительного просмотра сцен. Это может быть значение в секундах или процент (например, 2%) от общей продолжительности сцены.", "preview_exclude_start_time_head": "Исключенное время с начала файла", "preview_generation_options": "Параметры создания\\генерации превью", "preview_options": "Параметры превью", - "preview_preset_desc": "Предустановка регулирует размер, качество и время кодирования генерации предварительного просмотра. Предустановки с значением выше «slow» имеют незначительные преимущества и не рекомендуются.", - "preview_preset_head": "Предустановка кодирования превью", + "preview_preset_desc": "Предустановка влияет на размер, качество и время кодирования для создания файлов предварительного просмотра. Предустановки с значением выше «slow» имеют незначительные преимущества и не рекомендуются.", + "preview_preset_head": "Настройка кодировки предварительного просмотра", "preview_seg_count_desc": "Количество сегментов в файлах предварительного просмотра.", "preview_seg_count_head": "Количество сегментов в предварительном просмотре", "preview_seg_duration_desc": "Продолжительность каждого сегмента в файлах предварительного просмотра, в секундах.", "preview_seg_duration_head": "Продолжительность сегмента предварительного просмотра", - "transcodes": "Транскоды", + "sprites": "Спрайты сцен для полосы перемотки видео", + "sprites_tooltip": "Небольшие изображения предварительного просмотра для временной шкалы видео (для scrubber'а сцен)", + "transcodes": "Перекодированные файлы", "transcodes_tooltip": "Конвертация в MP4 формат, всех неподдерживаемых видео форматов", "video_previews": "Превью", "video_previews_tooltip": "Видео превью которые проигрываются при наведении курсора на сцену" @@ -575,6 +751,7 @@ "scrape_entity_query": "{entity_type} Scrape запрос", "scrape_entity_title": "{entity_type} Scrape результаты", "scrape_results_existing": "Существующий", + "scrape_results_scraped": "Собрано", "set_image_url_title": "Ссылка изображения", "unsaved_changes": "Имеются несохраненные изменений. Вы действительно хотите выйти?" }, @@ -631,13 +808,16 @@ "false": "Нет", "favourite": "Избранный", "file": "файл", + "file_count": "Количество файлов", "file_info": "Информация о файле", "file_mod_time": "Время модификации файла", "files": "файлы", + "files_amount": "{value} файлов", "filesize": "Размер файла", "filter": "Фильтр", "filter_name": "Название фильтра", "filters": "Фильтры", + "folder": "Папка", "framerate": "Частота кадров", "frames_per_second": "{value} кадров в секунду", "front_page": { @@ -652,7 +832,9 @@ "gender": "Пол", "gender_types": { "FEMALE": "Женщина", + "INTERSEX": "Интерсекс", "MALE": "Мужчина", + "NON_BINARY": "Не бинарный", "TRANSGENDER_FEMALE": "Женщина трансгендер", "TRANSGENDER_MALE": "Мужчина трансгендер" }, @@ -668,8 +850,9 @@ }, "hasMarkers": "Имеет маркеры", "height": "Рост", + "height_cm": "Рост (см)", "help": "Помощь", - "ignore_auto_tag": "Игнорировать автоматическую пометку тэгами", + "ignore_auto_tag": "Игнорировать автоматическую пометку тегами", "image": "Изображение", "image_count": "Количество изображений", "images": "Изображения", @@ -680,6 +863,7 @@ "interactive": "Интерактивно", "interactive_speed": "Интерактивная скорость", "isMissing": "Отсутствует", + "last_played_at": "Воспроизводился в последний раз", "library": "Библиотека", "loading": { "generic": "Загрузка…" @@ -697,7 +881,9 @@ "age": "{age} {years_old}", "age_context": "{age} {years_old} в этой сцене" }, - "phash": "PHash", + "phash": "PHash значение", + "play_count": "Счетчик воспроизведений", + "play_duration": "Продолжительность", "stream": "Стрим", "video_codec": "Видео кодек" }, @@ -708,14 +894,14 @@ "movies": "Фильмы", "name": "Имя", "new": "Новый", - "none": "Никакой", + "none": "Отсутствует", "o_counter": "О-Счетчик", "operations": "Операции", "organized": "Организован", "pagination": { - "first": "Первый", - "last": "Последний", - "next": "Следующий", + "first": "Первая", + "last": "Последняя", + "next": "Следующая", "previous": "Предыдущий" }, "parent_of": "Родитель {children}", @@ -729,11 +915,19 @@ "performerTags": "Теги актера", "performer_age": "Возраст актера", "performer_count": "Количество актеров", + "performer_favorite": "Участник добавлен в избранное", "performer_image": "Изображение актера", "performer_tagger": { "add_new_performers": "Добавить новых актеров", + "any_names_entered_will_be_queried": "Любые введенные имена будут проверены при помощи Stash-Box и позже добавлены, если они будут найдены точные совпадения.", + "batch_add_performers": "Пакетное добавление участников", + "batch_update_performers": "Пакетное обновление участников", "config": { + "active_stash-box_instance": "Активный stash-box экземпляр:", + "edit_excluded_fields": "Редактировать исключенные поля", + "excluded_fields": "Исключенные поля:", "no_fields_are_excluded": "Нет исключенных полей", + "no_instances_found": "Экземпляры не обнаружены", "these_fields_will_not_be_changed_when_updating_performers": "Эти поля не будут изменены при обновлении актеров." }, "current_page": "Текущая страница", @@ -742,16 +936,16 @@ "network_error": "Ошибка сети", "no_results_found": "Ничего не найдено.", "number_of_performers_will_be_processed": "{performer_count} актер(-ы) будут обработаны", - "performer_already_tagged": "Актер уже помечен тэгом", + "performer_already_tagged": "Актер уже помечен тегом", "performer_names_separated_by_comma": "Имена актеров, разделенные запятой", "performer_selection": "Выбор актеров", - "performer_successfully_tagged": "Актер успешно помечен тэгом:", + "performer_successfully_tagged": "Актер успешно помечен тегом:", "query_all_performers_in_the_database": "Все актеры в базе данных", - "refresh_tagged_performers": "Обновить помеченных тэгами актеров", - "refreshing_will_update_the_data": "Обновление обновит данные у всех помеченных тэгами актеров, используя stash-box.", - "status_tagging_job_queued": "Статус: Пометка тэгами в очереди", - "status_tagging_performers": "Статус: Пометка тэгами актеров", - "tag_status": "Статус тэга", + "refresh_tagged_performers": "Обновить помеченных тегами актеров", + "refreshing_will_update_the_data": "Обновление обновит данные у всех помеченных тегами актеров, используя stash-box.", + "status_tagging_job_queued": "Статус: Пометка тегами добавлена в очередь", + "status_tagging_performers": "Статус: Пометка тегами актеров", + "tag_status": "Статус тега", "to_use_the_performer_tagger": "Для пометки актеров тэгами, stash-box должен быть настроен.", "untagged_performers": "Не помеченные актеры", "update_performer": "Обновить актера", @@ -760,17 +954,26 @@ }, "performers": "Исполнители", "piercings": "Пирсинг", + "play_count": "Счетчик воспроизведений", + "play_duration": "Продолжительность воспроизведения", + "primary_file": "Первичный файл", "queue": "Очередь", "random": "Случайный", "rating": "Рейтинг", "recently_added_objects": "Недавно добавленные {objects}", "recently_released_objects": "Недавно выпущенные {objects}", + "release_notes": "Информация о сборке", "resolution": "Разрешение", + "resume_time": "Таймкод воспроизведения", "scene": "Сцена", "sceneTagger": "Пометка сцен тэгами", "sceneTags": "Тэги сцен", + "scene_code": "Идентификатор сцены", "scene_count": "Количество сцен", + "scene_created_at": "Сцена создана", + "scene_date": "Дата сцены", "scene_id": "ID сцены", + "scene_updated_at": "Сцена обновлена", "scenes": "Сцены", "scenes_updated_at": "Сцена обновлена в", "search_filter": { @@ -785,17 +988,27 @@ "confirm": { "almost_ready": "Мы почти завершили настройку. Подтвердите следующие настройки. Вы можете щелкнуть назад, чтобы изменить что-либо неправильное. Если все выглядит хорошо, нажмите «Подтвердить», чтобы создать свою систему.", "configuration_file_location": "Путь к файлу конфигурации:", + "database_file_path": "Путь файла базы данных", "default_db_location": "<путь, содержащий файл конфигурации>/stash-go.sqlite", - "default_generated_content_location": "<путь, содержащий файл конфигурации>/generated" + "default_generated_content_location": "<путь, содержащий файл конфигурации>/generated", + "generated_directory": "Папка сгенерированных вспомогательных файлов", + "nearly_there": "Почти готово!", + "stash_library_directories": "Библиотека каталогов Stash" }, "creating": { - "ffmpeg_notice": "Если ffmpeg еще не указан в ваших путях, наберитесь терпения, пока stash загрузит его. Просмотрите вывод в консоли, чтобы проследить за ходом загрузки." + "creating_your_system": "Создаем вашу систему", + "ffmpeg_notice": "Если ffmpeg еще не указан в ваших путях, наберитесь терпения, пока stash загрузит его. Чтобы проследить за ходом загрузки, проверьте консоль." }, "errors": { "something_went_wrong": "О, нет! Что-то пошло не так!", "something_went_wrong_description": "Если это похоже на проблему с вашими входными данными, нажмите «Назад», чтобы исправить их. В противном случае сообщите об ошибке на {githubLink} или обратитесь за помощью в {discordLink}.", "something_went_wrong_while_setting_up_your_system": "Что-то пошло не так при настройке вашей системы. Мы получили следующую ошибку: {error}" }, + "folder": { + "file_path": "Путь файла", + "up_dir": "Каталогом выше" + }, + "github_repository": "Репозиторий Github", "migrate": { "backup_database_path_leave_empty_to_disable_backup": "Путь к резервной копии базы данных (оставьте пустым чтобы отключить резервное копирование):", "backup_recommended": "Рекомендуется создать резервную копию перед тем как выполнять перенос. Мы может сделать это за вас, создав копию базы данных по пути {defaultBackupPath}.", @@ -804,28 +1017,33 @@ "migration_failed_error": "При переносе базы данных возникла следующая ошибка:", "migration_failed_help": "Внесите необходимые исправления и повторите попытку. В противном случае сообщите об ошибке в {githubLink} или обратитесь за помощью в {discordLink}.", "migration_irreversible_warning": "Процесс переноса схемы необратим. После выполнения ваша база данных будет несовместима с предыдущими версиями stash.", + "migration_notes": "Заметки переноса", "migration_required": "Перенос необходим", + "perform_schema_migration": "Выполнить миграцию схемы", "schema_too_old": "Ваша текущая Stash база данных имеет версию схемы {databaseSchema}, и ее необходимо перенести на версию {appSchema}. Текущая версия Stash не будет работать без переноса базы данных." }, "paths": { "database_filename_empty_for_default": "название файла базы данных (пусто по умолчанию)", "description": "Дальше нам нужно определить где сможем найти вашу коллекцию порно, где хранить базу данных Stash и генерированные файлы. Эти настройки при необходимости позже возможно будет изменить.", + "path_to_generated_directory_empty_for_default": "путь к сгенерированному каталогу (не указан по умолчанию)", "set_up_your_paths": "Задайте ваши пути", - "stash_alert": "Пути к библиотекам не выбраны. Никакие носители не смогут быть отсканированы в Stash. Вы уверены?", + "stash_alert": "Пути к файлам не выбраны. Для Stash никакие медиа файлы не будут отсканированы и добавлены. Вы уверены?", "where_can_stash_store_its_database": "Где Stash может хранить свою базу данных?", "where_can_stash_store_its_database_description": "Stash использует sqlite базу данных для хранения метаданных вашего порно. По умолчанию, она будет создана как stash-go.sqlite в директории где находится ваш файл конфигурации. Если хотите изменить это, пожалуйста введите абсолютный или относительный путь к файлу.", "where_can_stash_store_its_generated_content": "Где Stash сможет хранить сгенерированный контент?", - "where_can_stash_store_its_generated_content_description": "Чтобы предоставить эскизы, превью и спрайты, Stash генерирует изображения и видео. Сюда также входят транскоды для неподдерживаемых форматов файлов. По умолчанию Stash создает каталог generated в каталоге, содержащем ваш файл конфигурации. Если вы хотите изменить место хранения сгенерированного мультимедиа, введите абсолютный или относительный (по отношению к текущему рабочему каталогу) путь. Stash создаст этот каталог, если он еще не существует.", + "where_can_stash_store_its_generated_content_description": "Чтобы предоставить эскизы, превью и спрайты, Stash генерирует изображения и видео. Сюда также входят перекодированные файлы для неподдерживаемых форматов файлов. По умолчанию Stash создает каталог generated в каталоге, содержащем ваш файл конфигурации. Если вы хотите изменить место хранения сгенерированного мультимедиа, введите абсолютный или относительный (по отношению к текущему рабочему каталогу) путь. Stash создаст этот каталог, если он еще не существует.", "where_is_your_porn_located": "Где находится ваше порно?", "where_is_your_porn_located_description": "Добавьте каталоги, содержащие ваши порно видео и изображения. Stash будет использовать эти каталоги для поиска видео и изображений во время сканирования." }, "stash_setup_wizard": "Мастер установки Stash", "success": { + "getting_help": "Помощь", "help_links": "Если у вас возникнут проблемы, какие-либо вопросы или предложения, не стесняйтесь добавить описание проблемы в {githubLink} или задать вопрос сообществу в {discordLink}.", "in_app_manual_explained": "Вам рекомендуется ознакомиться с руководством в приложении, доступ к которому можно получить с помощью значка в правом верхнем углу экрана, который выглядит следующим образом: {icon}", "next_config_step_one": "Дальше вы будете направлены на страницу Конфигурации. Эта страница позволит вам настроить какие файлы включать, какие - исключить, задать имя пользователя и пароль для защиты системы, а также уйму других пунктов.", "next_config_step_two": "Если вас устраивают эти настройки, вы можете начать сканировать содержимое в Stash, нажав {localized_task}, а затем {localized_scan}.", "open_collective": "Посетите {open_collective_link}, чтобы узнать, как вы можете внести свой вклад в дальнейшее развитие Stash.", + "support_us": "Поддержите нас", "thanks_for_trying_stash": "Спасибо, что попробовали Stash!", "welcome_contrib": "Кроме того, всегда приветствуются привносимый вклад в виде кода (исправления ошибок, улучшения и новые функции), тестирование, отчетов об ошибках, идей для функций и улучшений, а также поддержка пользователей. Подробности об этом в соответствующем разделе встроенного в приложение руководства пользователя.", "your_system_has_been_created": "Готово! Ваша система создана!" @@ -847,10 +1065,16 @@ "welcome_to_stash": "Добро пожаловать в Stash" }, "stash_id": "Stash ID", + "stash_id_endpoint": "Конечная точка Stash ID", + "stash_ids": "Stash ID-ы", "stashbox": { "go_review_draft": "Перейдите к {endpoint_name}, чтобы просмотреть черновик.", + "selected_stash_box": "Выбранный Stash-Box экземпляр", + "submission_failed": "Отправка не удалась", + "submission_successful": "Отправка прошла успешно", "submit_update": "Уже существует в {endpoint_name}" }, + "statistics": "Статистика", "stats": { "image_size": "Объем изображений", "scenes_duration": "Продолжительность сцен", @@ -861,6 +1085,7 @@ "studio_depth": "Уровни (пусто для всех)", "studios": "Студии", "sub_tag_count": "Количество под-тегов", + "sub_tag_of": "Под-тег от {parent}", "sub_tags": "Под-Теги", "subsidiary_studios": "Дочерние студии", "synopsis": "Резюме", @@ -872,14 +1097,17 @@ "toast": { "added_entity": "Добавлен {count, plural, one {{singularEntity}} other {{pluralEntity}}}", "added_generation_job_to_queue": "Задание генерации добавлено в очередь", + "created_entity": "Создан {entity}", "default_filter_set": "Стандартный набор фильтров", "delete_past_tense": "Удален {count, plural, one {{singularEntity}} other {{pluralEntity}}}", "generating_screenshot": "Создание скриншота…", + "merged_scenes": "Объединенные сцены", "merged_tags": "Объединенные тэги", + "reassign_past_tense": "Файл переназначен", "removed_entity": "Удален {count, plural, one {{singularEntity}} other {{pluralEntity}}}", "rescanning_entity": "Повторное сканирование {count, plural, one {{singularEntity}} other {{pluralEntity}}}…", "saved_entity": "Сохранено {entity}", - "started_auto_tagging": "Начата автоматическая пометка тэгами", + "started_auto_tagging": "Автоматическая пометка тэгами запущена", "started_generating": "Генерация запущена", "started_importing": "Импортирование начато", "updated_entity": "Обновлен {entity}" @@ -893,5 +1121,7 @@ "videos": "Видео", "view_all": "Показать все", "weight": "Вес", - "years_old": "Возраст" + "weight_kg": "Вес (кг)", + "years_old": "Возраст", + "zip_file_count": "Количество zip-файлов" } diff --git a/ui/v2.5/src/locales/sv-SE.json b/ui/v2.5/src/locales/sv-SE.json index 28df4b15a..64f41fdea 100644 --- a/ui/v2.5/src/locales/sv-SE.json +++ b/ui/v2.5/src/locales/sv-SE.json @@ -54,6 +54,7 @@ "import": "Importera…", "import_from_file": "Importera från fil", "logout": "Logga ut", + "make_primary": "Gör primär", "merge": "Slå samman", "merge_from": "Slå samman från", "merge_into": "Slå samman till", @@ -189,6 +190,7 @@ }, "categories": { "about": "Om", + "changelog": "Ändringslogg", "interface": "Gränssnitt", "logs": "Loggar", "metadata_providers": "Metadataleverantörer", @@ -243,6 +245,10 @@ "username": "Användarnamn", "username_desc": "Användarnamn till Stash. Lämna tom för att inaktivera användarautentisering" }, + "backup_directory_path": { + "description": "Filsökväg för SQLite-databas backupfil", + "heading": "Backup filsökväg" + }, "cache_location": "Mappsökväg till cache", "cache_path_head": "Cache-sökväg", "calculate_md5_and_ohash_desc": "Beräkna MD5 checksumma i tillägg med ohash. Aktivering kan sakta ner första skanningar. Hashen måste vara vald till ohash för att avaktivera MD5-beräkning.", @@ -344,7 +350,7 @@ "auto_tagging": "Automatisk taggning", "backing_up_database": "Säkerhetskopierar databas", "backup_and_download": "Säkerhetskopierar databasen och laddar ner den resulterande filen.", - "backup_database": "Säkerhetskopierar databasen i databasens mapp med filnamnsformatet {filename_format}", + "backup_database": "Säkerhetskopierar databasen i backup-mappen med filnamnsformatet {filename_format}", "cleanup_desc": "Kollar efter saknade filer och raderar dem från databasen. Detta är en destruktiv handling.", "data_management": "Datahantering", "defaults_set": "Standard har valts och kommer användas när {action} trycks på Jobbsidan.", @@ -420,12 +426,21 @@ "scene_tools": "Scenverktyg" }, "ui": { + "abbreviate_counters": { + "description": "Förkorta siffror i kort- och detaljvyn, t.ex. \"1831\" kommer formateras \"1.8k\".", + "heading": "Förkorta siffror" + }, "basic_settings": "Grundinställningar", "custom_css": { "description": "Sidan måste laddas om för att ändringar ska ta effekt.", "heading": "Anpassad CSS", "option_label": "Använd anpassad CSS" }, + "custom_locales": { + "description": "Ändra individuella lokala strängar. Se https://github.com/stashapp/stash/blob/develop/ui/v2.5/src/locales/en-GB.json för kompletta listan. Sidan måste laddas om för att ändringar ska ske.", + "heading": "Egen översättning", + "option_label": "Aktiverat egen översättning" + }, "delete_options": { "description": "Standardalternativ vid radering av bilder, gallerier och scener.", "heading": "Alternativ för radering", @@ -518,7 +533,7 @@ "options": { "auto_start_video": "Starta videouppspelning automatiskt", "auto_start_video_on_play_selected": { - "description": "Autostarta uppspelning när valda eller slumpade spelas från Scener-sidan", + "description": "Autostarta uppspelning från kön, valda eller slumpade från Scener-sidan", "heading": "Autostarta video när valda spelas" }, "continue_playlist_default": { @@ -539,10 +554,32 @@ "description": "Antal gånger att försöka bläddra innan flytt till nästa/tidigare objekt. Används bara för Pan Y-läget.", "heading": "Bläddringsförsök innan flytt" }, + "show_tag_card_on_hover": { + "description": "Visa taggkortet när pekaren är på taggmärken", + "heading": "Taggkort" + }, "slideshow_delay": { "description": "Bildspel är tillgängligt för gallerier i väggvisningsläge", "heading": "Bildspelsfördröjning (sekunder)" }, + "studio_panel": { + "heading": "Studiovy", + "options": { + "show_child_studio_content": { + "description": "Visa också innehåll från underordnade studior i studiovyn", + "heading": "Visa underordnade studiors innehåll" + } + } + }, + "tag_panel": { + "heading": "Taggvy", + "options": { + "show_child_tagged_content": { + "description": "Visa också innehåll från underordnade taggar i taggvyn", + "heading": "Visa underordande taggars innehåll" + } + } + }, "title": "Användargränssnitt" } }, @@ -587,6 +624,7 @@ "death_date": "Dödsdatum", "death_year": "Dödsår", "descending": "Fallande", + "description": "Beskrivning", "detail": "Beskrivning", "details": "Beskrivningar", "developmentVersion": "Utvecklingsversion", @@ -595,12 +633,14 @@ "delete_alert": "De följande {count, plural, one {{singularEntity}} andra {{pluralEntity}}} kommer raderas permanent:", "delete_confirm": "Är du säker på att du vill radera {entityName}?", "delete_entity_desc": "{count, plural, one {Är du säker att du vill radera detta {singularEntity}? Sålänge filen inte också raderas, kommer {singularEntity} att läggas till igen vid nästa skanning.} other {Är du säker att du vill radera dessa {pluralEntity}? Sålänge filen inte också raderas, kommer dessa {pluralEntity} att läggas till igen vid nästa skanning.}}", + "delete_entity_simple_desc": "{count,plural,one {Är du säker att du vill radera denna {singularEntity}?} other {Är du säker att du vill radera dessa {pluralEntity}?}}", "delete_entity_title": "{count, plural, one {Radera {singularEntity}} other {Radera {pluralEntity}}}", "delete_galleries_extra": "... och alla bildfiler som inte är kopplade till något annat galleri.", "delete_gallery_files": "Radera gallerimapp/zip-fil och alla bilder som inte är kopplade till något annat galleri.", "delete_object_desc": "Är du säker på att du vill radera {count, plural, one {denna {singularEntity}} other {dessa {pluralEntity}}}?", "delete_object_overflow": "…och {count} other {count, plural, one {{singularEntity}} other {{pluralEntity}}}.", "delete_object_title": "Radera {count, plural, one {{singularEntity}} other {{pluralEntity}}}", + "dont_show_until_updated": "Visa inte tills nästa uppdatering", "edit_entity_title": "Redigera {count, plural, one {{singularEntity}} other {{pluralEntity}}}", "export_include_related_objects": "Inkludera relaterade objekt i exporten", "export_title": "Exportera", @@ -726,6 +766,7 @@ "false": "Falsk", "favourite": "Favorit", "file": "fil", + "file_count": "Filantal", "file_info": "Filinfo", "file_mod_time": "Filens Modifikationstid", "files": "filer", @@ -733,6 +774,7 @@ "filter": "Filter", "filter_name": "Filternamn", "filters": "Filter", + "folder": "Mapp", "framerate": "Bildhastighet", "frames_per_second": "{value} bilder per sekund", "front_page": { @@ -765,6 +807,7 @@ }, "hasMarkers": "Har markörer", "height": "Längd", + "height_cm": "Längd (cm)", "help": "Hjälp", "ignore_auto_tag": "Ignorera Autotagg", "image": "Bild", @@ -865,15 +908,18 @@ }, "performers": "Stjärnor", "piercings": "Piercingar", + "primary_file": "Primär fil", "queue": "Kö", "random": "Slumpad", "rating": "Betyg", "recently_added_objects": "Nyligen Tillagda {objects}", "recently_released_objects": "Nyligen Släppta {objects}", + "release_notes": "Versionsfakta", "resolution": "Upplösning", "scene": "Scen", "sceneTagger": "Scentaggaren", "sceneTags": "Scentaggar", + "scene_code": "Studiokod", "scene_count": "Antal scener", "scene_id": "Scenens ID", "scenes": "Scener", @@ -919,9 +965,10 @@ "migration_failed_error": "Följande fel stöttes på under migrationen av databasen:", "migration_failed_help": "Vänligen gör alla nödvändiga korrektioner och testa igen. Annars, skapa en buggrapport på {githubLink} eller sök hjälp på {githubLink}.", "migration_irreversible_warning": "Processen för schemamigration är ej omvändbar. När migrationen har skett kommer din databas inte längre vara kompatibel med tidigare versioner av Stash.", + "migration_notes": "Migrationsfakta", "migration_required": "Migration krävs", "perform_schema_migration": "Genomför schemamigration", - "schema_too_old": "Din nuvarande databas använder schemaversion {databaseSchema} och behöver migreras till version {appSchema}. Denna version av Stash kommer inte fungera utan en databasmigration." + "schema_too_old": "Din nuvarande databas använder schemaversion {databaseSchema} och behöver migreras till version {appSchema}. Denna version av Stash kommer inte fungera utan en databasmigration. Om du inte vill migrera kommer du behöva nedgradera till en version som matcher din databasschema." }, "paths": { "database_filename_empty_for_default": "databasfilnamn (blank för standard)", @@ -1019,5 +1066,7 @@ "videos": "Videor", "view_all": "Visa Allt", "weight": "Vikt", - "years_old": "år gammal" + "weight_kg": "Vikt (kg)", + "years_old": "år gammal", + "zip_file_count": "Antal Zip-filer" } diff --git a/ui/v2.5/src/locales/uk-UA.json b/ui/v2.5/src/locales/uk-UA.json new file mode 100644 index 000000000..5bfbd192e --- /dev/null +++ b/ui/v2.5/src/locales/uk-UA.json @@ -0,0 +1,219 @@ +{ + "actions": { + "add": "Добавити", + "add_directory": "Добавити Папку", + "add_entity": "Добавити {entityType}", + "add_to_entity": "Добавити до {entityType}", + "allow": "Дозволити", + "allow_temporarily": "Дозволити Тимчасово", + "apply": "Застосувати", + "auto_tag": "Авто-тегування", + "backup": "Резервна Копія", + "browse_for_image": "Вибрати Картинки", + "cancel": "Відмінити", + "clean": "Почистити", + "clear": "Очистити", + "clear_back_image": "Очистити Фонову Картинку", + "clear_front_image": "Очистити Першу Картинку", + "clear_image": "Очистити Картинку", + "close": "Закрити", + "confirm": "Підтвердити", + "continue": "Продовжити", + "create": "Створити", + "create_entity": "Створити {entityType}", + "create_marker": "Створити Маркер", + "created_entity": "Створений {entity_type}:{entity_name}", + "customise": "Кастомізувати", + "delete": "Видалити", + "delete_entity": "Видалити {entityType}", + "delete_file": "Видалити Файл", + "delete_file_and_funscript": "Видалити Файл (і фанскрипт)", + "delete_generated_supporting_files": "Видалити згенеровані файли підтрикм", + "delete_stashid": "Видалити StashID", + "disallow": "Заборонити", + "download": "Завантажити", + "download_backup": "Завантажити Резервну Копію", + "edit": "Редагувати", + "edit_entity": "Редагувати {entityType}", + "export": "Експортувати…", + "export_all": "Експортувати Все…", + "find": "Знайти", + "finish": "Завершити", + "from_file": "З Файлу…", + "from_url": "З URL…", + "full_export": "Повний Експорт", + "full_import": "Повний Імпорт", + "generate": "Згенерувати", + "generate_thumb_default": "Згенерувати Стандартну Мініатюру", + "generate_thumb_from_current": "Згенерувати Мініатюрю з поточного", + "hash_migration": "Міграція Хешу", + "hide": "Заховати", + "hide_configuration": "Заховати Налаштування", + "identify": "Ідентифікувати", + "ignore": "Ігнорувати", + "import": "Імпортувати…", + "import_from_file": "Імпортувати З Файлу", + "logout": "Вилогінитися", + "make_primary": "Зробити Головним", + "merge": "Зкомбінувати", + "merge_from": "Зкомбінувати з", + "merge_into": "Зкомбінувати В", + "next_action": "Наступний", + "not_running": "не працює", + "open_in_external_player": "Відкрити В Зовнішньому Програвачі", + "open_random": "Відкрити Випадковий", + "overwrite": "Переписати Існуючий", + "play_random": "Програти Випадковий", + "play_selected": "Програти Вибраний", + "preview": "Прев‘ю", + "previous_action": "Назад", + "refresh": "Оновити", + "reload_plugins": "Перезавантажити Плагіни", + "reload_scrapers": "Перезавантажити Скрейпери", + "remove": "Видалити", + "remove_from_gallery": "Видалити З Галереї", + "rename_gen_files": "Перейменувати Згенеровані Файли", + "rescan": "Пересканувати", + "reshuffle": "Перемішати", + "running": "працює", + "save": "Зберегти", + "save_delete_settings": "Використати ці опції за замовчуванням, коли видаляєте", + "save_filter": "Зберегти Фільтр", + "scan": "Сканувати", + "scrape": "Скрейпити", + "scrape_query": "Скрейп-запит", + "scrape_scene_fragment": "Скрепнути по фрагменту", + "scrape_with": "Скрейпити за допомогою…", + "search": "Пошук", + "select_all": "Вибрати Всі", + "select_entity": "Вибрати {entityType}", + "select_folders": "Вибрати Папку", + "select_none": "Нічого Не Вибрати", + "selective_auto_tag": "Селективне Авто-Тегування", + "selective_clean": "Селективна Очистка", + "selective_scan": "Селективне Сканування", + "set_as_default": "Зробити За Замовчуванням", + "set_image": "Встановити зображення…", + "show": "Показати", + "show_configuration": "Показати Конфігурацію", + "skip": "Пропустити", + "stop": "Зупинити", + "submit": "Підтвердити", + "submit_update": "Підтвердити Оновлення", + "temp_disable": "Тимчасово відключити…", + "temp_enable": "Тимчасово включити…", + "use_default": "Використовувати за замовчуванням", + "view_random": "Дивитися випадкове" + }, + "actions_name": "Дії", + "age": "Вік", + "aliases": "Псевдоніми", + "all": "всі", + "also_known_as": "Також відомий, як", + "birth_year": "Рік народження", + "birthdate": "Дата народження", + "bitrate": "Бітрейт", + "component_tagger": { + "config": { + "active_instance": "Активувати екземпляр stash-box:", + "blacklist_label": "Чорний список", + "query_mode_auto": "Авто", + "query_mode_auto_desc": "Використовує мета-інформацію, якщо наявна, або ім'я файлу", + "query_mode_dir_desc": "Використовує лише батьківську директорію відео-файлу", + "query_mode_filename": "Ім'я файлу", + "query_mode_filename_desc": "Використовує лише ім'я файла", + "query_mode_metadata": "Мета-інформація", + "query_mode_metadata_desc": "Використовує лише мета-інформацію", + "query_mode_path": "Шлях", + "query_mode_path_desc": "Використовує повний путь до файлу", + "set_tag_label": "Встановити теги", + "source": "Джерело" + }, + "results": { + "duration_unknown": "Тривалість невідома", + "match_failed_no_result": "Результати відсутні" + } + }, + "config": { + "about": { + "check_for_new_version": "Перевірити на оновлення", + "latest_version": "Найновіша версія", + "stash_discord": "Приєднуйтесь до нашого {url} каналу", + "stash_open_collective": "Підтримати нас на {url}", + "version": "Версія" + }, + "categories": { + "changelog": "Список змін", + "interface": "Інтерфейс", + "logs": "Логи", + "metadata_providers": "Провайдери мета-інформації", + "plugins": "Плагіни", + "security": "Безпека", + "services": "Сервіси", + "system": "Система", + "tasks": "Задачі", + "tools": "Інструменти" + }, + "dlna": { + "allow_temp_ip": "Дозволити {tempIP}", + "allowed_ip_addresses": "Дозволені IP-адреси", + "allowed_ip_temporarily": "Тимчасово дозволені IP-адреси", + "default_ip_whitelist": "Білий список IP-адрес за замовчуванням", + "default_ip_whitelist_desc": "IP-адреси за замовчуванням мають доступ до DLNA. Використовуйте {wildcard} щоб дозволити усі IP-адреси", + "disallowed_ip": "Заборонені IP-адреси", + "enabled_by_default": "Вмикнено за замовчуванням", + "network_interfaces": "Інтерфейси", + "recent_ip_addresses": "Нещодавні IP-адреси", + "server_display_name_desc": "Відображуване ім'я DLNA-сервера. Якщо пусте, {server_name} буде використано за замовчуванням.", + "successfully_cancelled_temporary_behaviour": "Тимчасова поведінка успішно скасована", + "until_restart": "до рестарту" + }, + "general": { + "auth": { + "api_key": "API-ключ", + "api_key_desc": "API-ключ для зовнішніх систем. Потрібен лише якщо ім'я користувача/пароль налаштовані. Ім'я користувача має бути збережена перед генерацією API-ключа.", + "authentication": "Аутентифікація", + "clear_api_key": "Видалити API-ключ", + "generate_api_key": "Згенерувати API-ключ", + "log_file": "Файл логів", + "log_file_desc": "Шлях до файлу, в який будуть записуватись логи. Залишити пустим, щоб відключити логування в файл. Потребує перезапуску", + "log_http": "Логувати HTTP-доступ", + "log_http_desc": "Логувати HTTP-дії до терміналу. Потребує перезапуску.", + "log_to_terminal": "Логувати до терміналу", + "log_to_terminal_desc": "Логи в термінал в додаток до логування в файл. Завжди вмикнено якщо вимкнено логування до файлу. Потребує перезапуску.", + "maximum_session_age": "Максимальній вік сессії", + "password": "Пароль", + "stash-box_integration": "Інтеграція stash-box", + "username": "Ім'я користувача", + "username_desc": "Ім'я користувача для доступу до Stash. Залишіть пустим, щоб вимкнути аутентифікацію" + }, + "backup_directory_path": { + "description": "Директорія для зберігання резервних копій бази даних SQLite", + "heading": "Шлях до директорії з резервними копіями" + }, + "cache_location": "Шлях до директорії з кешем", + "cache_path_head": "Шлях кешу", + "calculate_md5_and_ohash_label": "Рахувати контрольну суму MD5 для відео", + "check_for_insecure_certificates": "Перевірити на ненадійні сертифікати", + "chrome_cdp_path": "Шлях до Chrome CDP", + "chrome_cdp_path_desc": "Шлях до виконуваного файлу Chrome, або видаленна адреса (починається з http:// або https://, наприклад, http://localhost:9222/json/version) до екземпляру Chrome", + "create_galleries_from_folders_desc": "Якщо так, то створює галереї з папок із зображеннями.", + "create_galleries_from_folders_label": "Створити галереї з папок із зображеннями", + "db_path_head": "Шлях до бази даних", + "excluded_image_gallery_patterns_head": "Виключені паттерни Зображень/Галерей", + "excluded_video_patterns_head": "Виключені паттерни для відео", + "gallery_ext_desc": "Розділений комою список розширень файлів, які можуть бути ідентифіковані, як ZIP-файли галерей", + "gallery_ext_head": "Розширення ZIP-файлів з галереями", + "generated_path_head": "Згенерований Шлях", + "image_ext_desc": "Розділений комою список розширень файлів, які можуть бути ідентифікованими, як зображення.", + "image_ext_head": "Розширення Зображень", + "include_audio_head": "Включити аудіо", + "metadata_path": { + "heading": "Шлях до мета-інформації" + }, + "number_of_parallel_task_for_scan_generation_head": "Кількість паралельних задач для сканування/генерації", + "parallel_scan_head": "Паралельне сканування/генерація", + "preview_generation": "Генерація прев'ю" + } + } +} diff --git a/ui/v2.5/src/locales/zh-CN.json b/ui/v2.5/src/locales/zh-CN.json index 791e098af..9683bce81 100644 --- a/ui/v2.5/src/locales/zh-CN.json +++ b/ui/v2.5/src/locales/zh-CN.json @@ -54,6 +54,7 @@ "import": "导入…", "import_from_file": "从文件导入", "logout": "注销", + "make_primary": "作为主要文件", "merge": "合并", "merge_from": "合并源", "merge_into": "合并入", @@ -66,6 +67,7 @@ "play_selected": "播放已选择的", "preview": "预览", "previous_action": "回去", + "reassign": "重新分配", "refresh": "刷新", "reload_plugins": "重新加载插件", "reload_scrapers": "重新加载网页挖掘器", @@ -98,10 +100,12 @@ "show": "展示", "show_configuration": "显示设定", "skip": "跳过", + "split": "分割", "stop": "停止", "submit": "提交", "submit_stash_box": "提交给 Stash-Box", "submit_update": "提交更新", + "swap": "交换", "tasks": { "clean_confirm_message": "确定要清除吗? 这将删除系统中不存在的所有短片和图库的数据库信息和已经生成的内容。", "dry_mode_selected": "已经选择了模拟删除模式。不会实际删除文件,只会写下记录。", @@ -120,6 +124,7 @@ "also_known_as": "又称作", "ascending": "升序", "average_resolution": "平均分辨率", + "between_and": "以及", "birth_year": "出生年份", "birthdate": "出生日期", "bitrate": "比特率", @@ -189,6 +194,7 @@ }, "categories": { "about": "关于", + "changelog": "更新历史", "interface": "界面", "logs": "日志", "metadata_providers": "元数据提供者", @@ -243,6 +249,10 @@ "username": "用户名", "username_desc": "登录 Stash 时所需的用户名.留空表示关闭身份验证" }, + "backup_directory_path": { + "description": "备份SQLite 数据库文件的目录路径", + "heading": "备份用的路径" + }, "cache_location": "缓存目录的位置", "cache_path_head": "缓存路径", "calculate_md5_and_ohash_desc": "除了快搜码外还计算 MD5 值。如果开启,初次扫描时速度会较慢。如果关闭 MD5 值计算,则必须将文件名识别码算法设置为快搜码.", @@ -344,7 +354,7 @@ "auto_tagging": "自动标签", "backing_up_database": "自动备份数据中", "backup_and_download": "备份数据库并下载其文件.", - "backup_database": "将数据库备份到与数据库相同的目录,文件名格式为 {filename_format}", + "backup_database": "将数据库备份到backups目录,文件名格式为 {filename_format}", "cleanup_desc": "检查缺失的文件并将它们的数据从数据库中删除。 注意,这是一个破坏性的动作。", "data_management": "数据管理", "defaults_set": "预设值已经设定好,将会在按下任务页面里的{action}按钮后生效.", @@ -420,12 +430,26 @@ "scene_tools": "短片工具" }, "ui": { + "abbreviate_counters": { + "description": "缩减信息牌和详情页面上的统计数字,比方说“1831”会改成“1.8K”。", + "heading": "缩减统计数字" + }, "basic_settings": "基本设定", "custom_css": { "description": "必须重新加载页面才能使更改生效。", "heading": "自定义样式", "option_label": "自定义样式已启用" }, + "custom_javascript": { + "description": "必须重新加载页面才能生效。", + "heading": "自定义 JavaScript", + "option_label": "自定义 JavaScript 已启用" + }, + "custom_locales": { + "description": "强制使用个别特定用语。看 https://github.com/stashapp/stash/blob/develop/ui/v2.5/src/locales/en-GB.json 作为主要参考列表。页面必须更新以看到更改效果。", + "heading": "自定义本地设定", + "option_label": "使用自定义本地设定" + }, "delete_options": { "description": "删除图片,图库和短片时的默认设置。", "heading": "删除选项", @@ -446,7 +470,24 @@ "description": "禁止下拉的选择器建立新的对象", "heading": "禁止下拉菜单建立" }, - "heading": "编辑" + "heading": "编辑", + "rating_system": { + "star_precision": { + "label": "评分星的精度", + "options": { + "full": "完整", + "half": "一半", + "quarter": "四分之一" + } + }, + "type": { + "label": "评分系统类型", + "options": { + "decimal": "十进制", + "stars": "星" + } + } + } }, "funscript_offset": { "description": "交互式脚本播放的时间偏移量(以毫秒为单位)。", @@ -490,6 +531,10 @@ "description": "在导航栏上显示或隐藏不同类型的内容", "heading": "菜单列表" }, + "minimum_play_percent": { + "description": "在播放量增加前短片必须要播放的百分比。", + "heading": "最少播放百分比" + }, "performers": { "options": { "image_location": { @@ -516,16 +561,18 @@ "scene_player": { "heading": "短片播放器", "options": { + "always_start_from_beginning": "视频一定从头开始播放", "auto_start_video": "自动播放", "auto_start_video_on_play_selected": { - "description": "从短片页面播放选择或随机视频时自动开始", + "description": "从视频序列,或从短片页面播放选择或随机视频时自动开始放视频", "heading": "当播放选择视频时自动开始" }, "continue_playlist_default": { "description": "当视频结束时播放下一短片", "heading": "默认继续播放清单" }, - "show_scrubber": "显示预览轴" + "show_scrubber": "显示预览轴", + "track_activity": "监测播放动作" } }, "scene_wall": { @@ -539,10 +586,32 @@ "description": "在转到下/上一项前需要尝试滑动的次数。仅适用于垂直滚动的模式。", "heading": "转变所需的滑动尝试次数" }, + "show_tag_card_on_hover": { + "description": "当鼠标位于标签徽章上时显示标签牌", + "heading": "标签牌的提示" + }, "slideshow_delay": { "description": "在影音墙模式下,图库可用幻灯片功能", "heading": "幻灯片延迟(秒)" }, + "studio_panel": { + "heading": "工作室显示", + "options": { + "show_child_studio_content": { + "description": "在工作室显示里,同时显示副工作室的内容", + "heading": "显示副工作室内容" + } + } + }, + "tag_panel": { + "heading": "标签显示", + "options": { + "show_child_tagged_content": { + "description": "在标签显示里,显示副标签的内容", + "heading": "显示副标签的内容" + } + } + }, "title": "用户界面" } }, @@ -587,20 +656,24 @@ "death_date": "去世日期", "death_year": "去世年份", "descending": "降序", + "description": "描述", "detail": "详情", "details": "简介", "developmentVersion": "开发版本", "dialogs": { "aliases_must_be_unique": "别名必须是唯一的", + "create_new_entity": "创建新的 {entity}", "delete_alert": "以下 {count, plural, one {{singularEntity}} other {{pluralEntity}}} 会被永久删除:", "delete_confirm": "确定要删除 {entityName} 吗?", "delete_entity_desc": "{count, plural, one {确定要删除{singularEntity}吗? 除非同时删除文件, 否则下次扫描时{singularEntity}会重新被添加到数据库中。} other {确定要删除{pluralEntity}吗? 除非同时删除文件, 否则下次扫描时{pluralEntity}会重新被添加到数据库中。}}", + "delete_entity_simple_desc": "{count, plural, one {你确定要删除这个 {singularEntity}?} other {你确定要删除这些 {pluralEntity}?}}", "delete_entity_title": "{count, plural, one {删除 {singularEntity}} other {删除 {pluralEntity}}}", "delete_galleries_extra": "...以及任何没有加入其它图库的图片.", "delete_gallery_files": "删除图库的文件夹/压缩包和任何没有加入其它图库的图片.", "delete_object_desc": "确定要删除{count, plural, one {这个{singularEntity}} other {这些{pluralEntity}}}?", "delete_object_overflow": "…以及 {count} 个其他 {count, plural, one {{singularEntity}} other {{pluralEntity}}}.", "delete_object_title": "删除 {count, plural, one {{singularEntity}} other {{pluralEntity}}}", + "dont_show_until_updated": "下次更新前不再提示", "edit_entity_title": "编辑 {count, plural, one {{singularEntity}} other {{pluralEntity}}}", "export_include_related_objects": "导出时包含相关的数据", "export_title": "导出", @@ -625,11 +698,20 @@ "zoom": "放大" } }, + "merge": { + "destination": "目标", + "empty_results": "目标字段值将不变。", + "source": "源" + }, "merge_tags": { "destination": "目标", "source": "源" }, "overwrite_filter_confirm": "确定要覆盖现有的已保存查询 {entityName} 吗?", + "reassign_entity_title": "{count, plural, one {Reassign {singularEntity}} 其它 {Reassign {pluralEntity}}}", + "reassign_files": { + "destination": "重新指定至" + }, "scene_gen": { "force_transcodes": "强制生成转码文件", "force_transcodes_tooltip": "默认情况下,转码文件只会在浏览器不支持的情况下生成。如果开启此选项,即使浏览器支持该视频,也会生成转码文件。", @@ -726,13 +808,16 @@ "false": "假", "favourite": "收藏", "file": "文件", + "file_count": "文件数量", "file_info": "文件信息", "file_mod_time": "文件修改时间", "files": "文件", + "files_amount": "{value} 个文件", "filesize": "文件大小", "filter": "过滤", "filter_name": "过滤器名称", "filters": "过滤器", + "folder": "文件夹", "framerate": "帧率", "frames_per_second": "{value} 帧每秒", "front_page": { @@ -765,6 +850,7 @@ }, "hasMarkers": "含有章节标记", "height": "身高", + "height_cm": "高(cm)", "help": "说明", "ignore_auto_tag": "忽略自动标签", "image": "图片", @@ -777,6 +863,7 @@ "interactive": "互动", "interactive_speed": "互动速度", "isMissing": "缺失", + "last_played_at": "最后播放在", "library": "收藏库", "loading": { "generic": "加载中…" @@ -795,6 +882,8 @@ "age_context": "短片里面 {age} {years_old}" }, "phash": "感知码PHash", + "play_count": "播放量", + "play_duration": "播放长度", "stream": "视频流地址", "video_codec": "视频编码" }, @@ -865,17 +954,26 @@ }, "performers": "演员", "piercings": "穿洞", + "play_count": "播放量", + "play_duration": "播放长度", + "primary_file": "主要文件", "queue": "序列", "random": "随机", "rating": "评分", "recently_added_objects": "最近新增的 {objects}", "recently_released_objects": "最近发行的 {objects}", + "release_notes": "更新历史", "resolution": "分辨率", + "resume_time": "恢复时间", "scene": "短片", "sceneTagger": "短片标记器", "sceneTags": "短片标记", + "scene_code": "工作室代码", "scene_count": "短片数量", + "scene_created_at": "短片建立在", + "scene_date": "短片日期", "scene_id": "短片ID", + "scene_updated_at": "短片修改在", "scenes": "短片", "scenes_updated_at": "短片更新时间", "search_filter": { @@ -919,9 +1017,10 @@ "migration_failed_error": "迁移数据库时遇到以下错误:", "migration_failed_help": "请进行必要的改正,再尝试。要不然,在{githubLink}提出这毛病,或者在{discordLink}寻求帮助。", "migration_irreversible_warning": "数据库结构迁移的过程是不可逆的。迁移完成后,你的数据库将会无法和更早版本的stash兼容。", + "migration_notes": "迁移历史", "migration_required": "需要进行数据库迁移", "perform_schema_migration": "进行数据库结构迁移", - "schema_too_old": "你当前的stash数据库结构是版本{databaseSchema},需要迁移到版本{appSchema}. 这版本的Stash无法在没有迁移的数据库上工作。" + "schema_too_old": "你当前的stash数据库结构是版本{databaseSchema},需要迁移到版本{appSchema}. 这版本的Stash无法在没有迁移的数据库上工作。如果你不希望进行数据迁移,那你必须使用旧版本以适应你的数据库结构。" }, "paths": { "database_filename_empty_for_default": "数据库文件名 (留空则用默认名)", @@ -966,6 +1065,7 @@ "welcome_to_stash": "欢迎使用Stash" }, "stash_id": "Stash 号", + "stash_id_endpoint": "Stash 号的终端", "stash_ids": "Stash号", "stashbox": { "go_review_draft": "去 {endpoint_name} 检阅草稿。", @@ -1001,7 +1101,9 @@ "default_filter_set": "默认过滤器", "delete_past_tense": "已经删除 {count, plural, one {{singularEntity}} other {{pluralEntity}}}", "generating_screenshot": "正在生成截图…", + "merged_scenes": "拼合的短片", "merged_tags": "已经合并标签", + "reassign_past_tense": "文件重新指定了", "removed_entity": "已移除 {count, plural, one {{singularEntity}} other {{pluralEntity}}}", "rescanning_entity": "正在重新扫描 {count, plural, one {{singularEntity}} other {{pluralEntity}}}…", "saved_entity": "已经保存 {entity}", @@ -1019,5 +1121,7 @@ "videos": "视频", "view_all": "查看全部", "weight": "体重", - "years_old": "岁" + "weight_kg": "重量(kg)", + "years_old": "岁", + "zip_file_count": "压缩文件数量" } diff --git a/ui/v2.5/src/locales/zh-TW.json b/ui/v2.5/src/locales/zh-TW.json index 5ab4a1d12..148d4d4c2 100644 --- a/ui/v2.5/src/locales/zh-TW.json +++ b/ui/v2.5/src/locales/zh-TW.json @@ -54,11 +54,12 @@ "import": "匯入…", "import_from_file": "自檔案匯入", "logout": "登出", + "make_primary": "設定為主檔案", "merge": "合併", "merge_from": "與其他項目合併", "merge_into": "合併至其他項目", "next_action": "下一步", - "not_running": "尚未運行", + "not_running": "尚未執行", "open_in_external_player": "透過外部播放器開啟", "open_random": "隨機開啟", "overwrite": "覆寫", @@ -67,14 +68,14 @@ "preview": "預覽", "previous_action": "上一步", "refresh": "重新整理", - "reload_plugins": "重新整理插件", + "reload_plugins": "重新整理外掛程式", "reload_scrapers": "重新整理爬蟲", "remove": "移除", "remove_from_gallery": "自圖庫中移除", "rename_gen_files": "重新命名已產生的檔案", "rescan": "重新掃描", "reshuffle": "重新隨機排列", - "running": "運行中", + "running": "執行中", "save": "儲存", "save_delete_settings": "當刪除項目時,使用下列設定", "save_filter": "儲存過濾條件", @@ -105,11 +106,11 @@ "tasks": { "clean_confirm_message": "您確定要進行清理嗎?這將從資料庫及產生的文件中清除已不在的短片及圖庫。", "dry_mode_selected": "已選擇了模擬作業模式。不會進行任何實際刪除作業,只會進行模擬記錄。", - "import_warning": "您確定要匯入本檔案嗎?這將刪除現有資料庫,並將已新匯入的內容重建資料。" + "import_warning": "您確定要匯入此檔案嗎?這將刪除現有資料庫,並將已新匯入的內容重建資料。" }, "temp_disable": "暫時關閉…", "temp_enable": "暫時啟用…", - "unset": "重置", + "unset": "重設", "use_default": "使用預設選項", "view_random": "隨機開啟" }, @@ -128,7 +129,7 @@ "component_tagger": { "config": { "active_instance": "目前使用的 Stash-box:", - "blacklist_desc": "搜尋資訊時,置於黑名單內的字串將被省略。請注意,由於黑名單使用常規式 (Regular expression) 的關係,搜尋字串皆不分大小寫,且使用下列字元需先使用反斜線逃脫該字元:{chars_require_escape}", + "blacklist_desc": "搜尋資訊時,置於黑名單內的字串將被省略。請注意,由於黑名單使用正規表示式,搜尋字串皆不分大小寫,且使用下列字元需先使用反斜線逸出該字元:{chars_require_escape}", "blacklist_label": "黑名單", "query_mode_auto": "自動", "query_mode_auto_desc": "以 Metadata 或檔案名稱為優先", @@ -142,7 +143,7 @@ "query_mode_path": "路徑名稱", "query_mode_path_desc": "使用整個檔案路徑名稱", "set_cover_desc": "選擇搜尋時,如果有找到封面照片時,是否要使用該圖片。", - "set_cover_label": "替換短片封面", + "set_cover_label": "設定短片封面", "set_tag_desc": "選擇套用標籤時,該如何處理現有標籤。", "set_tag_label": "標籤設定", "show_male_desc": "選擇搜尋時,是否要取得男優資訊。", @@ -160,7 +161,7 @@ "match_failed_already_tagged": "短片先前已標記完畢", "match_failed_no_result": "未找到結果", "match_success": "已成功標記短片", - "phash_matches": "{count} 個 PHash 匹配", + "phash_matches": "{count} 個 PHash 符合", "unnamed": "未命名" }, "verb_match_fp": "特徵碼辨別", @@ -189,10 +190,11 @@ }, "categories": { "about": "關於", + "changelog": "更新日誌", "interface": "介面", "logs": "日誌", "metadata_providers": "Metadata 來源", - "plugins": "插件", + "plugins": "外掛程式", "scraping": "爬蟲設定", "security": "安全性", "services": "服務", @@ -211,7 +213,7 @@ "enabled_by_default": "預設啟用", "enabled_dlna_temporarily": "已暫時開啟 DLNA 伺服器", "network_interfaces": "網路裝置", - "network_interfaces_desc": "選擇要在哪個網路裝置上開放 DLNA 連線。當本列表為空時,則會在所有網路裝置上聽取連線。需重啟。", + "network_interfaces_desc": "選擇要在哪個網路裝置上開放 DLNA 連線。當此列表為空時,則會在所有網路裝置上聽取連線。需重啟。", "recent_ip_addresses": "最近的 IP 位址", "server_display_name": "伺服器顯示名稱", "server_display_name_desc": "DLNA 伺服器的顯示名稱。如果為空,則預設為 {server_name}。", @@ -221,7 +223,7 @@ "general": { "auth": { "api_key": "API 金鑰", - "api_key_desc": "外部系統的 API 金鑰,有設定用戶名/密碼時才需要。在生成 API 金鑰之前必須先設定用戶名。", + "api_key_desc": "外部系統的 API 金鑰,有設定使用者名稱/密碼時才需要。在生成 API 金鑰之前必須先設定使用者名稱。", "authentication": "驗證設定", "clear_api_key": "清除 API 金鑰", "credentials": { @@ -236,12 +238,16 @@ "log_to_terminal": "紀錄日誌至終端機內", "log_to_terminal_desc": "除了記錄至檔案外,也記錄到終端機內;如果關閉日誌檔案記錄,則該選項始終為真。需重啟。", "maximum_session_age": "有效驗證時間", - "maximum_session_age_desc": "用戶閒置多久後登出,以秒為單位。", + "maximum_session_age_desc": "使用者閒置多久後登出,以秒為單位。", "password": "密碼", "password_desc": "使用 Stash 時所需的密碼,留空以關閉身份驗證", "stash-box_integration": "整合 Stash-box", - "username": "用戶名", - "username_desc": "使用 Stash 時所需的用戶名,留空以關閉身份驗證" + "username": "使用者名稱", + "username_desc": "使用 Stash 時所需的使用者名稱,留空以關閉身份驗證" + }, + "backup_directory_path": { + "description": "SQLite 資料庫備份的檔案位置", + "heading": "備份目錄位置" }, "cache_location": "快取的檔案位置", "cache_path_head": "快取路徑", @@ -255,47 +261,47 @@ "create_galleries_from_folders_label": "從包含圖片的資料夾建立圖庫", "db_path_head": "資料庫路徑", "directory_locations_to_your_content": "多媒體的檔案位置", - "excluded_image_gallery_patterns_desc": "要從掃描中排除,並會被『清理』功能所移除的圖片及圖庫檔案/路徑的正則表達式", + "excluded_image_gallery_patterns_desc": "要從掃描中排除,並會被『清理』功能所移除的圖片及圖庫檔案/路徑的正規表示式", "excluded_image_gallery_patterns_head": "圖片/圖庫排除規則", - "excluded_video_patterns_desc": "要從掃描中排除,並會被『清理』功能所移除的影片檔案/路徑的正則表達式", + "excluded_video_patterns_desc": "要從掃描中排除,並會被『清理』功能所移除的影片檔案/路徑的正規表示式", "excluded_video_patterns_head": "影片排除規則", - "gallery_ext_desc": "以逗號分隔的副檔名名稱,這些檔案將被視為圖庫或圖包。", + "gallery_ext_desc": "以逗號分隔的副檔名名稱,這些檔案將視為圖庫或圖包。", "gallery_ext_head": "圖庫 ZIP 檔副檔名", "generated_file_naming_hash_desc": "使用 MD5 或 oshash 生成檔案命名。更改此設定後,所有短片則須有先對應的 MD5/oshash 雜湊值。因此,之前已經生成的檔案可能需要重新遷移或重新生成。請參閱『遷移』設定。", "generated_file_naming_hash_head": "生成檔案名所使用的雜湊演算法", "generated_files_location": "生成文件的檔案位置(短片標記、短片預覽、預覽圖等)", "generated_path_head": "生成檔案儲存路徑", "hashing": "雜湊值設定", - "image_ext_desc": "以逗號分隔的副檔名名稱,這些檔案將被視為圖片。", + "image_ext_desc": "以逗號分隔的副檔名名稱,這些檔案將視為圖片。", "image_ext_head": "圖片副檔名", "include_audio_desc": "產生預覽檔案時,順便產生音訊預覽。", "include_audio_head": "包含音訊", "logging": "日誌設定", - "maximum_streaming_transcode_size_desc": "轉碼生成的串流最大大小", - "maximum_streaming_transcode_size_head": "最大的串流轉碼解析度大小", - "maximum_transcode_size_desc": "轉碼生成的影片最大大小", - "maximum_transcode_size_head": "最大的轉碼解析度大小", + "maximum_streaming_transcode_size_desc": "轉檔生成的串流最大大小", + "maximum_streaming_transcode_size_head": "最大的串流轉檔解析度大小", + "maximum_transcode_size_desc": "轉檔生成的影片最大大小", + "maximum_transcode_size_head": "最大的轉檔解析度大小", "metadata_path": { "description": "進行完整匯出或匯入時所使用的檔案位置", "heading": "Metadata 路徑" }, - "number_of_parallel_task_for_scan_generation_desc": "設置為 0 以自動偵測。請注意,運行比使用 100% CPU 使用率所需的排程數量,可能會降低性能並導致其他問題。", + "number_of_parallel_task_for_scan_generation_desc": "設定為 0 以自動偵測。請注意,執行比使用 100% CPU 使用率所需的排程數量,可能會降低性能並導致其他問題。", "number_of_parallel_task_for_scan_generation_head": "掃描/生成的並行排程數量", "parallel_scan_head": "並行掃描/生成", "preview_generation": "預覽檔案生成", "python_path": { - "description": "Python 應用程式的路徑。用於爬蟲及插件。留空時,python 則會於環境變數中的路徑取得", + "description": "Python 應用程式的路徑。用於爬蟲及外掛程式。留空時,python 則會於環境變數中的路徑取得", "heading": "Python 路徑" }, - "scraper_user_agent": "爬蟲用戶代理名稱 (User Agent)", - "scraper_user_agent_desc": "抓取 HTTP 資料時所用的用戶代理字串 (User-Agent)", + "scraper_user_agent": "爬蟲使用者代理名稱 (User Agent)", + "scraper_user_agent_desc": "抓取 HTTP 資料時所用的使用者代理字串 (User-Agent)", "scrapers_path": { "description": "含有爬蟲設定檔的資料夾位置", "heading": "爬蟲路徑" }, "scraping": "爬蟲設定", "sqlite_location": "SQLite 資料庫的位置(需重新啟動)", - "video_ext_desc": "以逗號分隔的副檔名名稱,這些檔案將被視為影片。", + "video_ext_desc": "以逗號分隔的副檔名名稱,這些檔案將視為影片。", "video_ext_head": "影片副檔名", "video_head": "影片設定" }, @@ -314,8 +320,8 @@ "scraping": { "entity_metadata": "{entityType}資訊", "entity_scrapers": "{entityType}爬蟲", - "excluded_tag_patterns_desc": "自爬蟲結果中,排除符合以下正則表達式的標籤", - "excluded_tag_patterns_head": "排除符合正則表達式的標籤", + "excluded_tag_patterns_desc": "自爬蟲結果中,排除符合以下正規表示式的標籤", + "excluded_tag_patterns_head": "排除符合正規表示式的標籤", "scraper": "爬蟲", "scrapers": "爬蟲", "search_by_name": "透過名稱搜尋", @@ -332,7 +338,7 @@ "title": "Stash-box 端點" }, "system": { - "transcoding": "轉碼" + "transcoding": "轉檔" }, "tasks": { "added_job_to_queue": "已將『{operation_name}』加入至工作排程", @@ -349,7 +355,7 @@ "data_management": "資料管理", "defaults_set": "已設定預設值;以後按下「{action}」按鈕時將會使用這些設定。", "dont_include_file_extension_as_part_of_the_title": "不要在標題中附上檔案副檔名", - "empty_queue": "目前尚無任何運行中的排程。", + "empty_queue": "尚無排程執行中。", "export_to_json": "將資料庫中的 Metadata 匯出為 JSON 檔。", "generate": { "generating_from_paths": "為以下路徑之短片生成檔案中", @@ -359,7 +365,7 @@ "generate_phashes_during_scan": "產生 PHash", "generate_phashes_during_scan_tooltip": "可用於辨認短片或偵測重複的短片。", "generate_previews_during_scan": "產生動態預覽圖", - "generate_previews_during_scan_tooltip": "產生 WebP 動圖作為預覽,僅適用於設置為『動圖』的預覽類型。", + "generate_previews_during_scan_tooltip": "產生 WebP 動圖作為預覽,僅適用於設定為『動圖』的預覽類型。", "generate_sprites_during_scan": "產生時間軸預覽", "generate_thumbnails_during_scan": "替圖片產生縮圖", "generate_video_previews_during_scan": "產生影片預覽", @@ -385,14 +391,14 @@ "sources": "來源", "strategy": "方法" }, - "import_from_exported_json": "匯入先前從 Metadata 資料夾中匯出的 JSON 檔。本動作將清除現有資料庫中的內容。", + "import_from_exported_json": "匯入先前從 Metadata 資料夾中匯出的 JSON 檔。此動作將清除現有資料庫中的內容。", "incremental_import": "從匯出 ZIP 檔進行增量匯入。", "job_queue": "工作排程", "maintenance": "維護", - "migrate_hash_files": "在更改生成的檔案命名雜湊以將現有生成的文件重命名為新的雜湊格式後使用。", + "migrate_hash_files": "在更改生成的檔案命名雜湊以將現有生成的文件重新命名為新的雜湊格式後使用。", "migrations": "遷移", "only_dry_run": "僅模擬作業,不要刪除任何東西", - "plugin_tasks": "插件排程", + "plugin_tasks": "外掛程式排程", "scan": { "scanning_all_paths": "掃描所有路徑中", "scanning_paths": "掃描以下路徑中" @@ -406,7 +412,7 @@ "add_field": "新增項目", "capitalize_title": "將標題改為大寫", "display_fields": "選擇顯示項目", - "escape_chars": "使用反斜線 (\\) 跳脫字元", + "escape_chars": "使用反斜線 (\\) 溢出字元", "filename": "檔案名稱", "filename_pattern": "檔案名稱規則", "ignore_organized": "忽略已整理的短片", @@ -415,16 +421,25 @@ "select_parser_recipe": "選擇預設分析字串", "title": "短片名稱分析工具", "whitespace_chars": "空白字元", - "whitespace_chars_desc": "這些字元將在標題中被的空格替換" + "whitespace_chars_desc": "這些字元將在標題中被空格取代" }, "scene_tools": "短片工具" }, "ui": { + "abbreviate_counters": { + "description": "簡化短片詳情中的點閱數(例:『1831』將會被簡化為『1.8K』)。", + "heading": "簡化數量" + }, "basic_settings": "一般設定", "custom_css": { "description": "如需套用,請重新整理頁面。", - "heading": "自定義 CSS", - "option_label": "啟用自定義 CSS" + "heading": "自訂 CSS", + "option_label": "啟用自訂 CSS" + }, + "custom_locales": { + "description": "強制使用特定翻譯字串。主列表請參閱 https://github.com/stashapp/stash/blob/develop/ui/v2.5/src/locales/en-GB.json。必須重新整理頁面才能使更改生效。", + "heading": "自訂翻譯", + "option_label": "啟用自訂翻譯" }, "delete_options": { "description": "刪除圖片、圖庫及短片時的預設設定。", @@ -437,8 +452,8 @@ "desktop_integration": { "desktop_integration": "桌面整合", "notifications_enabled": "開啟通知", - "send_desktop_notifications_for_events": "當事件發生時,傳送瀏覽器通知", - "skip_opening_browser": "關閉瀏覽器自動開啟", + "send_desktop_notifications_for_events": "當事件發生時發送通知", + "skip_opening_browser": "停用自動開啟瀏覽器", "skip_opening_browser_on_startup": "伺服器啟動時,不要自動開啟瀏覽器" }, "editing": { @@ -463,7 +478,7 @@ "sync": "同步" }, "handy_connection_key": { - "description": "播放支援互動性的短片時所用的 Handy 連線金鑰。設定此金鑰後,Stash 將可把當前短片中的對應資訊分享至 handyfeeling.com", + "description": "播放支援互動性的短片時所用的 Handy 連線金鑰。設定此金鑰後,Stash 將可把目前短片中的對應資訊分享至 handyfeeling.com", "heading": "Handy 連線金鑰" }, "image_lightbox": { @@ -518,7 +533,7 @@ "options": { "auto_start_video": "自動播放", "auto_start_video_on_play_selected": { - "description": "開啟所選短片或隨機播放時,自動開始播放影片", + "description": "開啟佇列中或所選短片、或隨機播放時,自動開始播放影片", "heading": "自動播放所選短片" }, "continue_playlist_default": { @@ -539,10 +554,32 @@ "description": "在移動到下一項/上一項之前嘗試滑動的次數。僅適用於『Y軸滑動』模式。", "heading": "場景變換滑動嘗試次數" }, + "show_tag_card_on_hover": { + "description": "游標移至標籤上時,顯示標籤頁卡", + "heading": "顯示標籤頁卡" + }, "slideshow_delay": { "description": "幻燈片功能僅適用於「圖庫」種類下的預覽牆模式", "heading": "幻燈片延遲 (秒)" }, + "studio_panel": { + "heading": "檢視工作室", + "options": { + "show_child_studio_content": { + "description": "於工作室頁面中,同時顯示子工作室內容", + "heading": "顯示子工作室內容" + } + } + }, + "tag_panel": { + "heading": "檢視標籤", + "options": { + "show_child_tagged_content": { + "description": "於標籤頁面中,同時顯示子標籤內容", + "heading": "顯示子標籤內容" + } + } + }, "title": "使用者介面" } }, @@ -569,17 +606,17 @@ "criterion_modifier": { "between": "與 ... 之間", "equals": "是", - "excludes": "屏除", + "excludes": "排除", "format_string": "{criterion}{modifierString}{valueString}", "greater_than": "大於", "includes": "包含", "includes_all": "所有包含", "is_null": "為空", "less_than": "小於", - "matches_regex": "符合正則表達式", + "matches_regex": "符合正規表示式", "not_between": "不與 ... 之間", "not_equals": "不是", - "not_matches_regex": "不符合正則表達式", + "not_matches_regex": "不符合正規表示式", "not_null": "不為空" }, "custom": "自訂", @@ -587,6 +624,7 @@ "death_date": "去世日期", "death_year": "去世年分", "descending": "降序", + "description": "敘述", "detail": "詳情", "details": "細節", "developmentVersion": "開發版本", @@ -595,12 +633,14 @@ "delete_alert": "以下{count, plural, one {{singularEntity}} other {{pluralEntity}}}將被永久刪除:", "delete_confirm": "你確定要刪除 {entityName} 嗎?", "delete_entity_desc": "{count, plural, one {你確定要刪除該{singularEntity}嗎?除非連同檔案一起刪除,否則,下次進行檔案掃描時,該{singularEntity}會被重新加到資料庫中。} other {你確定要刪除這些{pluralEntity}嗎?除非連同檔案一起刪除,否則,下次進行檔案掃描時,這些{pluralEntity}會被重新加到資料庫中。}}", + "delete_entity_simple_desc": "{count, plural, one {您確定要刪除此檔案嗎 {singularEntity}?} other {您確定要刪除這些檔案嗎 {pluralEntity}?}}", "delete_entity_title": "{count, plural, other {刪除{pluralEntity}}}", "delete_galleries_extra": "…及其他不在圖庫內的圖片檔案。", "delete_gallery_files": "刪除所有不在任一圖庫內的圖庫資料夾、壓縮檔及圖檔。", "delete_object_desc": "你確定要刪除{count, plural, =1 {這個{singularEntity}} other {這些{pluralEntity}}}嗎?", "delete_object_overflow": "…以及 {count} 個其他 {count, plural, =1 {{singularEntity}} other {{pluralEntity}}}。", "delete_object_title": "刪除{count, plural, =1 {{singularEntity}} other {{pluralEntity}}}", + "dont_show_until_updated": "下次更新前不顯示", "edit_entity_title": "編輯{pluralEntity}", "export_include_related_objects": "匯出所有相關物件", "export_title": "匯出", @@ -613,7 +653,7 @@ "original": "原始" }, "options": "選項", - "reset_zoom_on_nav": "當更換圖片時,重置縮放大小", + "reset_zoom_on_nav": "當更換圖片時,重設縮放大小", "scale_up": { "description": "將較小的圖片放大,以填滿整個畫面", "label": "縮放適應" @@ -631,15 +671,15 @@ }, "overwrite_filter_confirm": "您確定要覆蓋現有的條件 {entityName} 嗎?", "scene_gen": { - "force_transcodes": "強制產生轉碼檔案", - "force_transcodes_tooltip": "預設情況下,只有無法正常於瀏覽器中播放的影片檔會被轉碼成可播放的格式。開啟此設定後,即使是可正常播放的影片檔,也會被視為需轉碼的檔案。", + "force_transcodes": "強制產生轉檔檔案", + "force_transcodes_tooltip": "預設情況下,只有無法正常於瀏覽器中播放的影片檔會被轉檔成可播放的格式。開啟此設定後,即使是可正常播放的影片檔,也會被視為需轉檔的檔案。", "image_previews": "動圖預覽", - "image_previews_tooltip": "產生 WebP 動圖作為預覽,僅適用於設置為『動圖』的預覽類型。", + "image_previews_tooltip": "產生 WebP 動圖作為預覽,僅適用於設定為『動圖』的預覽類型。", "interactive_heatmap_speed": "替可互動的短片產生熱圖 (heatmaps) 及速度", "marker_image_previews": "章節動圖預覽", - "marker_image_previews_tooltip": "產生 WebP 動圖作為章節預覽,僅適用於設置為『動圖』的預覽類型。", + "marker_image_previews_tooltip": "產生 WebP 動圖作為章節預覽,僅適用於設定為『動圖』的預覽類型。", "marker_screenshots": "章節截圖", - "marker_screenshots_tooltip": "靜態 JPG 預覽,僅適用於設置為『靜態』的預覽類型。", + "marker_screenshots_tooltip": "靜態 JPG 預覽,僅適用於設定為『靜態』的預覽類型。", "markers": "章節預覽", "markers_tooltip": "自標記的時間點算起長度20秒的短片預覽。", "override_preview_generation_options": "強制使用所選的預覽產生設定", @@ -660,7 +700,7 @@ "preview_seg_duration_head": "預覽片段長度", "sprites": "時間軸預覽", "sprites_tooltip": "時間軸預覽(用於短片中時間軸的預覽圖)", - "transcodes": "轉碼", + "transcodes": "轉檔", "transcodes_tooltip": "將不支援的影片格式轉換成 MP4", "video_previews": "影片預覽", "video_previews_tooltip": "此預覽將於滑鼠移至影片上時自動播放" @@ -671,7 +711,7 @@ "scrape_results_existing": "現有資訊", "scrape_results_scraped": "爬取資訊", "set_image_url_title": "圖片連結", - "unsaved_changes": "尚有未保存的更改。你確定要離開嗎?" + "unsaved_changes": "尚有未儲存的更改。你確定要離開嗎?" }, "dimensions": "解析度", "director": "導演", @@ -684,7 +724,7 @@ }, "donate": "贊助", "dupe_check": { - "description": "低於“精確”的準確度可能需要更長的時間來計算。誤報也比較可能在較低的準確度級別上。", + "description": "低於「精確」的準確度可能需要更長的時間來計算。誤報也比較可能在較低的準確度級別上。", "found_sets": "{setCount, plural, other{找到 # 組相近的短片。}}", "options": { "exact": "精確", @@ -726,6 +766,7 @@ "false": "否", "favourite": "收藏", "file": "檔案", + "file_count": "檔案數量", "file_info": "檔案資訊", "file_mod_time": "檔案修改於", "files": "檔案", @@ -733,8 +774,15 @@ "filter": "過濾", "filter_name": "過濾條件名稱", "filters": "過濾條件", + "folder": "資料夾", "framerate": "幀率", "frames_per_second": "{value} 幀/秒", + "front_page": { + "types": { + "premade_filter": "預製過濾條件", + "saved_filter": "已儲存過濾條件" + } + }, "galleries": "圖庫", "gallery": "圖庫", "gallery_count": "圖庫數量", @@ -749,7 +797,7 @@ }, "hair_color": "頭髮顏色", "handy_connection_status": { - "connecting": "連接中", + "connecting": "連線中", "disconnected": "已斷線", "error": "連接至 Handy 時出錯", "missing": "遺失", @@ -855,15 +903,17 @@ "untagged_performers": "未標記的演員", "update_performer": "更新演員資料", "update_performers": "更新演員", - "updating_untagged_performers_description": "更新未標記的演員將試著把尚有 stashid 的演員在 Stash-Box 上找尋對應的資料,並將其資料加入至本地的 Metadata 中。" + "updating_untagged_performers_description": "更新未標記的演員將試著把尚有 stashid 的演員在 Stash-Box 上找尋對應的資料,並將其資料加入至本機的 Metadata 中。" }, "performers": "演員", "piercings": "穿洞", + "primary_file": "主檔案", "queue": "佇列", "random": "隨機", "rating": "評比", "recently_added_objects": "最近新增的{objects}", "recently_released_objects": "最近釋出的{objects}", + "release_notes": "更新日誌", "resolution": "解析度", "scene": "短片", "sceneTagger": "短片標籤器", @@ -913,20 +963,21 @@ "migration_failed_error": "遷移資料庫時遇到以下錯誤:", "migration_failed_help": "請進行所需的更正並重試。否則,請在 {githubLink} 上提出問題或在 {discordLink} 中尋求協助。", "migration_irreversible_warning": "架構遷移是不可逆的過程。遷移後,您的資料庫將無法與先前的 Stash 版本相容。", + "migration_notes": "遷移說明", "migration_required": "需要遷移", "perform_schema_migration": "執行架構遷移", - "schema_too_old": "您當前的資料庫版本為 {databaseSchema},需要遷移至版本 {appSchema}。如果不進行資料庫遷移,此版本的 Stash 將無法運行。" + "schema_too_old": "您目前的資料庫版本為 {databaseSchema},需要遷移至版本 {appSchema}。若不進行資料庫遷移,此版本的 Stash 將無法執行;若您仍不想進行資料庫遷移,您則需降級至與此資料庫版本相符的 Stash 版本。" }, "paths": { "database_filename_empty_for_default": "資料庫檔案名稱(留空以使用預設)", "description": "接下來,我們需要確定可以在哪裡找到你的片片,在哪裡儲存資料庫及其生成檔案等等。如果需要,您稍後可以再更改這些設定。", "path_to_generated_directory_empty_for_default": "生成媒體資料夾路徑(留空以使用預設)", - "set_up_your_paths": "設置你的路徑", + "set_up_your_paths": "設定你的路徑", "stash_alert": "您尚未選取任何路徑,Stash 將無法掃描你的檔案。你確定要繼續嗎?", "where_can_stash_store_its_database": "Stash 可以在哪裡儲存資料庫?", - "where_can_stash_store_its_database_description": "Stash 使用 SQLite 數據庫來儲存您片片的資料。預設情況下,Stash 將在您的設定檔路徑下以 stash-go.sqlite 這個檔案來儲存本資料庫內容。如果您想要更改此設定,請在此輸入您所想要的絕對或相對路徑(相對於當前工作目錄)。", + "where_can_stash_store_its_database_description": "Stash 使用 SQLite 資料庫來儲存您片片的資料。預設情況下,Stash 將在您的設定檔路徑下以 stash-go.sqlite 這個檔案來儲存此資料庫內容。如果您想要更改此設定,請在此輸入您所想要的絕對或相對路徑(相對於目前工作目錄)。", "where_can_stash_store_its_generated_content": "Stash 可以在哪裡儲存其生成內容?", - "where_can_stash_store_its_generated_content_description": "為提供縮略圖、預覽和其他預覽資料,Stash 將自動生成圖片和影片資訊。這包括不支援的檔案格式之轉碼。預設情況下,Stash 將在包含您設定檔案的資料夾中建立一個新的 generated 資料夾。如果要更改此生成媒體的儲存位置,請在此輸入絕對或相對路徑(相對於當前工作目錄)。如果該資料夾不存在,Stash 將自動建立此目錄。", + "where_can_stash_store_its_generated_content_description": "為提供縮圖、預覽和其他預覽資料,Stash 將自動生成圖片和影片資訊。這包括不支援的檔案格式之轉檔。預設情況下,Stash 將在包含您設定檔案的資料夾中建立一個新的 generated 資料夾。如果要更改此生成媒體的儲存位置,請在此輸入絕對或相對路徑(相對於目前工作目錄)。如果該資料夾不存在,Stash 將自動建立此目錄。", "where_is_your_porn_located": "你的片片都藏哪?", "where_is_your_porn_located_description": "在此選擇你A片及圖片的資料夾,Stash 將在掃描影片及圖片時使用這些路徑。" }, @@ -935,13 +986,13 @@ "getting_help": "尋求協助", "help_links": "如果您有任何問題或建議,請隨時在 {githubLink} 中建立新的議題(Issue),或在 {discordLink} 中詢求協助。", "in_app_manual_explained": "我們鼓勵您查看本程式內建的說明手冊,您可在本程式的右上角的圖案中開啟此手冊,此圖案如下:{icon}", - "next_config_step_one": "接下來您將被帶到設定頁面。此頁面將允許您自訂要包含和排除的文件,設定用戶名和密碼以保護您的系統,以及一大堆其他選項。", + "next_config_step_one": "接下來您將被帶到設定頁面。此頁面將允許您自訂要包含和排除的文件,設定使用者名稱和密碼以保護您的系統,以及一大堆其他選項。", "next_config_step_two": "當您對這些設定感到滿意時,您可以點選 {localized_task} 開始將您的內容掃描到 Stash,然後點擊 {localized_scan}。", "open_collective": "您可查看我們的 {open_collective_link},了解您可以如何為 Stash 的持續發展做出貢獻。", "support_us": "支持我們", "thanks_for_trying_stash": "感謝您使用 Stash!", - "welcome_contrib": "我們也歡迎以程式碼(錯誤修復、改進和新功能實作)、測試、錯誤報告、改進和功能請求以及用戶支援的形式做出貢獻。詳情請見程式內說明的 Contribution(貢獻)頁面。", - "your_system_has_been_created": "成功!您的系統已完成安裝!" + "welcome_contrib": "我們也歡迎以程式碼(錯誤修復、改進和新功能實作)、測試、錯誤報告、改進和功能請求以及使用者支援的形式做出貢獻。詳情請見程式內說明的 Contribution(貢獻)頁面。", + "your_system_has_been_created": "成功!您的系統已安裝完成!" }, "welcome": { "config_path_logic_explained": "Stash 於執行時,會先在執行目錄中找尋其設定檔案 (config.yml),當找不到時,它將會再試著使用 $HOME/.stash/config.yml(於 Windows 中,此路徑為 %USERPROFILE%\\.stash\\config.yml)。您也可在執行 Stash 時透過 -c <設定檔路徑> 提供設定路徑,或者 --config 。", @@ -996,6 +1047,7 @@ "delete_past_tense": "已刪除{singularEntity}", "generating_screenshot": "產生截圖中…", "merged_tags": "已合併的標籤", + "removed_entity": "已刪除{singularEntity}", "rescanning_entity": "重新掃描{singularEntity}中…", "saved_entity": "已儲存{entity}", "started_auto_tagging": "自動套用標籤中", @@ -1010,7 +1062,8 @@ "updated_at": "更新於", "url": "連結", "videos": "影片", - "view_all": "檢視所有", + "view_all": "顯示全部", "weight": "體重", - "years_old": "歲" + "years_old": "歲", + "zip_file_count": "壓縮檔內容數量" } diff --git a/ui/v2.5/src/models/list-filter/criteria/country.ts b/ui/v2.5/src/models/list-filter/criteria/country.ts index b861b0474..56b67f6bb 100644 --- a/ui/v2.5/src/models/list-filter/criteria/country.ts +++ b/ui/v2.5/src/models/list-filter/criteria/country.ts @@ -1,3 +1,6 @@ +import { IntlShape } from "react-intl"; +import { CriterionModifier } from "src/core/generated-graphql"; +import { getCountryByISO } from "src/utils"; import { StringCriterion, StringCriterionOption } from "./criterion"; const countryCriterionOption = new StringCriterionOption( @@ -10,4 +13,15 @@ export class CountryCriterion extends StringCriterion { constructor() { super(countryCriterionOption); } + + public getLabelValue(intl: IntlShape) { + if ( + this.modifier === CriterionModifier.Equals || + this.modifier === CriterionModifier.NotEquals + ) { + return getCountryByISO(this.value, intl.locale) ?? this.value; + } + + return super.getLabelValue(intl); + } } diff --git a/ui/v2.5/src/models/list-filter/criteria/criterion.ts b/ui/v2.5/src/models/list-filter/criteria/criterion.ts index 7597874a1..60d245f62 100644 --- a/ui/v2.5/src/models/list-filter/criteria/criterion.ts +++ b/ui/v2.5/src/models/list-filter/criteria/criterion.ts @@ -1,4 +1,5 @@ /* eslint-disable consistent-return */ +/* eslint @typescript-eslint/no-unused-vars: ["error", { "argsIgnorePattern": "^_" }] */ import { IntlShape } from "react-intl"; import { @@ -7,6 +8,8 @@ import { IntCriterionInput, MultiCriterionInput, PHashDuplicationCriterionInput, + DateCriterionInput, + TimestampCriterionInput, } from "src/core/generated-graphql"; import DurationUtils from "src/utils/duration"; import { @@ -16,6 +19,9 @@ import { ILabeledValue, INumberValue, IOptionType, + IStashIDValue, + IDateValue, + ITimestampValue, } from "../types"; export type Option = string | number | IOptionType; @@ -23,7 +29,10 @@ export type CriterionValue = | string | ILabeledId[] | IHierarchicalLabelValue - | INumberValue; + | INumberValue + | IStashIDValue + | IDateValue + | ITimestampValue; const modifierMessageIDs = { [CriterionModifier.Equals]: "criterion_modifier.equals", @@ -61,7 +70,7 @@ export abstract class Criterion { this._value = newValue; } - public abstract getLabelValue(): string; + public abstract getLabelValue(intl: IntlShape): string; constructor(type: CriterionOption, value: V) { this.criterionOption = type; @@ -85,7 +94,7 @@ export abstract class Criterion { this.modifier !== CriterionModifier.IsNull && this.modifier !== CriterionModifier.NotNull ) { - valueString = this.getLabelValue(); + valueString = this.getLabelValue(intl); } return intl.formatMessage( @@ -215,7 +224,7 @@ export class StringCriterion extends Criterion { super(type, ""); } - public getLabelValue() { + public getLabelValue(_intl: IntlShape) { return this.value; } } @@ -317,10 +326,36 @@ export class NumberCriterionOption extends CriterionOption { } } +export class NullNumberCriterionOption extends CriterionOption { + constructor(messageID: string, value: CriterionType, parameterName?: string) { + super({ + messageID, + type: value, + parameterName, + modifierOptions: [ + CriterionModifier.Equals, + CriterionModifier.NotEquals, + CriterionModifier.GreaterThan, + CriterionModifier.LessThan, + CriterionModifier.Between, + CriterionModifier.NotBetween, + CriterionModifier.IsNull, + CriterionModifier.NotNull, + ], + defaultModifier: CriterionModifier.Equals, + inputType: "number", + }); + } +} + export function createNumberCriterionOption(value: CriterionType) { return new NumberCriterionOption(value, value, value); } +export function createNullNumberCriterionOption(value: CriterionType) { + return new NullNumberCriterionOption(value, value, value); +} + export class NumberCriterion extends Criterion { public get value(): INumberValue { return this._value; @@ -345,7 +380,7 @@ export class NumberCriterion extends Criterion { }; } - public getLabelValue() { + public getLabelValue(_intl: IntlShape) { const { value, value2 } = this.value; if ( this.modifier === CriterionModifier.Between || @@ -393,7 +428,7 @@ export class ILabeledIdCriterionOption extends CriterionOption { } export class ILabeledIdCriterion extends Criterion { - public getLabelValue(): string { + public getLabelValue(_intl: IntlShape): string { return this.value.map((v) => v.label).join(", "); } @@ -418,7 +453,7 @@ export class IHierarchicalLabeledIdCriterion extends Criterion v.label).join(", "); if (this.value.depth === 0) { @@ -478,7 +513,7 @@ export class DurationCriterion extends Criterion { }; } - public getLabelValue() { + public getLabelValue(_intl: IntlShape) { return this.modifier === CriterionModifier.Between || this.modifier === CriterionModifier.NotBetween ? `${DurationUtils.secondsToString( @@ -500,3 +535,162 @@ export class PhashDuplicateCriterion extends StringCriterion { }; } } + +export class DateCriterionOption extends CriterionOption { + constructor( + messageID: string, + value: CriterionType, + parameterName?: string, + options?: Option[] + ) { + super({ + messageID, + type: value, + parameterName, + modifierOptions: [ + CriterionModifier.Equals, + CriterionModifier.NotEquals, + CriterionModifier.GreaterThan, + CriterionModifier.LessThan, + CriterionModifier.IsNull, + CriterionModifier.NotNull, + CriterionModifier.Between, + CriterionModifier.NotBetween, + ], + defaultModifier: CriterionModifier.Equals, + options, + inputType: "text", + }); + } +} + +export function createDateCriterionOption(value: CriterionType) { + return new DateCriterionOption(value, value, value); +} + +export class DateCriterion extends Criterion { + public encodeValue() { + return { + value: this.value.value, + value2: this.value.value2, + }; + } + + protected toCriterionInput(): DateCriterionInput { + return { + modifier: this.modifier, + value: this.value.value, + value2: this.value.value2, + }; + } + + public getLabelValue() { + const { value } = this.value; + return this.modifier === CriterionModifier.Between || + this.modifier === CriterionModifier.NotBetween + ? `${value}, ${this.value.value2}` + : `${value}`; + } + + constructor(type: CriterionOption) { + super(type, { value: "", value2: undefined }); + } +} + +export class TimestampCriterionOption extends CriterionOption { + constructor( + messageID: string, + value: CriterionType, + parameterName?: string, + options?: Option[] + ) { + super({ + messageID, + type: value, + parameterName, + modifierOptions: [ + CriterionModifier.GreaterThan, + CriterionModifier.LessThan, + CriterionModifier.IsNull, + CriterionModifier.NotNull, + CriterionModifier.Between, + CriterionModifier.NotBetween, + ], + defaultModifier: CriterionModifier.GreaterThan, + options, + inputType: "text", + }); + } +} + +export function createTimestampCriterionOption(value: CriterionType) { + return new TimestampCriterionOption(value, value, value); +} + +export class TimestampCriterion extends Criterion { + public encodeValue() { + return { + value: this.value.value, + value2: this.value.value2, + }; + } + + protected toCriterionInput(): TimestampCriterionInput { + return { + modifier: this.modifier, + value: this.transformValueToInput(this.value.value), + value2: this.value.value2 + ? this.transformValueToInput(this.value.value2) + : null, + }; + } + + public getLabelValue() { + const { value } = this.value; + return this.modifier === CriterionModifier.Between || + this.modifier === CriterionModifier.NotBetween + ? `${value}, ${this.value.value2}` + : `${value}`; + } + + private transformValueToInput(value: string): string { + value = value.trim(); + if (/^\d{4}-\d{2}-\d{2}(( |T)\d{2}:\d{2})?$/.test(value)) { + return value.replace(" ", "T"); + } + + return ""; + } + + constructor(type: CriterionOption) { + super(type, { value: "", value2: undefined }); + } +} + +export class MandatoryTimestampCriterionOption extends CriterionOption { + constructor( + messageID: string, + value: CriterionType, + parameterName?: string, + options?: Option[] + ) { + super({ + messageID, + type: value, + parameterName, + modifierOptions: [ + CriterionModifier.GreaterThan, + CriterionModifier.LessThan, + CriterionModifier.Between, + CriterionModifier.NotBetween, + ], + defaultModifier: CriterionModifier.GreaterThan, + options, + inputType: "text", + }); + } +} + +export function createMandatoryTimestampCriterionOption(value: CriterionType) { + return new MandatoryTimestampCriterionOption(value, value, value); +} diff --git a/ui/v2.5/src/models/list-filter/criteria/factory.ts b/ui/v2.5/src/models/list-filter/criteria/factory.ts index 55d3a3991..bbc870543 100644 --- a/ui/v2.5/src/models/list-filter/criteria/factory.ts +++ b/ui/v2.5/src/models/list-filter/criteria/factory.ts @@ -5,11 +5,16 @@ import { DurationCriterion, NumberCriterionOption, MandatoryStringCriterionOption, + NullNumberCriterionOption, MandatoryNumberCriterionOption, StringCriterionOption, ILabeledIdCriterion, BooleanCriterion, BooleanCriterionOption, + DateCriterion, + DateCriterionOption, + TimestampCriterion, + MandatoryTimestampCriterionOption, } from "./criterion"; import { OrganizedCriterion } from "./organized"; import { FavoriteCriterion, PerformerFavoriteCriterion } from "./favorite"; @@ -41,11 +46,19 @@ import { MoviesCriterionOption } from "./movies"; import { GalleriesCriterion } from "./galleries"; import { CriterionType } from "../types"; import { InteractiveCriterion } from "./interactive"; -import { RatingCriterionOption } from "./rating"; import { DuplicatedCriterion, PhashCriterionOption } from "./phash"; import { CaptionCriterion } from "./captions"; +import { RatingCriterion } from "./rating"; +import { CountryCriterion } from "./country"; +import { StashIDCriterion } from "./stash-ids"; +import * as GQL from "src/core/generated-graphql"; +import { IUIConfig } from "src/core/config"; +import { defaultRatingSystemOptions } from "src/utils/rating"; -export function makeCriteria(type: CriterionType = "none") { +export function makeCriteria( + config: GQL.ConfigDataFragment | undefined, + type: CriterionType = "none" +) { switch (type) { case "none": return new NoneCriterion(); @@ -62,8 +75,6 @@ export function makeCriteria(type: CriterionType = "none") { return new StringCriterion( new MandatoryStringCriterionOption("media_info.hash", type, type) ); - case "rating": - return new NumberCriterion(RatingCriterionOption); case "organized": return new OrganizedCriterion(); case "o_counter": @@ -76,14 +87,25 @@ export function makeCriteria(type: CriterionType = "none") { case "performer_age": case "tag_count": case "file_count": + case "play_count": return new NumberCriterion( new MandatoryNumberCriterionOption(type, type) ); + case "rating": + return new NumberCriterion(new NullNumberCriterionOption(type, type)); + case "rating100": + return new RatingCriterion( + new NullNumberCriterionOption("rating", type), + (config?.ui as IUIConfig)?.ratingSystemOptions ?? + defaultRatingSystemOptions + ); case "resolution": return new ResolutionCriterion(); case "average_resolution": return new AverageResolutionCriterion(); + case "resume_time": case "duration": + case "play_duration": return new DurationCriterion(new NumberCriterionOption(type, type)); case "favorite": return new FavoriteCriterion(); @@ -144,11 +166,20 @@ export function makeCriteria(type: CriterionType = "none") { return new StringCriterion(PhashCriterionOption); case "duplicated": return new DuplicatedCriterion(); - case "ethnicity": case "country": + return new CountryCriterion(); + case "height": + case "height_cm": + return new NumberCriterion( + new NumberCriterionOption("height", "height_cm", type) + ); + // stash_id is deprecated + case "stash_id": + case "stash_id_endpoint": + return new StashIDCriterion(); + case "ethnicity": case "hair_color": case "eye_color": - case "height": case "measurements": case "fake_tits": case "career_length": @@ -156,12 +187,14 @@ export function makeCriteria(type: CriterionType = "none") { case "piercings": case "aliases": case "url": - case "stash_id": case "details": case "title": case "director": case "synopsis": + case "description": return new StringCriterion(new StringCriterionOption(type, type)); + case "scene_code": + return new StringCriterion(new StringCriterionOption(type, type, "code")); case "interactive": return new InteractiveCriterion(); case "captions": @@ -184,5 +217,17 @@ export function makeCriteria(type: CriterionType = "none") { ); case "ignore_auto_tag": return new BooleanCriterion(new BooleanCriterionOption(type, type)); + case "date": + case "birthdate": + case "death_date": + case "scene_date": + return new DateCriterion(new DateCriterionOption(type, type)); + case "created_at": + case "updated_at": + case "scene_created_at": + case "scene_updated_at": + return new TimestampCriterion( + new MandatoryTimestampCriterionOption(type, type) + ); } } diff --git a/ui/v2.5/src/models/list-filter/criteria/rating.ts b/ui/v2.5/src/models/list-filter/criteria/rating.ts index d9aa8e89f..ffacdaf3f 100644 --- a/ui/v2.5/src/models/list-filter/criteria/rating.ts +++ b/ui/v2.5/src/models/list-filter/criteria/rating.ts @@ -1,8 +1,57 @@ -import { NumberCriterionOption } from "./criterion"; +import { + convertFromRatingFormat, + convertToRatingFormat, + RatingSystemOptions, +} from "src/utils/rating"; +import { + CriterionModifier, + IntCriterionInput, +} from "../../../core/generated-graphql"; +import { INumberValue } from "../types"; +import { Criterion, CriterionOption } from "./criterion"; -export const RatingCriterionOption = new NumberCriterionOption( - "rating", - "rating", - "rating", - [1, 2, 3, 4, 5] -); +export class RatingCriterion extends Criterion { + ratingSystem: RatingSystemOptions; + + public get value(): INumberValue { + return this._value; + } + public set value(newValue: number | INumberValue) { + // backwards compatibility - if this.value is a number, use that + if (typeof newValue !== "object") { + this._value = { + value: convertFromRatingFormat(newValue, this.ratingSystem.type), + value2: undefined, + }; + } else { + this._value = newValue; + } + } + + protected toCriterionInput(): IntCriterionInput { + return { + modifier: this.modifier, + value: this.value.value, + value2: this.value.value2, + }; + } + + public getLabelValue() { + const { value, value2 } = this.value; + if ( + this.modifier === CriterionModifier.Between || + this.modifier === CriterionModifier.NotBetween + ) { + return `${convertToRatingFormat(value, this.ratingSystem) ?? 0}, ${ + convertToRatingFormat(value2, this.ratingSystem) ?? 0 + }`; + } else { + return `${convertToRatingFormat(value, this.ratingSystem) ?? 0}`; + } + } + + constructor(type: CriterionOption, ratingSystem: RatingSystemOptions) { + super(type, { value: 0, value2: undefined }); + this.ratingSystem = ratingSystem; + } +} diff --git a/ui/v2.5/src/models/list-filter/criteria/stash-ids.ts b/ui/v2.5/src/models/list-filter/criteria/stash-ids.ts new file mode 100644 index 000000000..6467c50ea --- /dev/null +++ b/ui/v2.5/src/models/list-filter/criteria/stash-ids.ts @@ -0,0 +1,106 @@ +/* eslint @typescript-eslint/no-unused-vars: ["error", { "argsIgnorePattern": "^_" }] */ +import { IntlShape } from "react-intl"; +import { + CriterionModifier, + StashIdCriterionInput, +} from "src/core/generated-graphql"; +import { IStashIDValue } from "../types"; +import { Criterion, CriterionOption } from "./criterion"; + +export const StashIDCriterionOption = new CriterionOption({ + messageID: "stash_id", + type: "stash_id_endpoint", + parameterName: "stash_id_endpoint", + modifierOptions: [ + CriterionModifier.Equals, + CriterionModifier.NotEquals, + CriterionModifier.IsNull, + CriterionModifier.NotNull, + ], +}); + +export class StashIDCriterion extends Criterion { + constructor() { + super(StashIDCriterionOption, { + endpoint: "", + stashID: "", + }); + } + + public get value(): IStashIDValue { + return this._value; + } + + public set value(newValue: string | IStashIDValue) { + // backwards compatibility - if this.value is a string, use that as stash_id + if (typeof newValue !== "object") { + this._value = { + endpoint: "", + stashID: newValue, + }; + } else { + this._value = newValue; + } + } + + protected toCriterionInput(): StashIdCriterionInput { + return { + endpoint: this.value.endpoint, + stash_id: this.value.stashID, + modifier: this.modifier, + }; + } + + public getLabel(intl: IntlShape): string { + const modifierString = Criterion.getModifierLabel(intl, this.modifier); + let valueString = ""; + + if ( + this.modifier !== CriterionModifier.IsNull && + this.modifier !== CriterionModifier.NotNull + ) { + valueString = this.getLabelValue(intl); + } else if (this.value.endpoint) { + valueString = "(" + this.value.endpoint + ")"; + } + + return intl.formatMessage( + { id: "criterion_modifier.format_string" }, + { + criterion: intl.formatMessage({ id: this.criterionOption.messageID }), + modifierString, + valueString, + } + ); + } + + public getLabelValue(_intl: IntlShape) { + let ret = this.value.stashID; + if (this.value.endpoint) { + ret += " (" + this.value.endpoint + ")"; + } + + return ret; + } + + public toJSON() { + let encodedCriterion; + if ( + (this.modifier === CriterionModifier.IsNull || + this.modifier === CriterionModifier.NotNull) && + !this.value.endpoint + ) { + encodedCriterion = { + type: this.criterionOption.type, + modifier: this.modifier, + }; + } else { + encodedCriterion = { + type: this.criterionOption.type, + value: this.value, + modifier: this.modifier, + }; + } + return JSON.stringify(encodedCriterion); + } +} diff --git a/ui/v2.5/src/models/list-filter/filter.ts b/ui/v2.5/src/models/list-filter/filter.ts index 114e04594..0a938eb07 100644 --- a/ui/v2.5/src/models/list-filter/filter.ts +++ b/ui/v2.5/src/models/list-filter/filter.ts @@ -1,6 +1,7 @@ import queryString, { ParsedQuery } from "query-string"; import clone from "lodash-es/clone"; import { + ConfigDataFragment, FilterMode, FindFilterType, SortDirectionEnum, @@ -30,6 +31,7 @@ const DEFAULT_PARAMS = { // TODO: handle customCriteria export class ListFilterModel { public mode: FilterMode; + private config: ConfigDataFragment | undefined; public searchTerm?: string; public currentPage = DEFAULT_PARAMS.currentPage; public itemsPerPage = DEFAULT_PARAMS.itemsPerPage; @@ -43,11 +45,13 @@ export class ListFilterModel { public constructor( mode: FilterMode, + config: ConfigDataFragment | undefined, defaultSort?: string, defaultDisplayMode?: DisplayMode, defaultZoomIndex?: number ) { this.mode = mode; + this.config = config; this.sortBy = defaultSort; if (defaultDisplayMode !== undefined) this.displayMode = defaultDisplayMode; if (defaultZoomIndex !== undefined) { @@ -57,7 +61,7 @@ export class ListFilterModel { } public clone() { - return Object.assign(new ListFilterModel(this.mode), this); + return Object.assign(new ListFilterModel(this.mode, this.config), this); } // Does not decode any URL-encoding in parameters @@ -78,12 +82,12 @@ export class ListFilterModel { } } } - if (params.sortdir !== undefined) { - this.sortDirection = - params.sortdir === "desc" - ? SortDirectionEnum.Desc - : SortDirectionEnum.Asc; - } + // #3193 - sortdir undefined means asc + this.sortDirection = + params.sortdir === "desc" + ? SortDirectionEnum.Desc + : SortDirectionEnum.Asc; + if (params.disp !== undefined) { this.displayMode = Number.parseInt(params.disp, 10); } @@ -104,7 +108,7 @@ export class ListFilterModel { params.c.forEach((jsonString) => { try { const encodedCriterion = JSON.parse(jsonString); - const criterion = makeCriteria(encodedCriterion.type); + const criterion = makeCriteria(this.config, encodedCriterion.type); // it's possible that we have unsupported criteria. Just skip if so. if (criterion) { if (encodedCriterion.value !== undefined) { @@ -145,18 +149,69 @@ export class ListFilterModel { jsonParameters = [params.c!]; } params.c = jsonParameters.map((jsonString) => { - let decodedJson = jsonString; - // replace () back to {} - decodedJson = decodedJson.replaceAll("(", "{"); - decodedJson = decodedJson.replaceAll(")", "}"); - // decode all other characters - decodedJson = decodeURIComponent(decodedJson); - return decodedJson; + const decoding = true; + return ListFilterModel.translateSpecialCharacters( + decodeURIComponent(jsonString), + decoding + ); }); } return params; } + private static translateSpecialCharacters(input: string, decoding: boolean) { + let inString = false; + let escape = false; + return [...input] + .map((c) => { + if (escape) { + // this character has been escaped, skip + escape = false; + return c; + } + + switch (c) { + case "\\": + // escape the next character if in a string + if (inString) { + escape = true; + } + break; + case '"': + // unescaped quote, toggle inString + inString = !inString; + break; + case "(": + // decode only: restore ( to { if not in a string + if (decoding && !inString) { + return "{"; + } + break; + case ")": + // decode only: restore ) to } if not in a string + if (decoding && !inString) { + return "}"; + } + break; + case "{": + // encode only: replace { with ( if not in a string + if (!decoding && !inString) { + return "("; + } + break; + case "}": + // encode only: replace } with ) if not in a string + if (!decoding && !inString) { + return ")"; + } + break; + } + + return c; + }) + .join(""); + } + public configureFromQueryString(query: string) { const parsed = queryString.parse(query, { decode: false }); const decoded = ListFilterModel.decodeQueryParameters(parsed); @@ -192,15 +247,15 @@ export class ListFilterModel { // Returns query parameters with necessary parts encoded public getQueryParameters(): IQueryParameters { const encodedCriteria: string[] = this.criteria.map((criterion) => { - let str = criterion.toJSON(); + const decoding = false; + let str = ListFilterModel.translateSpecialCharacters( + criterion.toJSON(), + decoding + ); + // URL-encode other characters str = encodeURI(str); - // force URL-encode existing () - str = str.replaceAll("(", "%28"); - str = str.replaceAll(")", "%29"); - // replace JSON '{'(%7B) '}'(%7D) with explicitly unreserved () - str = str.replaceAll("%7B", "("); - str = str.replaceAll("%7D", ")"); + // only the reserved characters ?#&;=+ need to be URL-encoded // as they have special meaning in query strings str = str.replaceAll("?", encodeURIComponent("?")); @@ -209,6 +264,7 @@ export class ListFilterModel { str = str.replaceAll(";", encodeURIComponent(";")); str = str.replaceAll("=", encodeURIComponent("=")); str = str.replaceAll("+", encodeURIComponent("+")); + return str; }); diff --git a/ui/v2.5/src/models/list-filter/galleries.ts b/ui/v2.5/src/models/list-filter/galleries.ts index d3b3cd332..3f1e7aa7e 100644 --- a/ui/v2.5/src/models/list-filter/galleries.ts +++ b/ui/v2.5/src/models/list-filter/galleries.ts @@ -1,12 +1,14 @@ import { createMandatoryNumberCriterionOption, createStringCriterionOption, + NullNumberCriterionOption, + createDateCriterionOption, + createMandatoryTimestampCriterionOption, } from "./criteria/criterion"; import { PerformerFavoriteCriterionOption } from "./criteria/favorite"; import { GalleryIsMissingCriterionOption } from "./criteria/is-missing"; import { OrganizedCriterionOption } from "./criteria/organized"; import { PerformersCriterionOption } from "./criteria/performers"; -import { RatingCriterionOption } from "./criteria/rating"; import { AverageResolutionCriterionOption } from "./criteria/resolution"; import { StudiosCriterionOption } from "./criteria/studios"; import { @@ -46,7 +48,7 @@ const criterionOptions = [ "media_info.checksum", "checksum" ), - RatingCriterionOption, + new NullNumberCriterionOption("rating", "rating100"), OrganizedCriterionOption, AverageResolutionCriterionOption, GalleryIsMissingCriterionOption, @@ -61,6 +63,9 @@ const criterionOptions = [ StudiosCriterionOption, createStringCriterionOption("url"), createMandatoryNumberCriterionOption("file_count", "zip_file_count"), + createDateCriterionOption("date"), + createMandatoryTimestampCriterionOption("created_at"), + createMandatoryTimestampCriterionOption("updated_at"), ]; export const GalleryListFilterOptions = new ListFilterOptions( diff --git a/ui/v2.5/src/models/list-filter/images.ts b/ui/v2.5/src/models/list-filter/images.ts index 5102e72c5..72c9e30d7 100644 --- a/ui/v2.5/src/models/list-filter/images.ts +++ b/ui/v2.5/src/models/list-filter/images.ts @@ -2,12 +2,13 @@ import { createMandatoryNumberCriterionOption, createMandatoryStringCriterionOption, createStringCriterionOption, + NullNumberCriterionOption, + createMandatoryTimestampCriterionOption, } from "./criteria/criterion"; import { PerformerFavoriteCriterionOption } from "./criteria/favorite"; import { ImageIsMissingCriterionOption } from "./criteria/is-missing"; import { OrganizedCriterionOption } from "./criteria/organized"; import { PerformersCriterionOption } from "./criteria/performers"; -import { RatingCriterionOption } from "./criteria/rating"; import { ResolutionCriterionOption } from "./criteria/resolution"; import { StudiosCriterionOption } from "./criteria/studios"; import { @@ -31,20 +32,21 @@ const criterionOptions = [ createStringCriterionOption("title"), createMandatoryStringCriterionOption("checksum", "media_info.checksum"), createMandatoryStringCriterionOption("path"), - RatingCriterionOption, OrganizedCriterionOption, createMandatoryNumberCriterionOption("o_counter"), ResolutionCriterionOption, ImageIsMissingCriterionOption, TagsCriterionOption, + new NullNumberCriterionOption("rating", "rating100"), createMandatoryNumberCriterionOption("tag_count"), PerformerTagsCriterionOption, PerformersCriterionOption, createMandatoryNumberCriterionOption("performer_count"), - createMandatoryNumberCriterionOption("performer_age"), PerformerFavoriteCriterionOption, StudiosCriterionOption, createMandatoryNumberCriterionOption("file_count"), + createMandatoryTimestampCriterionOption("created_at"), + createMandatoryTimestampCriterionOption("updated_at"), ]; export const ImageListFilterOptions = new ListFilterOptions( defaultSortBy, diff --git a/ui/v2.5/src/models/list-filter/movies.ts b/ui/v2.5/src/models/list-filter/movies.ts index dcf495f76..be327abb4 100644 --- a/ui/v2.5/src/models/list-filter/movies.ts +++ b/ui/v2.5/src/models/list-filter/movies.ts @@ -1,9 +1,11 @@ import { createMandatoryNumberCriterionOption, createStringCriterionOption, + NullNumberCriterionOption, + createDateCriterionOption, + createMandatoryTimestampCriterionOption, } from "./criteria/criterion"; import { MovieIsMissingCriterionOption } from "./criteria/is-missing"; -import { RatingCriterionOption } from "./criteria/rating"; import { StudiosCriterionOption } from "./criteria/studios"; import { PerformersCriterionOption } from "./criteria/performers"; import { ListFilterOptions } from "./filter-options"; @@ -28,8 +30,11 @@ const criterionOptions = [ createStringCriterionOption("director"), createStringCriterionOption("synopsis"), createMandatoryNumberCriterionOption("duration"), - RatingCriterionOption, + new NullNumberCriterionOption("rating", "rating100"), PerformersCriterionOption, + createDateCriterionOption("date"), + createMandatoryTimestampCriterionOption("created_at"), + createMandatoryTimestampCriterionOption("updated_at"), ]; export const MovieListFilterOptions = new ListFilterOptions( diff --git a/ui/v2.5/src/models/list-filter/performers.ts b/ui/v2.5/src/models/list-filter/performers.ts index 2792997a0..30d1d7316 100644 --- a/ui/v2.5/src/models/list-filter/performers.ts +++ b/ui/v2.5/src/models/list-filter/performers.ts @@ -3,11 +3,15 @@ import { createMandatoryNumberCriterionOption, createStringCriterionOption, createBooleanCriterionOption, + createDateCriterionOption, + createMandatoryTimestampCriterionOption, + NumberCriterionOption, + NullNumberCriterionOption, } from "./criteria/criterion"; import { FavoriteCriterionOption } from "./criteria/favorite"; import { GenderCriterionOption } from "./criteria/gender"; import { PerformerIsMissingCriterionOption } from "./criteria/is-missing"; -import { RatingCriterionOption } from "./criteria/rating"; +import { StashIDCriterionOption } from "./criteria/stash-ids"; import { StudiosCriterionOption } from "./criteria/studios"; import { TagsCriterionOption } from "./criteria/tags"; import { ListFilterOptions } from "./filter-options"; @@ -58,14 +62,12 @@ const stringCriteria: CriterionType[] = [ "country", "hair_color", "eye_color", - "height", "measurements", "fake_tits", "career_length", "tattoos", "piercings", "aliases", - "stash_id", ]; const criterionOptions = [ @@ -73,16 +75,22 @@ const criterionOptions = [ GenderCriterionOption, PerformerIsMissingCriterionOption, TagsCriterionOption, - RatingCriterionOption, StudiosCriterionOption, + StashIDCriterionOption, createStringCriterionOption("url"), + new NullNumberCriterionOption("rating", "rating100"), createMandatoryNumberCriterionOption("tag_count"), createMandatoryNumberCriterionOption("scene_count"), createMandatoryNumberCriterionOption("image_count"), createMandatoryNumberCriterionOption("gallery_count"), createBooleanCriterionOption("ignore_auto_tag"), + new NumberCriterionOption("height", "height_cm", "height_cm"), ...numberCriteria.map((c) => createNumberCriterionOption(c)), ...stringCriteria.map((c) => createStringCriterionOption(c)), + createDateCriterionOption("birthdate"), + createDateCriterionOption("death_date"), + createMandatoryTimestampCriterionOption("created_at"), + createMandatoryTimestampCriterionOption("updated_at"), ]; export const PerformerListFilterOptions = new ListFilterOptions( defaultSortBy, diff --git a/ui/v2.5/src/models/list-filter/scene-markers.ts b/ui/v2.5/src/models/list-filter/scene-markers.ts index 0080e017e..3de42b2a1 100644 --- a/ui/v2.5/src/models/list-filter/scene-markers.ts +++ b/ui/v2.5/src/models/list-filter/scene-markers.ts @@ -2,6 +2,10 @@ import { PerformersCriterionOption } from "./criteria/performers"; import { SceneTagsCriterionOption, TagsCriterionOption } from "./criteria/tags"; import { ListFilterOptions } from "./filter-options"; import { DisplayMode } from "./types"; +import { + createDateCriterionOption, + createMandatoryTimestampCriterionOption, +} from "./criteria/criterion"; const defaultSortBy = "title"; const sortByOptions = [ @@ -16,6 +20,11 @@ const criterionOptions = [ TagsCriterionOption, SceneTagsCriterionOption, PerformersCriterionOption, + createMandatoryTimestampCriterionOption("created_at"), + createMandatoryTimestampCriterionOption("updated_at"), + createDateCriterionOption("scene_date"), + createMandatoryTimestampCriterionOption("scene_created_at"), + createMandatoryTimestampCriterionOption("scene_updated_at"), ]; export const SceneMarkerListFilterOptions = new ListFilterOptions( diff --git a/ui/v2.5/src/models/list-filter/scenes.ts b/ui/v2.5/src/models/list-filter/scenes.ts index 485f31e8c..b894628d8 100644 --- a/ui/v2.5/src/models/list-filter/scenes.ts +++ b/ui/v2.5/src/models/list-filter/scenes.ts @@ -2,13 +2,15 @@ import { createMandatoryNumberCriterionOption, createMandatoryStringCriterionOption, createStringCriterionOption, + NullNumberCriterionOption, + createDateCriterionOption, + createMandatoryTimestampCriterionOption, } from "./criteria/criterion"; import { HasMarkersCriterionOption } from "./criteria/has-markers"; import { SceneIsMissingCriterionOption } from "./criteria/is-missing"; import { MoviesCriterionOption } from "./criteria/movies"; import { OrganizedCriterionOption } from "./criteria/organized"; import { PerformersCriterionOption } from "./criteria/performers"; -import { RatingCriterionOption } from "./criteria/rating"; import { ResolutionCriterionOption } from "./criteria/resolution"; import { StudiosCriterionOption } from "./criteria/studios"; import { InteractiveCriterionOption } from "./criteria/interactive"; @@ -24,6 +26,7 @@ import { } from "./criteria/phash"; import { PerformerFavoriteCriterionOption } from "./criteria/favorite"; import { CaptionsCriterionOption } from "./criteria/captions"; +import { StashIDCriterionOption } from "./criteria/stash-ids"; const defaultSortBy = "date"; const sortByOptions = [ @@ -35,6 +38,10 @@ const sortByOptions = [ "duration", "framerate", "bitrate", + "last_played_at", + "resume_time", + "play_duration", + "play_count", "movie_scene_number", "interactive", "interactive_speed", @@ -51,8 +58,10 @@ const displayModeOptions = [ const criterionOptions = [ createStringCriterionOption("title"), + createStringCriterionOption("scene_code"), createMandatoryStringCriterionOption("path"), createStringCriterionOption("details"), + createStringCriterionOption("director"), createMandatoryStringCriterionOption("oshash", "media_info.hash"), createStringCriterionOption( "sceneChecksum", @@ -61,11 +70,14 @@ const criterionOptions = [ ), PhashCriterionOption, DuplicatedCriterionOption, - RatingCriterionOption, OrganizedCriterionOption, + new NullNumberCriterionOption("rating", "rating100"), createMandatoryNumberCriterionOption("o_counter"), ResolutionCriterionOption, createMandatoryNumberCriterionOption("duration"), + createMandatoryNumberCriterionOption("resume_time"), + createMandatoryNumberCriterionOption("play_duration"), + createMandatoryNumberCriterionOption("play_count"), HasMarkersCriterionOption, SceneIsMissingCriterionOption, TagsCriterionOption, @@ -78,11 +90,14 @@ const criterionOptions = [ StudiosCriterionOption, MoviesCriterionOption, createStringCriterionOption("url"), - createStringCriterionOption("stash_id"), + StashIDCriterionOption, InteractiveCriterionOption, CaptionsCriterionOption, createMandatoryNumberCriterionOption("interactive_speed"), createMandatoryNumberCriterionOption("file_count"), + createDateCriterionOption("date"), + createMandatoryTimestampCriterionOption("created_at"), + createMandatoryTimestampCriterionOption("updated_at"), ]; export const SceneListFilterOptions = new ListFilterOptions( diff --git a/ui/v2.5/src/models/list-filter/studios.ts b/ui/v2.5/src/models/list-filter/studios.ts index f693572c1..89b26af7c 100644 --- a/ui/v2.5/src/models/list-filter/studios.ts +++ b/ui/v2.5/src/models/list-filter/studios.ts @@ -3,9 +3,11 @@ import { createMandatoryNumberCriterionOption, createMandatoryStringCriterionOption, createStringCriterionOption, + NullNumberCriterionOption, + createMandatoryTimestampCriterionOption, } from "./criteria/criterion"; import { StudioIsMissingCriterionOption } from "./criteria/is-missing"; -import { RatingCriterionOption } from "./criteria/rating"; +import { StashIDCriterionOption } from "./criteria/stash-ids"; import { ParentStudiosCriterionOption } from "./criteria/studios"; import { ListFilterOptions } from "./filter-options"; import { DisplayMode } from "./types"; @@ -34,14 +36,16 @@ const criterionOptions = [ createStringCriterionOption("details"), ParentStudiosCriterionOption, StudioIsMissingCriterionOption, - RatingCriterionOption, + new NullNumberCriterionOption("rating", "rating100"), createBooleanCriterionOption("ignore_auto_tag"), createMandatoryNumberCriterionOption("scene_count"), createMandatoryNumberCriterionOption("image_count"), createMandatoryNumberCriterionOption("gallery_count"), createStringCriterionOption("url"), - createStringCriterionOption("stash_id"), + StashIDCriterionOption, createStringCriterionOption("aliases"), + createMandatoryTimestampCriterionOption("created_at"), + createMandatoryTimestampCriterionOption("updated_at"), ]; export const StudioListFilterOptions = new ListFilterOptions( diff --git a/ui/v2.5/src/models/list-filter/tags.ts b/ui/v2.5/src/models/list-filter/tags.ts index 7ab794509..8e90a27e7 100644 --- a/ui/v2.5/src/models/list-filter/tags.ts +++ b/ui/v2.5/src/models/list-filter/tags.ts @@ -4,6 +4,7 @@ import { createMandatoryStringCriterionOption, createStringCriterionOption, MandatoryNumberCriterionOption, + createMandatoryTimestampCriterionOption, } from "./criteria/criterion"; import { TagIsMissingCriterionOption } from "./criteria/is-missing"; import { ListFilterOptions } from "./filter-options"; @@ -44,6 +45,7 @@ const criterionOptions = [ createMandatoryStringCriterionOption("name"), TagIsMissingCriterionOption, createStringCriterionOption("aliases"), + createStringCriterionOption("description"), createBooleanCriterionOption("ignore_auto_tag"), createMandatoryNumberCriterionOption("scene_count"), createMandatoryNumberCriterionOption("image_count"), @@ -62,6 +64,8 @@ const criterionOptions = [ "child_tag_count", "child_count" ), + createMandatoryTimestampCriterionOption("created_at"), + createMandatoryTimestampCriterionOption("updated_at"), ]; export const TagListFilterOptions = new ListFilterOptions( diff --git a/ui/v2.5/src/models/list-filter/types.ts b/ui/v2.5/src/models/list-filter/types.ts index 46313a314..eb671d37e 100644 --- a/ui/v2.5/src/models/list-filter/types.ts +++ b/ui/v2.5/src/models/list-filter/types.ts @@ -33,6 +33,21 @@ export interface IPHashDuplicationValue { distance?: number; // currently not implemented } +export interface IStashIDValue { + endpoint: string; + stashID: string; +} + +export interface IDateValue { + value: string; + value2: string | undefined; +} + +export interface ITimestampValue { + value: string; + value2: string | undefined; +} + export function criterionIsHierarchicalLabelValue( // eslint-disable-next-line @typescript-eslint/no-explicit-any value: any @@ -47,6 +62,27 @@ export function criterionIsNumberValue( return typeof value === "object" && "value" in value && "value2" in value; } +export function criterionIsStashIDValue( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + value: any +): value is IStashIDValue { + return typeof value === "object" && "endpoint" in value && "stashID" in value; +} + +export function criterionIsDateValue( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + value: any +): value is IDateValue { + return typeof value === "object" && "value" in value && "value2" in value; +} + +export function criterionIsTimestampValue( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + value: any +): value is ITimestampValue { + return typeof value === "object" && "value" in value && "value2" in value; +} + export interface IOptionType { id: string; name?: string; @@ -57,6 +93,7 @@ export type CriterionType = | "none" | "path" | "rating" + | "rating100" | "organized" | "o_counter" | "resolution" @@ -88,6 +125,7 @@ export type CriterionType = | "hair_color" | "eye_color" | "height" + | "height_cm" | "weight" | "measurements" | "fake_tits" @@ -108,6 +146,9 @@ export type CriterionType = | "interactive" | "interactive_speed" | "captions" + | "resume_time" + | "play_count" + | "play_duration" | "name" | "details" | "title" @@ -124,4 +165,15 @@ export type CriterionType = | "performer_age" | "duplicated" | "ignore_auto_tag" - | "file_count"; + | "file_count" + | "stash_id_endpoint" + | "date" + | "created_at" + | "updated_at" + | "birthdate" + | "death_date" + | "scene_date" + | "scene_created_at" + | "scene_updated_at" + | "description" + | "scene_code"; diff --git a/ui/v2.5/src/models/react-jw-player.d.ts b/ui/v2.5/src/models/react-jw-player.d.ts deleted file mode 100644 index db9b06cb2..000000000 --- a/ui/v2.5/src/models/react-jw-player.d.ts +++ /dev/null @@ -1,5 +0,0 @@ -declare module "react-jw-player" { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const ReactJSPlayer: any; - export default ReactJSPlayer; -} diff --git a/ui/v2.5/src/models/sceneQueue.ts b/ui/v2.5/src/models/sceneQueue.ts index 199218e33..a43e547ad 100644 --- a/ui/v2.5/src/models/sceneQueue.ts +++ b/ui/v2.5/src/models/sceneQueue.ts @@ -1,5 +1,4 @@ -import queryString from "query-string"; -import { RouteComponentProps } from "react-router-dom"; +import queryString, { ParsedQuery } from "query-string"; import { FilterMode, Scene } from "src/core/generated-graphql"; import { ListFilterModel } from "./list-filter/filter"; import { SceneListFilterOptions } from "./list-filter/scenes"; @@ -77,49 +76,45 @@ export class SceneQueue { return ""; } - public static fromQueryParameters(params: string) { + public static fromQueryParameters(params: ParsedQuery) { const ret = new SceneQueue(); - const parsed = queryString.parse(params, { decode: false }); const translated = { - sortby: parsed.qsort, - sortdir: parsed.qsortd, - q: parsed.qfq, - p: parsed.qfp, - c: parsed.qfc, + sortby: params.qsort, + sortdir: params.qsortd, + q: params.qfq, + p: params.qfp, + c: params.qfc, }; - if (parsed.qfp) { + if (params.qfp) { const decoded = ListFilterModel.decodeQueryParameters(translated); const query = new ListFilterModel( FilterMode.Scenes, + undefined, SceneListFilterOptions.defaultSortBy ); query.configureFromQueryParameters(decoded); ret.query = query; - } else if (parsed.qs) { + } else if (params.qs) { // must be scene list - ret.sceneIDs = Array.isArray(parsed.qs) - ? parsed.qs.map((v) => Number(v)) - : [Number(parsed.qs)]; + ret.sceneIDs = Array.isArray(params.qs) + ? params.qs.map((v) => Number(v)) + : [Number(params.qs)]; } return ret; } - public playScene( - history: RouteComponentProps["history"], - sceneID: string, - options?: IPlaySceneOptions - ) { - history.replace(this.makeLink(sceneID, options)); - } - - public makeLink(sceneID: string, options?: IPlaySceneOptions) { - const params = [ - this.makeQueryParameters(options?.sceneIndex, options?.newPage), - options?.autoPlay ? "autoplay=true" : "", - options?.continue ? "continue=true" : "", - ].filter((param) => !!param); + public makeLink(sceneID: string, options: IPlaySceneOptions) { + let params = [ + this.makeQueryParameters(options.sceneIndex, options.newPage), + ]; + if (options.autoPlay !== undefined) { + params.push("autoplay=" + options.autoPlay); + } + if (options.continue !== undefined) { + params.push("continue=" + options.continue); + } return `/scenes/${sceneID}${params.length ? "?" + params.join("&") : ""}`; } } diff --git a/ui/v2.5/src/polyfills.ts b/ui/v2.5/src/polyfills.ts index 7f6bb6f1f..b232bc907 100644 --- a/ui/v2.5/src/polyfills.ts +++ b/ui/v2.5/src/polyfills.ts @@ -24,6 +24,11 @@ async function checkPolyfills() { await import("@formatjs/intl-pluralrules/polyfill"); await import("@formatjs/intl-pluralrules/locale-data/en"); } + + if (!("ResizeObserver" in window)) { + const ResizeObserver = await import("resize-observer-polyfill"); + window.ResizeObserver = ResizeObserver.default; + } } export const initPolyfills = async () => { diff --git a/ui/v2.5/src/utils/bulkUpdate.ts b/ui/v2.5/src/utils/bulkUpdate.ts index 9ffba6796..e56b58375 100644 --- a/ui/v2.5/src/utils/bulkUpdate.ts +++ b/ui/v2.5/src/utils/bulkUpdate.ts @@ -2,7 +2,7 @@ import * as GQL from "src/core/generated-graphql"; import isEqual from "lodash-es/isEqual"; interface IHasRating { - rating?: GQL.Maybe | undefined; + rating100?: GQL.Maybe | undefined; } export function getAggregateRating(state: IHasRating[]) { @@ -11,9 +11,9 @@ export function getAggregateRating(state: IHasRating[]) { state.forEach((o) => { if (first) { - ret = o.rating ?? undefined; + ret = o.rating100 ?? undefined; first = false; - } else if (ret !== o.rating) { + } else if (ret !== o.rating100) { ret = undefined; } }); diff --git a/ui/v2.5/src/utils/country.ts b/ui/v2.5/src/utils/country.ts index 68a087c10..b1cb2c692 100644 --- a/ui/v2.5/src/utils/country.ts +++ b/ui/v2.5/src/utils/country.ts @@ -1,41 +1,32 @@ import Countries from "i18n-iso-countries"; -import english from "i18n-iso-countries/langs/en.json"; +import { getLocaleCode } from "src/locales"; -Countries.registerLocale(english); +export const getCountryByISO = ( + iso: string | null | undefined, + locale: string = "en" +): string | undefined => { + if (!iso) return; -const fuzzyDict: Record = { - USA: "US", - "United States": "US", - America: "US", - American: "US", - Czechia: "CZ", - England: "GB", - "United Kingdom": "GB", - Russia: "RU", - "Slovak Republic": "SK", - Iran: "IR", - Moldova: "MD", - Laos: "LA", + const ret = Countries.getName(iso, getLocaleCode(locale)); + if (ret) { + return ret; + } + + // fallback to english if locale is not en + if (locale !== "en") { + return Countries.getName(iso, "en"); + } }; -const getISOCountry = (country: string | null | undefined) => { - if (!country) return null; +export const getCountries = (locale: string = "en") => { + let countries = Countries.getNames(getLocaleCode(locale)); - const code = - fuzzyDict[country] ?? Countries.getAlpha2Code(country, "en") ?? country; - // Check if code is valid alpha2 iso - if (!Countries.alpha2ToAlpha3(code)) return null; + if (!countries.length) { + countries = Countries.getNames("en"); + } - return { - code, - name: Countries.getName(code, "en"), - }; + return Object.entries(countries).map(([code, name]) => ({ + label: name, + value: code, + })); }; - -export const getCountryByISO = (iso: string | null | undefined) => { - if (!iso) return null; - - return Countries.getName(iso, "en") ?? null; -}; - -export default getISOCountry; diff --git a/ui/v2.5/src/utils/editabletext.tsx b/ui/v2.5/src/utils/editabletext.tsx index 6c4ee3cd1..43d52ec5f 100644 --- a/ui/v2.5/src/utils/editabletext.tsx +++ b/ui/v2.5/src/utils/editabletext.tsx @@ -1,6 +1,7 @@ import React from "react"; import { Form } from "react-bootstrap"; -import { FilterSelect, DurationInput } from "src/components/Shared"; +import { DurationInput } from "src/components/Shared/DurationInput"; +import { FilterSelect } from "src/components/Shared"; import { DurationUtils } from "."; const renderTextArea = (options: { diff --git a/ui/v2.5/src/utils/image.tsx b/ui/v2.5/src/utils/image.tsx index 3bbdaecc2..484c5d055 100644 --- a/ui/v2.5/src/utils/image.tsx +++ b/ui/v2.5/src/utils/image.tsx @@ -53,8 +53,22 @@ const usePasteImage = ( return false; }; +const imageToDataURL = async (url: string) => { + const response = await fetch(url); + const blob = await response.blob(); + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onloadend = () => { + resolve(reader.result as string); + }; + reader.onerror = reject; + reader.readAsDataURL(blob); + }); +}; + const Image = { onImageChange, usePasteImage, + imageToDataURL, }; export default Image; diff --git a/ui/v2.5/src/utils/index.ts b/ui/v2.5/src/utils/index.ts index 262c020e9..4ed80cc3e 100644 --- a/ui/v2.5/src/utils/index.ts +++ b/ui/v2.5/src/utils/index.ts @@ -6,11 +6,13 @@ export { default as TextUtils } from "./text"; export { default as EditableTextUtils } from "./editabletext"; export { default as FormUtils } from "./form"; export { default as DurationUtils } from "./duration"; +export { default as PercentUtils } from "./percent"; export { default as SessionUtils } from "./session"; export { default as flattenMessages } from "./flattenMessages"; -export { default as getISOCountry } from "./country"; +export * from "./country"; export { default as useFocus } from "./focus"; export { default as downloadFile } from "./download"; export * from "./data"; export { getStashIDs } from "./stashIds"; export * from "./stashbox"; +export * from "./gender"; diff --git a/ui/v2.5/src/utils/navigation.ts b/ui/v2.5/src/utils/navigation.ts index adfd01ada..bbdd34ef8 100644 --- a/ui/v2.5/src/utils/navigation.ts +++ b/ui/v2.5/src/utils/navigation.ts @@ -34,7 +34,7 @@ const makePerformerScenesUrl = ( extraCriteria?: Criterion[] ) => { if (!performer.id) return "#"; - const filter = new ListFilterModel(GQL.FilterMode.Scenes); + const filter = new ListFilterModel(GQL.FilterMode.Scenes, undefined); const criterion = new PerformersCriterion(); criterion.value = [ { id: performer.id, label: performer.name || `Performer ${performer.id}` }, @@ -49,7 +49,7 @@ const makePerformerImagesUrl = ( extraCriteria?: Criterion[] ) => { if (!performer.id) return "#"; - const filter = new ListFilterModel(GQL.FilterMode.Images); + const filter = new ListFilterModel(GQL.FilterMode.Images, undefined); const criterion = new PerformersCriterion(); criterion.value = [ { id: performer.id, label: performer.name || `Performer ${performer.id}` }, @@ -64,7 +64,7 @@ const makePerformerGalleriesUrl = ( extraCriteria?: Criterion[] ) => { if (!performer.id) return "#"; - const filter = new ListFilterModel(GQL.FilterMode.Galleries); + const filter = new ListFilterModel(GQL.FilterMode.Galleries, undefined); const criterion = new PerformersCriterion(); criterion.value = [ { id: performer.id, label: performer.name || `Performer ${performer.id}` }, @@ -79,7 +79,7 @@ const makePerformerMoviesUrl = ( extraCriteria?: Criterion[] ) => { if (!performer.id) return "#"; - const filter = new ListFilterModel(GQL.FilterMode.Movies); + const filter = new ListFilterModel(GQL.FilterMode.Movies, undefined); const criterion = new PerformersCriterion(); criterion.value = [ { id: performer.id, label: performer.name || `Performer ${performer.id}` }, @@ -93,7 +93,7 @@ const makePerformersCountryUrl = ( performer: Partial ) => { if (!performer.id) return "#"; - const filter = new ListFilterModel(GQL.FilterMode.Performers); + const filter = new ListFilterModel(GQL.FilterMode.Performers, undefined); const criterion = new CountryCriterion(); criterion.value = `${performer.country}`; filter.criteria.push(criterion); @@ -102,7 +102,7 @@ const makePerformersCountryUrl = ( const makeStudioScenesUrl = (studio: Partial) => { if (!studio.id) return "#"; - const filter = new ListFilterModel(GQL.FilterMode.Scenes); + const filter = new ListFilterModel(GQL.FilterMode.Scenes, undefined); const criterion = new StudiosCriterion(); criterion.value = { items: [{ id: studio.id, label: studio.name || `Studio ${studio.id}` }], @@ -114,7 +114,7 @@ const makeStudioScenesUrl = (studio: Partial) => { const makeStudioImagesUrl = (studio: Partial) => { if (!studio.id) return "#"; - const filter = new ListFilterModel(GQL.FilterMode.Images); + const filter = new ListFilterModel(GQL.FilterMode.Images, undefined); const criterion = new StudiosCriterion(); criterion.value = { items: [{ id: studio.id, label: studio.name || `Studio ${studio.id}` }], @@ -126,7 +126,7 @@ const makeStudioImagesUrl = (studio: Partial) => { const makeStudioGalleriesUrl = (studio: Partial) => { if (!studio.id) return "#"; - const filter = new ListFilterModel(GQL.FilterMode.Galleries); + const filter = new ListFilterModel(GQL.FilterMode.Galleries, undefined); const criterion = new StudiosCriterion(); criterion.value = { items: [{ id: studio.id, label: studio.name || `Studio ${studio.id}` }], @@ -138,7 +138,7 @@ const makeStudioGalleriesUrl = (studio: Partial) => { const makeStudioMoviesUrl = (studio: Partial) => { if (!studio.id) return "#"; - const filter = new ListFilterModel(GQL.FilterMode.Movies); + const filter = new ListFilterModel(GQL.FilterMode.Movies, undefined); const criterion = new StudiosCriterion(); criterion.value = { items: [{ id: studio.id, label: studio.name || `Studio ${studio.id}` }], @@ -150,7 +150,7 @@ const makeStudioMoviesUrl = (studio: Partial) => { const makeChildStudiosUrl = (studio: Partial) => { if (!studio.id) return "#"; - const filter = new ListFilterModel(GQL.FilterMode.Studios); + const filter = new ListFilterModel(GQL.FilterMode.Studios, undefined); const criterion = new ParentStudiosCriterion(); criterion.value = [ { id: studio.id, label: studio.name || `Studio ${studio.id}` }, @@ -161,7 +161,7 @@ const makeChildStudiosUrl = (studio: Partial) => { const makeMovieScenesUrl = (movie: Partial) => { if (!movie.id) return "#"; - const filter = new ListFilterModel(GQL.FilterMode.Scenes); + const filter = new ListFilterModel(GQL.FilterMode.Scenes, undefined); const criterion = new MoviesCriterion(); criterion.value = [ { id: movie.id, label: movie.name || `Movie ${movie.id}` }, @@ -172,7 +172,7 @@ const makeMovieScenesUrl = (movie: Partial) => { const makeParentTagsUrl = (tag: Partial) => { if (!tag.id) return "#"; - const filter = new ListFilterModel(GQL.FilterMode.Tags); + const filter = new ListFilterModel(GQL.FilterMode.Tags, undefined); const criterion = new TagsCriterion(ChildTagsCriterionOption); criterion.value = { items: [ @@ -189,7 +189,7 @@ const makeParentTagsUrl = (tag: Partial) => { const makeChildTagsUrl = (tag: Partial) => { if (!tag.id) return "#"; - const filter = new ListFilterModel(GQL.FilterMode.Tags); + const filter = new ListFilterModel(GQL.FilterMode.Tags, undefined); const criterion = new TagsCriterion(ParentTagsCriterionOption); criterion.value = { items: [ @@ -206,7 +206,7 @@ const makeChildTagsUrl = (tag: Partial) => { const makeTagScenesUrl = (tag: Partial) => { if (!tag.id) return "#"; - const filter = new ListFilterModel(GQL.FilterMode.Scenes); + const filter = new ListFilterModel(GQL.FilterMode.Scenes, undefined); const criterion = new TagsCriterion(TagsCriterionOption); criterion.value = { items: [{ id: tag.id, label: tag.name || `Tag ${tag.id}` }], @@ -218,7 +218,7 @@ const makeTagScenesUrl = (tag: Partial) => { const makeTagPerformersUrl = (tag: Partial) => { if (!tag.id) return "#"; - const filter = new ListFilterModel(GQL.FilterMode.Performers); + const filter = new ListFilterModel(GQL.FilterMode.Performers, undefined); const criterion = new TagsCriterion(TagsCriterionOption); criterion.value = { items: [{ id: tag.id, label: tag.name || `Tag ${tag.id}` }], @@ -230,7 +230,7 @@ const makeTagPerformersUrl = (tag: Partial) => { const makeTagSceneMarkersUrl = (tag: Partial) => { if (!tag.id) return "#"; - const filter = new ListFilterModel(GQL.FilterMode.SceneMarkers); + const filter = new ListFilterModel(GQL.FilterMode.SceneMarkers, undefined); const criterion = new TagsCriterion(TagsCriterionOption); criterion.value = { items: [{ id: tag.id, label: tag.name || `Tag ${tag.id}` }], @@ -242,7 +242,7 @@ const makeTagSceneMarkersUrl = (tag: Partial) => { const makeTagGalleriesUrl = (tag: Partial) => { if (!tag.id) return "#"; - const filter = new ListFilterModel(GQL.FilterMode.Galleries); + const filter = new ListFilterModel(GQL.FilterMode.Galleries, undefined); const criterion = new TagsCriterion(TagsCriterionOption); criterion.value = { items: [{ id: tag.id, label: tag.name || `Tag ${tag.id}` }], @@ -254,7 +254,7 @@ const makeTagGalleriesUrl = (tag: Partial) => { const makeTagImagesUrl = (tag: Partial) => { if (!tag.id) return "#"; - const filter = new ListFilterModel(GQL.FilterMode.Images); + const filter = new ListFilterModel(GQL.FilterMode.Images, undefined); const criterion = new TagsCriterion(TagsCriterionOption); criterion.value = { items: [{ id: tag.id, label: tag.name || `Tag ${tag.id}` }], @@ -275,7 +275,7 @@ const makeSceneMarkerUrl = (sceneMarker: SceneMarkerDataFragment) => { const makeScenesPHashMatchUrl = (phash: GQL.Maybe | undefined) => { if (!phash) return "#"; - const filter = new ListFilterModel(GQL.FilterMode.Scenes); + const filter = new ListFilterModel(GQL.FilterMode.Scenes, undefined); const criterion = new PhashCriterion(); criterion.value = phash; filter.criteria.push(criterion); @@ -287,7 +287,7 @@ const makeGalleryImagesUrl = ( extraCriteria?: Criterion[] ) => { if (!gallery.id) return "#"; - const filter = new ListFilterModel(GQL.FilterMode.Images); + const filter = new ListFilterModel(GQL.FilterMode.Images, undefined); const criterion = new GalleriesCriterion(); criterion.value = [ { id: gallery.id, label: gallery.title || `Gallery ${gallery.id}` }, diff --git a/ui/v2.5/src/utils/percent.ts b/ui/v2.5/src/utils/percent.ts new file mode 100644 index 000000000..6616733af --- /dev/null +++ b/ui/v2.5/src/utils/percent.ts @@ -0,0 +1,17 @@ +const numberToString = (seconds: number) => { + return seconds + "%"; +}; + +const stringToNumber = (v?: string) => { + if (!v) { + return 0; + } + + const numStr = v.replace("%", ""); + return parseInt(numStr, 10); +}; + +export default { + numberToString, + stringToNumber, +}; diff --git a/ui/v2.5/src/utils/rating.ts b/ui/v2.5/src/utils/rating.ts new file mode 100644 index 000000000..f129239a5 --- /dev/null +++ b/ui/v2.5/src/utils/rating.ts @@ -0,0 +1,105 @@ +export enum RatingSystemType { + Stars = "stars", + Decimal = "decimal", +} + +export enum RatingStarPrecision { + Full = "full", + Half = "half", + Quarter = "quarter", +} + +export const defaultRatingSystemType: RatingSystemType = RatingSystemType.Stars; +export const defaultRatingStarPrecision: RatingStarPrecision = + RatingStarPrecision.Full; + +export const ratingSystemIntlMap = new Map([ + [ + RatingSystemType.Stars, + "config.ui.editing.rating_system.type.options.stars", + ], + [ + RatingSystemType.Decimal, + "config.ui.editing.rating_system.type.options.decimal", + ], +]); + +export const ratingStarPrecisionIntlMap = new Map([ + [ + RatingStarPrecision.Full, + "config.ui.editing.rating_system.star_precision.options.full", + ], + [ + RatingStarPrecision.Half, + "config.ui.editing.rating_system.star_precision.options.half", + ], + [ + RatingStarPrecision.Quarter, + "config.ui.editing.rating_system.star_precision.options.quarter", + ], +]); + +export type RatingSystemOptions = { + type: RatingSystemType; + starPrecision?: RatingStarPrecision; +}; + +export const defaultRatingSystemOptions = { + type: defaultRatingSystemType, + starPrecision: defaultRatingStarPrecision, +}; + +function round(value: number, step: number) { + let denom = step; + if (!denom) { + denom = 1.0; + } + const inv = 1.0 / denom; + return Math.round(value * inv) / inv; +} + +export function getRatingPrecision(precision: RatingStarPrecision) { + switch (precision) { + case RatingStarPrecision.Full: + return 1; + case RatingStarPrecision.Half: + return 0.5; + case RatingStarPrecision.Quarter: + return 0.25; + default: + return 1; + } +} + +export function convertToRatingFormat( + rating: number | undefined, + ratingSystemOptions: RatingSystemOptions +) { + if (!rating) { + return null; + } + + const { type, starPrecision } = ratingSystemOptions; + + const precision = + type === RatingSystemType.Decimal + ? 0.1 + : getRatingPrecision(starPrecision ?? RatingStarPrecision.Full); + const maxValue = type === RatingSystemType.Decimal ? 10 : 5; + const denom = 100 / maxValue; + + return round(rating / denom, precision); +} + +export function convertFromRatingFormat( + rating: number, + ratingSystem: RatingSystemType | undefined +) { + const maxValue = + (ratingSystem ?? RatingSystemType.Stars) === RatingSystemType.Decimal + ? 10 + : 5; + const factor = 100 / maxValue; + + return Math.round(rating * factor); +} diff --git a/ui/v2.5/src/utils/units.ts b/ui/v2.5/src/utils/units.ts new file mode 100644 index 000000000..63a2199d4 --- /dev/null +++ b/ui/v2.5/src/utils/units.ts @@ -0,0 +1,11 @@ +export function cmToImperial(cm: number) { + const cmInInches = 0.393700787; + const inchesInFeet = 12; + const inches = Math.floor(cm * cmInInches); + const feet = Math.floor(inches / inchesInFeet); + return [feet, inches % inchesInFeet]; +} + +export function kgToLbs(kg: number) { + return Math.floor(kg * 2.20462262185); +} diff --git a/ui/v2.5/yarn.lock b/ui/v2.5/yarn.lock index 6bbd64791..95829b3d8 100644 --- a/ui/v2.5/yarn.lock +++ b/ui/v2.5/yarn.lock @@ -1576,10 +1576,17 @@ resolved "https://registry.npmjs.org/@types/unist/-/unist-2.0.3.tgz" integrity sha512-FvUupuM3rlRsRtCN+fDudtmytGO6iHJuuRKS1Ss0pG5z8oX0diNEw94UEL7hgDbpN94rgaK5R7sWm6RrSkZuAQ== -"@types/video.js@*", "@types/video.js@^7.3.28": - version "7.3.28" - resolved "https://registry.yarnpkg.com/@types/video.js/-/video.js-7.3.28.tgz#9acb8c46db3984d556ee7321483ef834bf0a43a2" - integrity sha512-vUxgGAQN+tAOx6OVu8wiSKGfvXJXvX++B5xKJTRpWfPHOV8y1VcJxqhIMb9nLfhIz0Pw3R69pWq2DzlOoxOn8Q== +"@types/video.js@*", "@types/video.js@^7.3.49": + version "7.3.49" + resolved "https://registry.yarnpkg.com/@types/video.js/-/video.js-7.3.49.tgz#33fbc421a02827c90935afbf7dcaac77170b6cda" + integrity sha512-GtBMH+rm7yyw5DAK7ycQeEd35x/EYoLK/49op+CqDDoNUm9XJEVOfb+EARKKe4TwP5jkaikjWqf5RFjmw8yHoQ== + +"@types/videojs-mobile-ui@^0.5.0": + version "0.5.0" + resolved "https://registry.yarnpkg.com/@types/videojs-mobile-ui/-/videojs-mobile-ui-0.5.0.tgz#66934b140fd437fda361986f8e7e87b01dc39138" + integrity sha512-wqeapTB35qpLfERxvL5mZGoexf5bA2TreDpFgc3zyCdr7Acf86VItvo9oTclFeUc11wOo7W7/4ueZZAEYmlTaA== + dependencies: + "@types/video.js" "*" "@types/videojs-seek-buttons@^2.1.0": version "2.1.0" @@ -1680,24 +1687,24 @@ resolved "https://registry.npmjs.org/@ungap/global-this/-/global-this-0.4.4.tgz" integrity sha512-mHkm6FvepJECMNthFuIgpAEFmPOk71UyXuIxYfjytvFTnSDBIz7jmViO+LfHI/AjrazWije0PnSP3+/NlwzqtA== -"@videojs/http-streaming@2.12.0": - version "2.12.0" - resolved "https://registry.yarnpkg.com/@videojs/http-streaming/-/http-streaming-2.12.0.tgz#850069e063e26cf2fa5ed9bb3addfc92fa899f78" - integrity sha512-vdQA0lDYBXGJqV2T02AGqg1w4dcgyRoN+bYG+G8uF4DpCEMhEtUI0BA4tRu4/Njar8w/9D5k0a1KX40pcvM3fA== +"@videojs/http-streaming@2.14.3": + version "2.14.3" + resolved "https://registry.yarnpkg.com/@videojs/http-streaming/-/http-streaming-2.14.3.tgz#3277e03b576766decb4fc663e954e18bfa10d2a1" + integrity sha512-2tFwxCaNbcEZzQugWf8EERwNMyNtspfHnvxRGRABQs09W/5SqmkWFuGWfUAm4wQKlXGfdPyAJ1338ASl459xAA== dependencies: "@babel/runtime" "^7.12.5" - "@videojs/vhs-utils" "3.0.4" - aes-decrypter "3.1.2" + "@videojs/vhs-utils" "3.0.5" + aes-decrypter "3.1.3" global "^4.4.0" - m3u8-parser "4.7.0" - mpd-parser "0.19.2" - mux.js "5.14.1" + m3u8-parser "4.7.1" + mpd-parser "0.21.1" + mux.js "6.0.1" video.js "^6 || ^7" -"@videojs/vhs-utils@3.0.4", "@videojs/vhs-utils@^3.0.0", "@videojs/vhs-utils@^3.0.2", "@videojs/vhs-utils@^3.0.3": - version "3.0.4" - resolved "https://registry.yarnpkg.com/@videojs/vhs-utils/-/vhs-utils-3.0.4.tgz#e253eecd8e9318f767e752010d213587f94bb03a" - integrity sha512-hui4zOj2I1kLzDgf8QDVxD3IzrwjS/43KiS8IHQO0OeeSsb4pB/lgNt1NG7Dv0wMQfCccUpMVLGcK618s890Yg== +"@videojs/vhs-utils@3.0.5", "@videojs/vhs-utils@^3.0.4", "@videojs/vhs-utils@^3.0.5": + version "3.0.5" + resolved "https://registry.yarnpkg.com/@videojs/vhs-utils/-/vhs-utils-3.0.5.tgz#665ba70d78258ba1ab977364e2fe9f4d4799c46c" + integrity sha512-PKVgdo8/GReqdx512F+ombhS+Bzogiofy1LgAj4tN8PfdBx3HSS7V5WfJotKTqtOWGwVfSWsrYN/t09/DSryrw== dependencies: "@babel/runtime" "^7.12.5" global "^4.4.0" @@ -1748,13 +1755,13 @@ acorn@^7.4.0: resolved "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz" integrity sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A== -aes-decrypter@3.1.2: - version "3.1.2" - resolved "https://registry.yarnpkg.com/aes-decrypter/-/aes-decrypter-3.1.2.tgz#3545546f8e9f6b878640339a242efe221ba7a7cb" - integrity sha512-42nRwfQuPRj9R1zqZBdoxnaAmnIFyDi0MNyTVhjdFOd8fifXKKRfwIHIZ6AMn1or4x5WONzjwRTbTWcsIQ0O4A== +aes-decrypter@3.1.3: + version "3.1.3" + resolved "https://registry.yarnpkg.com/aes-decrypter/-/aes-decrypter-3.1.3.tgz#65ff5f2175324d80c41083b0e135d1464b12ac35" + integrity sha512-VkG9g4BbhMBy+N5/XodDeV6F02chEk9IpgRTq/0bS80y4dzy79VH2Gtms02VXomf3HmyRe3yyJYkJ990ns+d6A== dependencies: "@babel/runtime" "^7.12.5" - "@videojs/vhs-utils" "^3.0.0" + "@videojs/vhs-utils" "^3.0.5" global "^4.4.0" pkcs7 "^1.0.4" @@ -1770,7 +1777,7 @@ ajv-keywords@^3.5.2: resolved "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz" integrity sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ== -ajv@^6.10.0, ajv@^6.12.3, ajv@^6.12.4, ajv@^6.12.5: +ajv@^6.10.0, ajv@^6.12.4, ajv@^6.12.5: version "6.12.6" resolved "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz" integrity sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g== @@ -1959,18 +1966,6 @@ asap@~2.0.3: resolved "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz" integrity sha1-5QNHYR1+aQlDIIu9r+vLwvuGbUY= -asn1@~0.2.3: - version "0.2.6" - resolved "https://registry.yarnpkg.com/asn1/-/asn1-0.2.6.tgz#0d3a7bb6e64e02a90c0303b31f292868ea09a08d" - integrity sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ== - dependencies: - safer-buffer "~2.1.0" - -assert-plus@1.0.0, assert-plus@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/assert-plus/-/assert-plus-1.0.0.tgz#f12e0f3c5d77b0b1cdd9146942e4e96c1e4dd525" - integrity sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU= - ast-types-flow@^0.0.7: version "0.0.7" resolved "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.7.tgz" @@ -2014,27 +2009,19 @@ autoprefixer@^9.8.6: postcss "^7.0.32" postcss-value-parser "^4.1.0" -aws-sign2@~0.7.0: - version "0.7.0" - resolved "https://registry.yarnpkg.com/aws-sign2/-/aws-sign2-0.7.0.tgz#b46e890934a9591f2d2f6f86d7e6a9f1b3fe76a8" - integrity sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg= - -aws4@^1.8.0: - version "1.11.0" - resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.11.0.tgz#d61f46d83b2519250e2784daf5b09479a8b41c59" - integrity sha512-xh1Rl34h6Fi1DC2WWKfxUTVqRsNnr6LsKz2+hfwDxQJWmrx8+c7ylaqBMcHfl1U1r2dsifOvKX3LQuLNZ+XSvA== - axe-core@^4.0.2: version "4.1.3" resolved "https://registry.npmjs.org/axe-core/-/axe-core-4.1.3.tgz" integrity sha512-vwPpH4Aj4122EW38mxO/fxhGKtwWTMLDIJfZ1He0Edbtjcfna/R3YB67yVhezUMzqc3Jr3+Ii50KRntlENL4xQ== -axios@0.24.0: - version "0.24.0" - resolved "https://registry.yarnpkg.com/axios/-/axios-0.24.0.tgz#804e6fa1e4b9c5288501dd9dff56a7a0940d20d6" - integrity sha512-Q6cWsys88HoPgAaFAVUb0WpPk0O8iTeisR9IMqy9G8AbO4NlpVknrnQS03zzF9PGAWgO3cgletO3VjV/P7VztA== +axios@^1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/axios/-/axios-1.1.3.tgz#8274250dada2edf53814ed7db644b9c2866c1e35" + integrity sha512-00tXVRwKx/FZr/IDVFt4C+f9FYairX517WoGCL6dpOntqLkZofjhu43F/Xl44UOpqa+9sLFDrG/XAnFsUYgkDA== dependencies: - follow-redirects "^1.14.4" + follow-redirects "^1.15.0" + form-data "^4.0.0" + proxy-from-env "^1.1.0" axobject-query@^2.2.0: version "2.2.0" @@ -2139,13 +2126,6 @@ base64-js@^1.3.1: resolved "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz" integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== -bcrypt-pbkdf@^1.0.0: - version "1.0.2" - resolved "https://registry.yarnpkg.com/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz#a4301d389b6a43f9b67ff3ca11a3f6637e360e9e" - integrity sha1-pDAdOJtqQ/m2f/PKEaP2Y342Dp4= - dependencies: - tweetnacl "^0.14.3" - binary-extensions@^2.0.0: version "2.2.0" resolved "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz" @@ -2284,11 +2264,6 @@ capital-case@^1.0.4: tslib "^2.0.3" upper-case-first "^2.0.2" -caseless@~0.12.0: - version "0.12.0" - resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc" - integrity sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw= - ccount@^1.0.0: version "1.1.0" resolved "https://registry.npmjs.org/ccount/-/ccount-1.1.0.tgz" @@ -2522,7 +2497,7 @@ colorette@^1.2.1: resolved "https://registry.npmjs.org/colorette/-/colorette-1.2.2.tgz" integrity sha512-MKGMzyfeuutC/ZJ1cba9NqcNpfeqMUcYmyF1ZFY6/Cn7CNSAKx6a+s48sqLqyAiZuaP2TcqMhoo+dlwFnVxT9w== -combined-stream@^1.0.6, combined-stream@^1.0.8, combined-stream@~1.0.6: +combined-stream@^1.0.8: version "1.0.8" resolved "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz" integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg== @@ -2575,11 +2550,6 @@ core-js-pure@^3.0.0: resolved "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.9.1.tgz" integrity sha512-laz3Zx0avrw9a4QEIdmIblnVuJz8W51leY9iLThatCsFawWxC3sE4guASC78JbCin+DkwMpCdp1AVAuzL/GN7A== -core-util-is@1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7" - integrity sha1-tf1UIgqivFq1eqtxQMlAdUUDwac= - cosmiconfig-toml-loader@1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/cosmiconfig-toml-loader/-/cosmiconfig-toml-loader-1.0.0.tgz" @@ -2659,13 +2629,6 @@ damerau-levenshtein@^1.0.6: resolved "https://registry.yarnpkg.com/damerau-levenshtein/-/damerau-levenshtein-1.0.6.tgz#143c1641cb3d85c60c32329e26899adea8701791" integrity sha512-JVrozIeElnj3QzfUIt8tB8YMluBJom4Vw9qTPpjGYQ9fYlB3D/rb6OordUxf3xeFB35LKWs0xqcO5U6ySvBtug== -dashdash@^1.12.0: - version "1.14.1" - resolved "https://registry.yarnpkg.com/dashdash/-/dashdash-1.14.1.tgz#853cfa0f7cbe2fed5de20326b8dd581035f6e2f0" - integrity sha1-hTz6D3y+L+1d4gMmuN1YEDX24vA= - dependencies: - assert-plus "^1.0.0" - dataloader@2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/dataloader/-/dataloader-2.0.0.tgz#41eaf123db115987e21ca93c005cd7753c55fe6f" @@ -2893,14 +2856,6 @@ duplexer3@^0.1.4: resolved "https://registry.npmjs.org/duplexer3/-/duplexer3-0.1.4.tgz" integrity sha1-7gHdHKwO08vH/b6jfcCo8c4ALOI= -ecc-jsbn@~0.1.1: - version "0.1.2" - resolved "https://registry.yarnpkg.com/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz#3a83a904e54353287874c564b7549386849a98c9" - integrity sha1-OoOpBOVDUyh4dMVkt1SThoSamMk= - dependencies: - jsbn "~0.1.0" - safer-buffer "^2.1.0" - ecdsa-sig-formatter@1.0.11: version "1.0.11" resolved "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz" @@ -3411,7 +3366,7 @@ execall@^2.0.0: dependencies: clone-regexp "^2.1.0" -extend@^3.0.0, extend@~3.0.2: +extend@^3.0.0: version "3.0.2" resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa" integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g== @@ -3451,16 +3406,6 @@ extract-react-intl-messages@^4.1.1: sort-keys "^4.0.0" write-json-file "^4.3.0" -extsprintf@1.3.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.3.0.tgz#96918440e3041a7a414f8c52e3c574eb3c3e1e05" - integrity sha1-lpGEQOMEGnpBT4xS48V06zw+HgU= - -extsprintf@^1.2.0: - version "1.4.1" - resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.4.1.tgz#8d172c064867f235c0c84a596806d279bf4bcc07" - integrity sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA== - fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: version "3.1.3" resolved "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz" @@ -3609,17 +3554,12 @@ flexbin@^0.2.0: resolved "https://registry.npmjs.org/flexbin/-/flexbin-0.2.0.tgz" integrity sha1-ASYwbT1ZX8t9/LhxSbnJWZ/49Ok= -follow-redirects@^1.14.4: - version "1.14.6" - resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.14.6.tgz#8cfb281bbc035b3c067d6cd975b0f6ade6e855cd" - integrity sha512-fhUl5EwSJbbl8AR+uYL2KQDxLkdSjZGR36xy46AO7cOMTrCMON6Sa28FmAnC2tRTDbd/Uuzz3aJBv7EBN7JH8A== +follow-redirects@^1.15.0: + version "1.15.2" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.2.tgz#b460864144ba63f2681096f274c4e57026da2c13" + integrity sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA== -forever-agent@~0.6.1: - version "0.6.1" - resolved "https://registry.yarnpkg.com/forever-agent/-/forever-agent-0.6.1.tgz#fbc71f0c41adeb37f96c577ad1ed42d8fdacca91" - integrity sha1-+8cfDEGt6zf5bFd60e1C2P2sypE= - -form-data@4.0.0: +form-data@4.0.0, form-data@^4.0.0: version "4.0.0" resolved "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz" integrity sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww== @@ -3637,15 +3577,6 @@ form-data@^3.0.0: combined-stream "^1.0.8" mime-types "^2.1.12" -form-data@~2.3.2: - version "2.3.3" - resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.3.3.tgz#dcce52c05f644f298c6a7ab936bd724ceffbf3a6" - integrity sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ== - dependencies: - asynckit "^0.4.0" - combined-stream "^1.0.6" - mime-types "^2.1.12" - formik@^2.2.6: version "2.2.6" resolved "https://registry.npmjs.org/formik/-/formik-2.2.6.tgz" @@ -3749,13 +3680,6 @@ get-symbol-description@^1.0.0: call-bind "^1.0.2" get-intrinsic "^1.1.1" -getpass@^0.1.1: - version "0.1.7" - resolved "https://registry.yarnpkg.com/getpass/-/getpass-0.1.7.tgz#5eff8e3e684d569ae4cb2b1282604e8ba62149fa" - integrity sha1-Xv+OPmhNVprkyysSgmBOi6YhSfo= - dependencies: - assert-plus "^1.0.0" - glob-parent@^5.1.0, glob-parent@^5.1.2, glob-parent@~5.1.0: version "5.1.2" resolved "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz" @@ -3958,19 +3882,6 @@ graphql@^15.3.0, graphql@^15.4.0: resolved "https://registry.npmjs.org/graphql/-/graphql-15.5.0.tgz" integrity sha512-OmaM7y0kaK31NKG31q4YbD2beNYa6jBBKtMFT6gLYJljHLJr42IqJ8KX08u3Li/0ifzTU5HjmoOOrwa5BRLeDA== -har-schema@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/har-schema/-/har-schema-2.0.0.tgz#a94c2224ebcac04782a0d9035521f24735b7ec92" - integrity sha1-qUwiJOvKwEeCoNkDVSHyRzW37JI= - -har-validator@~5.1.3: - version "5.1.5" - resolved "https://registry.yarnpkg.com/har-validator/-/har-validator-5.1.5.tgz#1f0803b9f8cb20c0fa13822df1ecddb36bde1efd" - integrity sha512-nmT2T0lljbxdQZfspsno9hgrG3Uir6Ks5afism62poxqBM6sDnMEuPmzTq8XN0OEwqKLLdh1jQI3qyE66Nzb3w== - dependencies: - ajv "^6.12.3" - har-schema "^2.0.0" - hard-rejection@^2.1.0: version "2.1.0" resolved "https://registry.npmjs.org/hard-rejection/-/hard-rejection-2.1.0.tgz" @@ -4103,15 +4014,6 @@ http-proxy-agent@^4.0.1: agent-base "6" debug "4" -http-signature@~1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/http-signature/-/http-signature-1.2.0.tgz#9aecd925114772f3d95b65a60abb8f7c18fbace1" - integrity sha1-muzZJRFHcvPZW2WmCruPfBj7rOE= - dependencies: - assert-plus "^1.0.0" - jsprim "^1.2.2" - sshpk "^1.7.0" - https-proxy-agent@^5.0.0: version "5.0.0" resolved "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.0.tgz" @@ -4570,7 +4472,7 @@ is-symbol@^1.0.2, is-symbol@^1.0.3: dependencies: has-symbols "^1.0.1" -is-typedarray@^1.0.0, is-typedarray@~1.0.0: +is-typedarray@^1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz" integrity sha1-5HnICFjfDBsR3dppQPlgEfzaSpo= @@ -4634,11 +4536,6 @@ isomorphic-ws@4.0.1: resolved "https://registry.npmjs.org/isomorphic-ws/-/isomorphic-ws-4.0.1.tgz" integrity sha512-BhBvN2MBpWTaSHdWRb/bwdZJ1WaehQ2L1KngkCkfLUGF0mAWAT1sQUQacEmQ0jXkFw/czDXPNQSL5u2/Krsz1w== -isstream@~0.1.2: - version "0.1.2" - resolved "https://registry.yarnpkg.com/isstream/-/isstream-0.1.2.tgz#47e63f7af55afa6f92e1500e690eb8b8529c099a" - integrity sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo= - iterall@^1.2.1: version "1.3.0" resolved "https://registry.npmjs.org/iterall/-/iterall-1.3.0.tgz" @@ -4664,11 +4561,6 @@ js-yaml@^4.0.0: dependencies: argparse "^2.0.1" -jsbn@~0.1.0: - version "0.1.1" - resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-0.1.1.tgz#a5e654c2e5a2deb5f201d96cefbca80c0ef2f513" - integrity sha1-peZUwuWi3rXyAdls77yoDA7y9RM= - jsesc@^2.5.1: version "2.5.2" resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-2.5.2.tgz#80564d2e483dacf6e8ef209650a67df3f0c283a4" @@ -4694,11 +4586,6 @@ json-schema-traverse@^1.0.0: resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz#ae7bcb3656ab77a73ba5c49bf654f38e6b6860e2" integrity sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug== -json-schema@0.4.0: - version "0.4.0" - resolved "https://registry.yarnpkg.com/json-schema/-/json-schema-0.4.0.tgz#f7de4cf6efab838ebaeb3236474cbba5a1930ab5" - integrity sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA== - json-stable-stringify-without-jsonify@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651" @@ -4711,11 +4598,6 @@ json-stable-stringify@^1.0.1: dependencies: jsonify "~0.0.0" -json-stringify-safe@~5.0.1: - version "5.0.1" - resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb" - integrity sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus= - json-to-pretty-yaml@^1.2.2: version "1.2.2" resolved "https://registry.yarnpkg.com/json-to-pretty-yaml/-/json-to-pretty-yaml-1.2.2.tgz#f4cd0bd0a5e8fe1df25aaf5ba118b099fd992d5b" @@ -4775,16 +4657,6 @@ jsonwebtoken@^8.5.1: ms "^2.1.1" semver "^5.6.0" -jsprim@^1.2.2: - version "1.4.2" - resolved "https://registry.yarnpkg.com/jsprim/-/jsprim-1.4.2.tgz#712c65533a15c878ba59e9ed5f0e26d5b77c5feb" - integrity sha512-P2bSOMAc/ciLz6DzgjVlGJP9+BrJWu5UDGK70C2iweC5QBIeFf0ZXRvGjEj2uYgrY2MkAAhsSWHDWlFtEroZWw== - dependencies: - assert-plus "1.0.0" - extsprintf "1.3.0" - json-schema "0.4.0" - verror "1.10.0" - "jsx-ast-utils@^2.4.1 || ^3.0.0", jsx-ast-utils@^3.1.0: version "3.2.0" resolved "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.2.0.tgz" @@ -4930,10 +4802,10 @@ load-json-file@^6.2.0: strip-bom "^4.0.0" type-fest "^0.6.0" -localforage@1.9.0: - version "1.9.0" - resolved "https://registry.npmjs.org/localforage/-/localforage-1.9.0.tgz" - integrity sha512-rR1oyNrKulpe+VM9cYmcFn6tsHuokyVHFaCM3+osEmxaHTbEk8oQu6eGDfS6DQLWi/N67XRmB8ECG37OES368g== +localforage@^1.9.0: + version "1.10.0" + resolved "https://registry.yarnpkg.com/localforage/-/localforage-1.10.0.tgz#5c465dc5f62b2807c3a84c0c6a1b1b3212781dd4" + integrity sha512-14/H1aX7hzBBmmh7sGPd+AOMkkIrHM3Z1PAyGgZigA1H1p5O5ANnMyWzvpAETtG68/dC4pC0ncy3+PPGzXZHPg== dependencies: lie "3.1.1" @@ -5099,13 +4971,13 @@ lru-cache@^6.0.0: dependencies: yallist "^4.0.0" -m3u8-parser@4.7.0: - version "4.7.0" - resolved "https://registry.yarnpkg.com/m3u8-parser/-/m3u8-parser-4.7.0.tgz#e01e8ce136098ade1b14ee691ea20fc4dc60abf6" - integrity sha512-48l/OwRyjBm+QhNNigEEcRcgbRvnUjL7rxs597HmW9QSNbyNvt+RcZ9T/d9vxi9A9z7EZrB1POtZYhdRlwYQkQ== +m3u8-parser@4.7.1: + version "4.7.1" + resolved "https://registry.yarnpkg.com/m3u8-parser/-/m3u8-parser-4.7.1.tgz#d6df2c940bb19a01112a04ccc4ff44886a945305" + integrity sha512-pbrQwiMiq+MmI9bl7UjtPT3AK603PV9bogNlr83uC+X9IoxqL5E4k7kU7fMQ0dpRgxgeSMygqUa0IMLQNXLBNA== dependencies: "@babel/runtime" "^7.12.5" - "@videojs/vhs-utils" "^3.0.0" + "@videojs/vhs-utils" "^3.0.5" global "^4.4.0" make-dir@^3.0.0: @@ -5584,11 +5456,6 @@ mime-db@1.46.0: resolved "https://registry.npmjs.org/mime-db/-/mime-db-1.46.0.tgz" integrity sha512-svXaP8UQRZ5K7or+ZmfNhg2xX3yKDMUzqadsSqi4NCH/KomcH75MAMYAGVlvXn4+b/xOPhS3I2uHKRUzvjY7BQ== -mime-db@1.51.0: - version "1.51.0" - resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.51.0.tgz#d9ff62451859b18342d960850dc3cfb77e63fb0c" - integrity sha512-5y8A56jg7XVQx2mbv1lu49NR4dokRnhZYTtL+KGfaa27uq4pSTXkwQkFJl4pkRMyNFz/EtYDSkiiEHx3F7UN6g== - mime-types@^2.1.12: version "2.1.29" resolved "https://registry.npmjs.org/mime-types/-/mime-types-2.1.29.tgz" @@ -5596,13 +5463,6 @@ mime-types@^2.1.12: dependencies: mime-db "1.46.0" -mime-types@~2.1.19: - version "2.1.34" - resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.34.tgz#5a712f9ec1503511a945803640fafe09d3793c24" - integrity sha512-6cP692WwGIs9XXdOO4++N+7qjqv0rqxxVvJ3VHPh/Sc9mVZcQP+ZGhkKiTvWMQRr2tbHkJP/Yn7Y0npb3ZBs4A== - dependencies: - mime-db "1.51.0" - mimic-fn@^1.0.0: version "1.2.0" resolved "https://registry.npmjs.org/mimic-fn/-/mimic-fn-1.2.0.tgz" @@ -5679,13 +5539,13 @@ mousetrap@^1.6.5: resolved "https://registry.npmjs.org/mousetrap/-/mousetrap-1.6.5.tgz" integrity sha512-QNo4kEepaIBwiT8CDhP98umTetp+JNfQYBWvC1pc6/OAibuXtRcxZ58Qz8skvEHYvURne/7R8T5VoOI7rDsEUA== -mpd-parser@0.19.2: - version "0.19.2" - resolved "https://registry.yarnpkg.com/mpd-parser/-/mpd-parser-0.19.2.tgz#68611e653cdf2cc1e90688825c4a129b7f9007e0" - integrity sha512-M5tAIdtBM2TN+OSTz/37T7V+h9ZLvhyNqq4TNIdtjAQ/Hg8UnMRf5nJQDjffcXag3POXi31yUJQEKOXdcAM/nw== +mpd-parser@0.21.1: + version "0.21.1" + resolved "https://registry.yarnpkg.com/mpd-parser/-/mpd-parser-0.21.1.tgz#4f4834074ed0a8e265d8b04a5d2d7b5045a4fa55" + integrity sha512-BxlSXWbKE1n7eyEPBnTEkrzhS3PdmkkKdM1pgKbPnPOH0WFZIc0sPOWi7m0Uo3Wd2a4Or8Qf4ZbS7+ASqQ49fw== dependencies: "@babel/runtime" "^7.12.5" - "@videojs/vhs-utils" "^3.0.2" + "@videojs/vhs-utils" "^3.0.5" "@xmldom/xmldom" "^0.7.2" global "^4.4.0" @@ -5714,12 +5574,13 @@ mute-stream@0.0.8: resolved "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz" integrity sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA== -mux.js@5.14.1: - version "5.14.1" - resolved "https://registry.yarnpkg.com/mux.js/-/mux.js-5.14.1.tgz#209583f454255d9ba2ff1bb61ad5a6867cf61878" - integrity sha512-38kA/xjWRDzMbcpHQfhKbJAME8eTZVsb9U2Puk890oGvGqnyu8B/AkKdICKPHkigfqYX9MY20vje88TP14nhog== +mux.js@6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/mux.js/-/mux.js-6.0.1.tgz#65ce0f7a961d56c006829d024d772902d28c7755" + integrity sha512-22CHb59rH8pWGcPGW5Og7JngJ9s+z4XuSlYvnxhLuc58cA1WqGDQPzuG8I+sPm1/p0CdgpzVTaKW408k5DNn8w== dependencies: "@babel/runtime" "^7.11.2" + global "^4.4.0" nanoclone@^0.2.1: version "0.2.1" @@ -5826,11 +5687,6 @@ number-is-nan@^1.0.0: resolved "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz" integrity sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0= -oauth-sign@~0.9.0: - version "0.9.0" - resolved "https://registry.yarnpkg.com/oauth-sign/-/oauth-sign-0.9.0.tgz#47a7b016baa68b5fa0ecf3dee08a85c679ac6455" - integrity sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ== - object-assign@^4.1.0, object-assign@^4.1.1: version "4.1.1" resolved "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz" @@ -6153,11 +6009,6 @@ path-type@^4.0.0: resolved "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz" integrity sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw== -performance-now@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b" - integrity sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns= - picocolors@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.0.0.tgz#cb5bdc74ff3f51892236eaf79d68bc44564ab81c" @@ -6345,10 +6196,10 @@ property-information@^6.0.0: resolved "https://registry.yarnpkg.com/property-information/-/property-information-6.1.1.tgz#5ca85510a3019726cb9afed4197b7b8ac5926a22" integrity sha512-hrzC564QIl0r0vy4l6MvRLhafmUowhO/O3KgVSoXIbbA2Sz4j8HGpJc6T2cubRVwMwpdiG/vKGfhT4IixmKN9w== -psl@^1.1.28: - version "1.8.0" - resolved "https://registry.yarnpkg.com/psl/-/psl-1.8.0.tgz#9326f8bcfb013adcc005fdff056acce020e51c24" - integrity sha512-RIdOzyoavK+hA18OGGWDqUTsCLhtA7IcZ/6NCs4fFJaHBDab+pDDmDIByWFRQJq2Cd7r1OoQxBGKOaztq+hjIQ== +proxy-from-env@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24d58c3e2" + integrity sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg== pump@^3.0.0: version "3.0.0" @@ -6358,16 +6209,11 @@ pump@^3.0.0: end-of-stream "^1.1.0" once "^1.3.1" -punycode@^2.1.0, punycode@^2.1.1: +punycode@^2.1.0: version "2.1.1" resolved "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz" integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A== -qs@~6.5.2: - version "6.5.2" - resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.2.tgz#cb3ae806e8740444584ef154ce8ee98d403f3e36" - integrity sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA== - query-string@6.13.8: version "6.13.8" resolved "https://registry.npmjs.org/query-string/-/query-string-6.13.8.tgz" @@ -6814,32 +6660,6 @@ replaceall@^0.1.6: resolved "https://registry.npmjs.org/replaceall/-/replaceall-0.1.6.tgz" integrity sha1-gdgax663LX9cSUKt8ml6MiBojY4= -request@^2.88.2: - version "2.88.2" - resolved "https://registry.yarnpkg.com/request/-/request-2.88.2.tgz#d73c918731cb5a87da047e207234146f664d12b3" - integrity sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw== - dependencies: - aws-sign2 "~0.7.0" - aws4 "^1.8.0" - caseless "~0.12.0" - combined-stream "~1.0.6" - extend "~3.0.2" - forever-agent "~0.6.1" - form-data "~2.3.2" - har-validator "~5.1.3" - http-signature "~1.2.0" - is-typedarray "~1.0.0" - isstream "~0.1.2" - json-stringify-safe "~5.0.1" - mime-types "~2.1.19" - oauth-sign "~0.9.0" - performance-now "^2.1.0" - qs "~6.5.2" - safe-buffer "^5.1.2" - tough-cookie "~2.5.0" - tunnel-agent "^0.6.0" - uuid "^3.3.2" - require-directory@^2.1.1: version "2.1.1" resolved "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz" @@ -6860,7 +6680,7 @@ requires-port@^1.0.0: resolved "https://registry.yarnpkg.com/requires-port/-/requires-port-1.0.0.tgz#925d2601d39ac485e091cf0da5c6e694dc3dcaff" integrity sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8= -resize-observer-polyfill@^1.5.0: +resize-observer-polyfill@^1.5.0, resize-observer-polyfill@^1.5.1: version "1.5.1" resolved "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz" integrity sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg== @@ -6972,7 +6792,7 @@ sade@^1.7.3: dependencies: mri "^1.1.0" -safe-buffer@^5.0.1, safe-buffer@^5.1.2, safe-buffer@~5.2.0: +safe-buffer@^5.0.1, safe-buffer@~5.2.0: version "5.2.1" resolved "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz" integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== @@ -6989,7 +6809,7 @@ safe-json-parse@4.0.0: dependencies: rust-result "^1.0.0" -"safer-buffer@>= 2.1.2 < 3", safer-buffer@^2.0.2, safer-buffer@^2.1.0, safer-buffer@~2.1.0: +"safer-buffer@>= 2.1.2 < 3": version "2.1.2" resolved "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz" integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== @@ -7224,21 +7044,6 @@ sse-z@0.3.0: resolved "https://registry.npmjs.org/sse-z/-/sse-z-0.3.0.tgz" integrity sha512-jfcXynl9oAOS9YJ7iqS2JMUEHOlvrRAD+54CENiWnc4xsuVLQVSgmwf7cwOTcBd/uq3XkQKBGojgvEtVXcJ/8w== -sshpk@^1.7.0: - version "1.16.1" - resolved "https://registry.yarnpkg.com/sshpk/-/sshpk-1.16.1.tgz#fb661c0bef29b39db40769ee39fa70093d6f6877" - integrity sha512-HXXqVUq7+pcKeLqqZj6mHFUMvXtOJt1uoUx09pFW6011inTMxqI8BA8PM95myrIyyKwdnzjdFjLiE6KBPVtJIg== - dependencies: - asn1 "~0.2.3" - assert-plus "^1.0.0" - bcrypt-pbkdf "^1.0.0" - dashdash "^1.12.0" - ecc-jsbn "~0.1.1" - getpass "^0.1.1" - jsbn "~0.1.0" - safer-buffer "^2.0.2" - tweetnacl "~0.14.0" - "statuses@>= 1.5.0 < 2": version "1.5.0" resolved "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz" @@ -7660,14 +7465,6 @@ totalist@^2.0.0: resolved "https://registry.yarnpkg.com/totalist/-/totalist-2.0.0.tgz#db6f1e19c0fa63e71339bbb8fba89653c18c7eec" integrity sha512-+Y17F0YzxfACxTyjfhnJQEe7afPA0GSpYlFkl2VFMxYP7jshQf9gXV7cH47EfToBumFThfKBvfAcoUn6fdNeRQ== -tough-cookie@~2.5.0: - version "2.5.0" - resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.5.0.tgz#cd9fb2a0aa1d5a12b473bd9fb96fa3dcff65ade2" - integrity sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g== - dependencies: - psl "^1.1.28" - punycode "^2.1.1" - trim-newlines@^3.0.0: version "3.0.1" resolved "https://registry.yarnpkg.com/trim-newlines/-/trim-newlines-3.0.1.tgz#260a5d962d8b752425b32f3a7db0dcacd176c144" @@ -7741,18 +7538,6 @@ tsutils@^3.21.0: dependencies: tslib "^1.8.1" -tunnel-agent@^0.6.0: - version "0.6.0" - resolved "https://registry.yarnpkg.com/tunnel-agent/-/tunnel-agent-0.6.0.tgz#27a5dea06b36b04a0a9966774b290868f0fc40fd" - integrity sha1-J6XeoGs2sEoKmWZ3SykIaPD8QP0= - dependencies: - safe-buffer "^5.0.1" - -tweetnacl@^0.14.3, tweetnacl@~0.14.0: - version "0.14.5" - resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-0.14.5.tgz#5ae68177f192d4456269d108afa93ff8743f4f64" - integrity sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q= - type-check@^0.4.0, type-check@~0.4.0: version "0.4.0" resolved "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz" @@ -8033,11 +7818,6 @@ util-deprecate@^1.0.1, util-deprecate@^1.0.2: resolved "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz" integrity sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8= -uuid@^3.3.2: - version "3.4.0" - resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.4.0.tgz#b23e4358afa8a202fe7a100af1f5f883f02007ee" - integrity sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A== - uvu@^0.5.0: version "0.5.2" resolved "https://registry.npmjs.org/uvu/-/uvu-0.5.2.tgz" @@ -8072,15 +7852,6 @@ value-equal@^1.0.1: resolved "https://registry.npmjs.org/value-equal/-/value-equal-1.0.1.tgz" integrity sha512-NOJ6JZCAWr0zlxZt+xqCHNTEKOsrks2HQd4MqhP1qy4z1SkbEP467eNx6TgDKXMvUOb+OENfJCZwM+16n7fRfw== -verror@1.10.0: - version "1.10.0" - resolved "https://registry.yarnpkg.com/verror/-/verror-1.10.0.tgz#3a105ca17053af55d6e270c1f8288682e18da400" - integrity sha1-OhBcoXBTr1XW4nDB+CiGguGNpAA= - dependencies: - assert-plus "^1.0.0" - core-util-is "1.0.2" - extsprintf "^1.2.0" - vfile-message@^2.0.0: version "2.0.4" resolved "https://registry.npmjs.org/vfile-message/-/vfile-message-2.0.4.tgz" @@ -8117,58 +7888,48 @@ vfile@^5.0.0: unist-util-stringify-position "^3.0.0" vfile-message "^3.0.0" -"video.js@^6 || ^7", video.js@^7.17.0, "video.js@^7.2.0 || ^6.6.0": - version "7.17.0" - resolved "https://registry.yarnpkg.com/video.js/-/video.js-7.17.0.tgz#35918cc03748a5680f5d5f1da410e06eeea7786e" - integrity sha512-8RbLu9+Pdpep9OTPncUHIvZXFgn/7hKdPnSTE/lGSnlFSucXtTUBp41R7NDwncscMLQ0WgazUbmFlvr4MNWMbA== +"video.js@^6 || ^7", video.js@^7.20.3: + version "7.20.3" + resolved "https://registry.yarnpkg.com/video.js/-/video.js-7.20.3.tgz#5694741346dc683255993e5069daa15d4bacb646" + integrity sha512-JMspxaK74LdfWcv69XWhX4rILywz/eInOVPdKefpQiZJSMD5O8xXYueqACP2Q5yqKstycgmmEKlJzZ+kVmDciw== dependencies: "@babel/runtime" "^7.12.5" - "@videojs/http-streaming" "2.12.0" - "@videojs/vhs-utils" "^3.0.3" + "@videojs/http-streaming" "2.14.3" + "@videojs/vhs-utils" "^3.0.4" "@videojs/xhr" "2.6.0" - aes-decrypter "3.1.2" + aes-decrypter "3.1.3" global "^4.4.0" keycode "^2.2.0" - m3u8-parser "4.7.0" - mpd-parser "0.19.2" - mux.js "5.14.1" + m3u8-parser "4.7.1" + mpd-parser "0.21.1" + mux.js "6.0.1" safe-json-parse "4.0.0" videojs-font "3.2.0" - videojs-vtt.js "^0.15.3" + videojs-vtt.js "^0.15.4" videojs-font@3.2.0: version "3.2.0" resolved "https://registry.yarnpkg.com/videojs-font/-/videojs-font-3.2.0.tgz#212c9d3f4e4ec3fa7345167d64316add35e92232" integrity sha512-g8vHMKK2/JGorSfqAZQUmYYNnXmfec4MLhwtEFS+mMs2IDY398GLysy6BH6K+aS1KMNu/xWZ8Sue/X/mdQPliA== -videojs-landscape-fullscreen@^11.33.0: - version "11.33.0" - resolved "https://registry.yarnpkg.com/videojs-landscape-fullscreen/-/videojs-landscape-fullscreen-11.33.0.tgz#4033100b3a97399c994426e825662860dfc232e0" - integrity sha512-Eex5ovlvIipHHif9LEhVL63zxxmOEQQi6Bt1P+EaA0QpjJAPF+CocWfhMItfGF1GcFQeP1ZFGCPTGw60n7vyUg== +videojs-mobile-ui@^0.8.0: + version "0.8.0" + resolved "https://registry.yarnpkg.com/videojs-mobile-ui/-/videojs-mobile-ui-0.8.0.tgz#40a1c6f9302071b9bbe95937c934114600916ac5" + integrity sha512-Jd+u/ctjUkbZlT1cAA0umTu0LQwSZSFG+02cJxShuwq27B6rfrRALETK/gsuTc7U27lB9fbwcF7HBMaNxW62nA== dependencies: global "^4.4.0" -videojs-seek-buttons@^2.2.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/videojs-seek-buttons/-/videojs-seek-buttons-2.2.0.tgz#50b8da1178a5718ee5a7649fbb90a64a518103f7" - integrity sha512-yjCA6ntq+8fRKgZi/H6QJlghQWgA1x9oSRl6wfLODAcujhynDXetwMgRKGgl4NlV5af2bKY6erNtJ0kOBko/nQ== +videojs-seek-buttons@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/videojs-seek-buttons/-/videojs-seek-buttons-3.0.1.tgz#cc2adc23a6372e8aa6c2e9fd0fe7e7831a46747f" + integrity sha512-scVWOqCMqHajlbwYZIzJ5nBYkDXTAhEpWjfcdCu8ykksA1barrKnEKdQvS84TtDWOx6UXDD/e/x0acYEZCDMEQ== dependencies: global "^4.4.0" - video.js "^6 || ^7" -videojs-vtt-thumbnails-freetube@^0.0.15: - version "0.0.15" - resolved "https://registry.yarnpkg.com/videojs-vtt-thumbnails-freetube/-/videojs-vtt-thumbnails-freetube-0.0.15.tgz#5bbc1f98c4d4cffd5b3538e8caab36aca94c86cf" - integrity sha512-aRjG6fvsuWCpcFcdhqRbI5HUWw1l7boHRJZoQki+z74uDbys/u8OVo6S/oJgpmog//iToQEKqHjSEisFdVDQlA== - dependencies: - global "^4.4.0" - request "^2.88.2" - video.js "^7.2.0 || ^6.6.0" - -videojs-vtt.js@^0.15.3: - version "0.15.3" - resolved "https://registry.yarnpkg.com/videojs-vtt.js/-/videojs-vtt.js-0.15.3.tgz#84260393b79487fcf195d9372f812d7fab83a993" - integrity sha512-5FvVsICuMRx6Hd7H/Y9s9GDeEtYcXQWzGMS+sl4UX3t/zoHp3y+isSfIPRochnTH7h+Bh1ILyC639xy9Z6kPag== +videojs-vtt.js@^0.15.4: + version "0.15.4" + resolved "https://registry.yarnpkg.com/videojs-vtt.js/-/videojs-vtt.js-0.15.4.tgz#5dc5aabcd82ba40c5595469bd855ea8230ca152c" + integrity sha512-r6IhM325fcLb1D6pgsMkTQT1PpFdUdYZa1iqk7wJEu+QlibBwATPfPc9Bg8Jiym0GE5yP1AG2rMLu+QMVWkYtA== dependencies: global "^4.3.1" diff --git a/vendor/modules.txt b/vendor/modules.txt index 549279671..d4d5df879 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -488,8 +488,6 @@ golang.org/x/tools/internal/imports golang.org/x/tools/internal/packagesinternal golang.org/x/tools/internal/typeparams golang.org/x/tools/internal/typesinternal -# golang.org/x/xerrors v0.0.0-20220609144429-65e65417b02f -## explicit; go 1.17 # gopkg.in/guregu/null.v4 v4.0.0 ## explicit gopkg.in/guregu/null.v4