From efede32dd753175bf8b902450fc45c48e74b50a5 Mon Sep 17 00:00:00 2001 From: InfiniteStash <117855276+InfiniteStash@users.noreply.github.com> Date: Tue, 11 Jun 2024 00:26:03 +0200 Subject: [PATCH 001/103] Fix studio selection in scraping dialogs (#4953) --- .../components/Shared/ScrapeDialog/ScrapedObjectsRow.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/ui/v2.5/src/components/Shared/ScrapeDialog/ScrapedObjectsRow.tsx b/ui/v2.5/src/components/Shared/ScrapeDialog/ScrapedObjectsRow.tsx index 8113c90a888..7184716a744 100644 --- a/ui/v2.5/src/components/Shared/ScrapeDialog/ScrapedObjectsRow.tsx +++ b/ui/v2.5/src/components/Shared/ScrapeDialog/ScrapedObjectsRow.tsx @@ -54,7 +54,11 @@ export const ScrapedStudioRow: React.FC = ({ isDisabled={!isNew} onSelect={(items) => { if (onChangeFn) { - onChangeFn(items[0]); + const { id, ...data } = items[0]; + onChangeFn({ + ...data, + stored_id: id, + }); } }} values={selectValue} From e4b89064b17a3ea760769e99941f3370b2904a36 Mon Sep 17 00:00:00 2001 From: anonymous-ants <53803255+anonymous-ants@users.noreply.github.com> Date: Mon, 10 Jun 2024 15:26:56 -0700 Subject: [PATCH 002/103] Fix typos in docs (en) (#4946) --- ui/v2.5/src/docs/en/Changelog/v0200.md | 2 +- ui/v2.5/src/docs/en/Manual/Configuration.md | 4 ++-- ui/v2.5/src/docs/en/Manual/Introduction.md | 2 +- ui/v2.5/src/docs/en/ReleaseNotes/v0240.md | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/ui/v2.5/src/docs/en/Changelog/v0200.md b/ui/v2.5/src/docs/en/Changelog/v0200.md index e3174fe520d..e879b3ba43a 100644 --- a/ui/v2.5/src/docs/en/Changelog/v0200.md +++ b/ui/v2.5/src/docs/en/Changelog/v0200.md @@ -47,7 +47,7 @@ Once migrated, these files can be deleted. The files can be optionally deleted d * Fixed `/stream` endpoint serving directory list. ([#3541](https://github.com/stashapp/stash/pull/3541)) * Fixed error when querying with a large or unlimited page size. ([#3544](https://github.com/stashapp/stash/pull/3544)) * Fixed sprites not being displayed for scenes with numeric-only hashes. ([#3513](https://github.com/stashapp/stash/pull/3513)) -* Fixed Save button being disabled when stting Tag image. ([#3509](https://github.com/stashapp/stash/pull/3509)) +* Fixed Save button being disabled when setting Tag image. ([#3509](https://github.com/stashapp/stash/pull/3509)) * Fixed incorrect performer with identical name being matched when scraping from stash-box. ([#3488](https://github.com/stashapp/stash/pull/3488)) * Fixed scene cover not being included when submitting file-less scenes to stash-box. ([#3465](https://github.com/stashapp/stash/pull/3465)) * Fixed URL not being during stash-box scrape if the Studio URL is not set. ([#3439](https://github.com/stashapp/stash/pull/3439)) diff --git a/ui/v2.5/src/docs/en/Manual/Configuration.md b/ui/v2.5/src/docs/en/Manual/Configuration.md index f2b3bf545a7..197ae541024 100644 --- a/ui/v2.5/src/docs/en/Manual/Configuration.md +++ b/ui/v2.5/src/docs/en/Manual/Configuration.md @@ -15,7 +15,7 @@ Regex patterns can be added in the config file or from the UI. If you add manually to the config file a restart is needed while from the UI you just need to click the Save button. When added through the config file directly special care must be given to double escape the `\` character. -There are 2 sperate exclusion settings. One is for videos, another is for images/galleries. +There are 2 separate exclusion settings. One is for videos, another is for images/galleries. Some examples: @@ -23,7 +23,7 @@ Some examples: - `"/\.[[:word:]]+/"` will exclude all hidden directories like `/.directoryname/`. - `"c:\\stash\\videos\\exclude"` will exclude specific Windows directory `c:\stash\videos\exclude`. - `"^/stash/videos/exclude/"` will exclude all directories that match `/stash/videos/exclude/` pattern. -- `"\\\\stash\\network\\share\\excl\\"` will exlcude specific Windows network path `\\stash\network\share\excl\`. +- `"\\\\stash\\network\\share\\excl\\"` will exclude specific Windows network path `\\stash\network\share\excl\`. > **Note:** If a directory is excluded for images and videos, then the directory will be excluded from scans completely. diff --git a/ui/v2.5/src/docs/en/Manual/Introduction.md b/ui/v2.5/src/docs/en/Manual/Introduction.md index 248c653b66c..1496ad2b16d 100644 --- a/ui/v2.5/src/docs/en/Manual/Introduction.md +++ b/ui/v2.5/src/docs/en/Manual/Introduction.md @@ -2,6 +2,6 @@ Stash works by cataloging your media using the paths that you provide. Once you have [configured](/settings?tab=library) the locations where your media is stored, you can click the Scan button in [`Settings -> Tasks`](/settings?tab=tasks) and stash will begin scanning and importing your media into its library. -For the best experience, it is recommmended that after a scan is finished, that video previews and sprites are generated. You can do this in [`Settings -> Tasks`](/settings?tab=tasks). Note that currently it is only possible to perform one task at a time and but there is a task queue, so the generate tasks should be performed after scan is complete. +For the best experience, it is recommended that after a scan is finished, that video previews and sprites are generated. You can do this in [`Settings -> Tasks`](/settings?tab=tasks). Note that currently it is only possible to perform one task at a time and but there is a task queue, so the generate tasks should be performed after scan is complete. Once your media is imported, you are ready to begin creating Performers, Studios and Tags, and curating your content! \ No newline at end of file diff --git a/ui/v2.5/src/docs/en/ReleaseNotes/v0240.md b/ui/v2.5/src/docs/en/ReleaseNotes/v0240.md index c2653d0e900..1e4ced314e0 100644 --- a/ui/v2.5/src/docs/en/ReleaseNotes/v0240.md +++ b/ui/v2.5/src/docs/en/ReleaseNotes/v0240.md @@ -1,5 +1,5 @@ This release introduces scraper and plugin management interfaces. This allows installing, updating and removing scrapers and plugins from the WebUI. -Default package sources have been automatically configured to point at the _stable_ branches of the `CommunityScrapers` and `CommunityScripts` respositories. These branches will correspond to the current stable version of stash. +Default package sources have been automatically configured to point at the _stable_ branches of the `CommunityScrapers` and `CommunityScripts` repositories. These branches will correspond to the current stable version of stash. **Note:** existing scrapers and plugins will _not_ be able to be managed using the management interface. It is recommended that any existing scrapers and plugins that are available from the community repositories are backed up and removed from the applicable `scrapers` or `plugins` directory, and reinstalled using the management UI. From ff23d4e20b14c49fa1fc6f2964f5fee4bbe15c52 Mon Sep 17 00:00:00 2001 From: its-josh4 <74079536+its-josh4@users.noreply.github.com> Date: Mon, 10 Jun 2024 17:55:02 -0700 Subject: [PATCH 003/103] Update to Go 1.22 (#4822) * Update to Go 1.22 Updates to Go 1.22 because 1.19 is un-supported and has some CVEs. Also updates a small number of low-risk deps * Explicitly install Go in CI * Bump compiler version * Add build tags to it target --------- Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com> --- .github/workflows/build.yml | 7 ++- .github/workflows/golangci-lint.yml | 7 ++- Makefile | 3 +- docker/build/x86_64/Dockerfile | 2 +- docker/build/x86_64/Dockerfile-CUDA | 2 +- docker/compiler/Dockerfile | 2 +- docker/compiler/Makefile | 2 +- go.mod | 24 +++++----- go.sum | 71 +++++++++++++++-------------- 9 files changed, 66 insertions(+), 54 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 893be42f479..02a5d973e5a 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:8 + COMPILER_IMAGE: stashapp/compiler:9 jobs: build: @@ -23,6 +23,11 @@ jobs: - name: Checkout run: git fetch --prune --unshallow --tags + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: 'go.mod' + - name: Pull compiler image run: docker pull $COMPILER_IMAGE diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml index b4100b8d066..e29d56c7999 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:8 + COMPILER_IMAGE: stashapp/compiler:9 jobs: golangci: @@ -21,6 +21,11 @@ jobs: - name: Checkout run: git fetch --prune --unshallow --tags + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: 'go.mod' + - name: Pull compiler image run: docker pull $COMPILER_IMAGE diff --git a/Makefile b/Makefile index 237f48557ab..e3d9dfc2bd7 100644 --- a/Makefile +++ b/Makefile @@ -307,7 +307,8 @@ test: # runs all tests - including integration tests .PHONY: it it: - go test -tags=integration ./... + $(eval GO_BUILD_TAGS += integration) + go test -tags "$(GO_BUILD_TAGS)" ./... # generates test mocks .PHONY: generate-test-mocks diff --git a/docker/build/x86_64/Dockerfile b/docker/build/x86_64/Dockerfile index cf47278e31d..b945c9c4adc 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.19-alpine as backend +FROM golang:1.22-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/build/x86_64/Dockerfile-CUDA b/docker/build/x86_64/Dockerfile-CUDA index 53ebb60c03b..f76c6dea609 100644 --- a/docker/build/x86_64/Dockerfile-CUDA +++ b/docker/build/x86_64/Dockerfile-CUDA @@ -16,7 +16,7 @@ ARG STASH_VERSION RUN BUILD_DATE=$(date +"%Y-%m-%d %H:%M:%S") make ui # Build Backend -FROM golang:1.19-bullseye as backend +FROM golang:1.22-bullseye as backend RUN apt update && apt install -y build-essential golang WORKDIR /stash COPY ./go* ./*.go Makefile gqlgen.yml .gqlgenc.yml /stash/ diff --git a/docker/compiler/Dockerfile b/docker/compiler/Dockerfile index d543ca20c6e..d69cea3e34d 100644 --- a/docker/compiler/Dockerfile +++ b/docker/compiler/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.19 +FROM golang:1.22 LABEL maintainer="https://discord.gg/2TsNFKt" diff --git a/docker/compiler/Makefile b/docker/compiler/Makefile index 2dd2f1e08f4..dbd9e16f89e 100644 --- a/docker/compiler/Makefile +++ b/docker/compiler/Makefile @@ -1,6 +1,6 @@ user=stashapp repo=compiler -version=8 +version=9 latest: docker build -t ${user}/${repo}:latest . diff --git a/go.mod b/go.mod index c47641c222b..1795a6d34eb 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/stashapp/stash -go 1.19 +go 1.22 require ( github.com/99designs/gqlgen v0.17.2 @@ -15,11 +15,11 @@ require ( github.com/disintegration/imaging v1.6.2 github.com/dop251/goja v0.0.0-20231027120936-b396bb4c349d github.com/doug-martin/goqu/v9 v9.18.0 - github.com/go-chi/chi/v5 v5.0.10 + github.com/go-chi/chi/v5 v5.0.12 github.com/go-chi/cors v1.2.1 github.com/go-chi/httplog v0.3.1 github.com/go-toast/toast v0.0.0-20190211030409-01e6764cf0a4 - github.com/gofrs/uuid/v5 v5.0.0 + github.com/gofrs/uuid/v5 v5.1.0 github.com/golang-jwt/jwt/v4 v4.5.0 github.com/golang-migrate/migrate/v4 v4.16.2 github.com/gorilla/securecookie v1.1.1 @@ -27,19 +27,19 @@ require ( github.com/gorilla/websocket v1.5.0 github.com/hashicorp/golang-lru/v2 v2.0.6 github.com/jinzhu/copier v0.4.0 - github.com/jmoiron/sqlx v1.3.5 + github.com/jmoiron/sqlx v1.4.0 github.com/json-iterator/go v1.1.12 github.com/kermieisinthehouse/gosx-notifier v0.1.2 github.com/kermieisinthehouse/systray v1.2.4 github.com/knadh/koanf v1.5.0 github.com/lucasb-eyer/go-colorful v1.2.0 - github.com/mattn/go-sqlite3 v1.14.17 + github.com/mattn/go-sqlite3 v1.14.22 github.com/natefinch/pie v0.0.0-20170715172608-9a0d72014007 github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 github.com/remeh/sizedwaitgroup v1.0.0 github.com/shurcooL/graphql v0.0.0-20181231061246-d48a9a75455f github.com/sirupsen/logrus v1.9.3 - github.com/spf13/cast v1.5.1 + github.com/spf13/cast v1.6.0 github.com/spf13/pflag v1.0.5 github.com/stretchr/testify v1.8.4 github.com/tidwall/gjson v1.16.0 @@ -49,12 +49,12 @@ require ( github.com/vektra/mockery/v2 v2.10.0 github.com/xWTF/chardet v0.0.0-20230208095535-c780f2ac244e github.com/zencoder/go-dash/v3 v3.0.2 - golang.org/x/crypto v0.21.0 - golang.org/x/image v0.12.0 - golang.org/x/net v0.23.0 - golang.org/x/sys v0.18.0 - golang.org/x/term v0.18.0 - golang.org/x/text v0.14.0 + golang.org/x/crypto v0.23.0 + golang.org/x/image v0.16.0 + golang.org/x/net v0.25.0 + golang.org/x/sys v0.20.0 + golang.org/x/term v0.20.0 + golang.org/x/text v0.15.0 gopkg.in/guregu/null.v4 v4.0.0 gopkg.in/yaml.v2 v2.4.0 ) diff --git a/go.sum b/go.sum index fc7a78ccc16..6fe894ecfa3 100644 --- a/go.sum +++ b/go.sum @@ -49,6 +49,8 @@ cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RX cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= cloud.google.com/go/storage v1.14.0/go.mod h1:GrKmX003DSIwi9o29oFT7YDnHYwZoctc3fOKtUw0Xmo= dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/99designs/gqlgen v0.17.2 h1:yczvlwMsfcVu/JtejqfrLwXuSP0yZFhmcss3caEvHw8= github.com/99designs/gqlgen v0.17.2/go.mod h1:K5fzLKwtph+FFgh9j7nFbRUdBKvTcGnsta51fsMTn3o= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= @@ -81,6 +83,7 @@ github.com/anacrolix/tagflag v0.0.0-20180109131632-2146c8d41bf0/go.mod h1:1m2U/K github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ= github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/cCs= +github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= github.com/antchfx/htmlquery v1.3.0 h1:5I5yNFOVI+egyia5F2s/5Do2nFWxJz41Tr3DyfKD25E= github.com/antchfx/htmlquery v1.3.0/go.mod h1:zKPDVTMhfOmcwxheXUsx4rKJy8KEY/PU6eXr/2SebQ8= github.com/antchfx/xpath v1.2.3 h1:CCZWOzv5bAqjVv0offZ2LVgVYFbeldKQVuLNbViZdes= @@ -114,6 +117,7 @@ github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+Ce github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= github.com/bool64/dev v0.2.28 h1:6ayDfrB/jnNr2iQAZHI+uT3Qi6rErSbJYQs1y8rSrwM= +github.com/bool64/dev v0.2.28/go.mod h1:iJbh1y/HkunEPhgebWRNcs8wfGq7sjvJ6W5iabL8ACg= github.com/bradfitz/iter v0.0.0-20140124041915-454541ec3da2/go.mod h1:PyRFw1Lt2wKX4ZVSQ2mk+PeDa1rxyObEDlApuIsUKuo= github.com/bradfitz/iter v0.0.0-20190303215204-33e6a9893b0c/go.mod h1:PyRFw1Lt2wKX4ZVSQ2mk+PeDa1rxyObEDlApuIsUKuo= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= @@ -192,8 +196,9 @@ github.com/envoyproxy/protoc-gen-validate v0.6.2/go.mod h1:2t7qjJNvHPx8IjnBOzl9E github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU= github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= -github.com/frankban/quicktest v1.14.4 h1:g2rn0vABPOOXmZUj+vbmUp0lPoXEMuhTpIluN0XL9UY= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/fsnotify/fsnotify v1.5.1/go.mod h1:T3375wBYaZdLLcVNkcVbzGHY7f1l/uK5T5Ai1i3InKU= github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY= @@ -202,8 +207,8 @@ github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeME github.com/glycerine/go-unsnap-stream v0.0.0-20180323001048-9f0cb55181dd/go.mod h1:/20jfyN9Y5QPEAprSgKAUr+glWDY39ZiUEAYOEv5dsE= github.com/glycerine/goconvey v0.0.0-20180728074245-46e3a41ad493/go.mod h1:Ogl1Tioa0aV7gstGFO7KhffUsb9M4ydbEbbxpcEDc24= github.com/go-chi/chi/v5 v5.0.7/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= -github.com/go-chi/chi/v5 v5.0.10 h1:rLz5avzKpjqxrYwXNfmjkrYYXOyLJd37pz53UFHC6vk= -github.com/go-chi/chi/v5 v5.0.10/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= +github.com/go-chi/chi/v5 v5.0.12 h1:9euLV5sTrTNTRUU9POmDUvfxyj6LAABLUcEWO+JJb4s= +github.com/go-chi/chi/v5 v5.0.12/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4= github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= github.com/go-chi/httplog v0.3.1 h1:uC3IUWCZagtbCinb3ypFh36SEcgd6StWw2Bu0XSXRtg= @@ -220,8 +225,9 @@ github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU= github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg= -github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE= github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= +github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= +github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/go-test/deep v1.0.2-0.20181118220953-042da051cf31/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= github.com/go-toast/toast v0.0.0-20190211030409-01e6764cf0a4 h1:qZNfIGkIANxGv/OqtnntR4DfOY2+BgwR60cAcu/i3SE= @@ -234,8 +240,8 @@ github.com/gobwas/ws v1.2.1/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/K github.com/gobwas/ws v1.3.0 h1:sbeU3Y4Qzlb+MOzIe6mQGf7QR4Hkv6ZD0qhGkBFL2O0= github.com/gobwas/ws v1.3.0/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= -github.com/gofrs/uuid/v5 v5.0.0 h1:p544++a97kEL+svbcFbCQVM9KFu0Yo25UoISXGNNH9M= -github.com/gofrs/uuid/v5 v5.0.0/go.mod h1:CDOjlDMVAtN56jqyRUZh58JT31Tiw7/oQyEXZV+9bD8= +github.com/gofrs/uuid/v5 v5.1.0 h1:S5rqVKIigghZTCBKPCw0Y+bXkn26K3TB5mvQq2Ix8dk= +github.com/gofrs/uuid/v5 v5.1.0/go.mod h1:CDOjlDMVAtN56jqyRUZh58JT31Tiw7/oQyEXZV+9bD8= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg= @@ -295,6 +301,7 @@ github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= @@ -399,8 +406,8 @@ github.com/jinzhu/copier v0.4.0 h1:w3ciUoD19shMCRargcpm0cm91ytaBhDvuRpz1ODO/U8= github.com/jinzhu/copier v0.4.0/go.mod h1:DfbEm0FYsaqBcKcFuvmOZb218JkPGtvSHsKg8S8hyyg= github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= -github.com/jmoiron/sqlx v1.3.5 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g= -github.com/jmoiron/sqlx v1.3.5/go.mod h1:nRVWtLre0KfCLJvgxzCsLVMogSvQ1zNJtpYr2Ccp0mQ= +github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= +github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= github.com/joho/godotenv v1.3.0 h1:Zjp+RcGpHhGlrMbJzXTrZZPrWj+1vfm90La1wgB6Bhc= github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= @@ -435,15 +442,16 @@ github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfn github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo= github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs= -github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/lib/pq v1.10.1/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= -github.com/lib/pq v1.10.2 h1:AqzbZs4ZoCBp+GtejcpCpcxM3zlSMx29dXbUSeVtJb8= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/logrusorgru/aurora/v3 v3.0.0/go.mod h1:vsR12bk5grlLvLXAYrBsb5Oc/N+LxAlxggSjiwMnCUc= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= @@ -471,10 +479,9 @@ github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27k github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= github.com/mattn/go-sqlite3 v1.14.7/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= -github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM= -github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= github.com/miekg/dns v1.1.26/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso= @@ -565,6 +572,7 @@ github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6L github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rs/xid v1.3.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= github.com/rs/xid v1.4.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= @@ -601,8 +609,8 @@ github.com/spf13/afero v1.8.0/go.mod h1:CtAatgMJh6bJEIs48Ay/FOnkljP3WeGUG0MC1RfA github.com/spf13/afero v1.9.5 h1:stMpOSZFs//0Lv29HduCmli3GUfpFoF3Y1Q/aXj/wVM= github.com/spf13/afero v1.9.5/go.mod h1:UBogFpq8E9Hx+xc5CNTTEpTnuHVmXDwZcZcE1eb/UhQ= github.com/spf13/cast v1.4.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= -github.com/spf13/cast v1.5.1 h1:R+kOtfhWQE6TVQzY+4D7wJLBgkdVasCEFxSUBYBYIlA= -github.com/spf13/cast v1.5.1/go.mod h1:b9PdjNptOpzXr7Rq1q9gJML/2cdGQAo69NKzQ10KN48= +github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= +github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= github.com/spf13/cobra v1.3.0/go.mod h1:BrRVncBjOJa/eUcVVm9CE+oC6as8k+VYr4NY7WCi9V4= github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I= github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0= @@ -706,8 +714,8 @@ golang.org/x/crypto v0.0.0-20211108221036-ceb1ce70b4fa/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.0.0-20211215165025-cf75a172585e/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= golang.org/x/crypto v0.0.0-20220112180741-5e0467b6c7ce/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA= -golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= +golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -721,8 +729,8 @@ golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMk golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= -golang.org/x/image v0.12.0 h1:w13vZbU4o5rKOFFR8y7M+c4A5jXDC0uXTdHYRP8X2DQ= -golang.org/x/image v0.12.0/go.mod h1:Lu90jvHG7GfemOIcldsh9A2hS01ocl6oNO7ype5mEnk= +golang.org/x/image v0.16.0 h1:9kloLAKhUufZhA12l5fwnx2NZW39/we1UhBesW433jw= +golang.org/x/image v0.16.0/go.mod h1:ugSZItdV4nOxyqp56HmXwH0Ry0nBCpjnZdpDaIHdoPs= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= @@ -750,7 +758,6 @@ golang.org/x/mod v0.5.0/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro= golang.org/x/mod v0.5.1/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro= golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0 h1:rmsUpXtvNzj340zd98LZ4KntptpfRHwpFOHG188oHXc= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -802,9 +809,8 @@ golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qx golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws= -golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs= -golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= +golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -834,8 +840,8 @@ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJ 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/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= 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= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -925,17 +931,15 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= -golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.4.0/go.mod h1:9P2UbLfCdcvo3p/nzKvsmas4TnlujnuoV9hGgYzW1lQ= -golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/term v0.18.0 h1:FcHjZXDMxI8mM3nwhX9HlKop4C0YQvCVCdwYl2wOtE8= -golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58= +golang.org/x/term v0.20.0 h1:VnkxpohqXaOBYJtBmEppKUG6mXpi+4O6purfc2+sMhw= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -948,10 +952,8 @@ golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= -golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= -golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= @@ -1017,7 +1019,6 @@ golang.org/x/tools v0.1.8/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU= golang.org/x/tools v0.1.9/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU= golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0 h1:Iey4qkscZuv0VvIt8E0neZjtPVQFSc870HQ448QgEmQ= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= From e843c890fb37f96898ba74b2b1600dfd7860760f Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Tue, 11 Jun 2024 11:34:38 +1000 Subject: [PATCH 004/103] Add related object filter criteria to various filter types in graphql schema (#4861) * Move filter criterion handlers into separate file * Add related filters for image filter * Add related filters for scene filter * Add related filters to gallery filter * Add related filters to movie filter * Add related filters to performer filter * Add related filters to studio filter * Add related filters to tag filter * Add scene filter to scene marker filter --- graphql/schema/types/filters.graphql | 65 ++ pkg/match/cache.go | 10 +- pkg/models/filter.go | 21 + pkg/models/gallery.go | 14 +- pkg/models/image.go | 12 +- pkg/models/movie.go | 5 + pkg/models/performer.go | 12 +- pkg/models/scene.go | 16 +- pkg/models/scene_marker.go | 2 + pkg/models/studio.go | 10 +- pkg/models/tag.go | 10 +- pkg/sqlite/blob.go | 10 +- pkg/sqlite/criterion_handlers.go | 984 +++++++++++++++++++++++++++ pkg/sqlite/database.go | 35 +- pkg/sqlite/file.go | 1 - pkg/sqlite/filter.go | 941 ++----------------------- pkg/sqlite/gallery.go | 550 +++------------ pkg/sqlite/gallery_filter.go | 432 ++++++++++++ pkg/sqlite/gallery_test.go | 32 +- pkg/sqlite/image.go | 420 +++--------- pkg/sqlite/image_filter.go | 290 ++++++++ pkg/sqlite/image_test.go | 32 +- pkg/sqlite/movies.go | 121 +--- pkg/sqlite/movies_filter.go | 150 ++++ pkg/sqlite/performer.go | 599 +++------------- pkg/sqlite/performer_filter.go | 516 ++++++++++++++ pkg/sqlite/performer_test.go | 44 +- pkg/sqlite/repository.go | 26 +- pkg/sqlite/scene.go | 682 +++---------------- pkg/sqlite/scene_filter.go | 533 +++++++++++++++ pkg/sqlite/scene_marker.go | 210 +----- pkg/sqlite/scene_marker_filter.go | 189 +++++ pkg/sqlite/scene_test.go | 32 +- pkg/sqlite/sql.go | 5 + pkg/sqlite/studio.go | 229 ++----- pkg/sqlite/studio_filter.go | 200 ++++++ pkg/sqlite/studio_test.go | 32 +- pkg/sqlite/table.go | 11 +- pkg/sqlite/tag.go | 465 ++----------- pkg/sqlite/tag_filter.go | 395 +++++++++++ pkg/sqlite/tx.go | 20 +- 41 files changed, 4560 insertions(+), 3803 deletions(-) create mode 100644 pkg/sqlite/gallery_filter.go create mode 100644 pkg/sqlite/image_filter.go create mode 100644 pkg/sqlite/movies_filter.go create mode 100644 pkg/sqlite/performer_filter.go create mode 100644 pkg/sqlite/scene_filter.go create mode 100644 pkg/sqlite/scene_marker_filter.go create mode 100644 pkg/sqlite/studio_filter.go create mode 100644 pkg/sqlite/tag_filter.go diff --git a/graphql/schema/types/filters.graphql b/graphql/schema/types/filters.graphql index 5d5209006da..92127416fd1 100644 --- a/graphql/schema/types/filters.graphql +++ b/graphql/schema/types/filters.graphql @@ -170,6 +170,14 @@ input PerformerFilterType { birthdate: DateCriterionInput "Filter by death date" death_date: DateCriterionInput + "Filter by related scenes that meet this criteria" + scenes_filter: SceneFilterType + "Filter by related images that meet this criteria" + images_filter: ImageFilterType + "Filter by related galleries that meet this criteria" + galleries_filter: GalleryFilterType + "Filter by related tags that meet this criteria" + tags_filter: TagFilterType "Filter by creation time" created_at: TimestampCriterionInput "Filter by last update time" @@ -193,6 +201,8 @@ input SceneMarkerFilterType { scene_created_at: TimestampCriterionInput "Filter by lscene ast update time" scene_updated_at: TimestampCriterionInput + "Filter by related scenes that meet this criteria" + scene_filter: SceneFilterType } input SceneFilterType { @@ -288,9 +298,26 @@ input SceneFilterType { created_at: TimestampCriterionInput "Filter by last update time" updated_at: TimestampCriterionInput + + "Filter by related galleries that meet this criteria" + galleries_filter: GalleryFilterType + "Filter by related performers that meet this criteria" + performers_filter: PerformerFilterType + "Filter by related studios that meet this criteria" + studios_filter: StudioFilterType + "Filter by related tags that meet this criteria" + tags_filter: TagFilterType + "Filter by related movies that meet this criteria" + movies_filter: MovieFilterType + "Filter by related markers that meet this criteria" + markers_filter: SceneMarkerFilterType } input MovieFilterType { + AND: MovieFilterType + OR: MovieFilterType + NOT: MovieFilterType + name: StringCriterionInput director: StringCriterionInput synopsis: StringCriterionInput @@ -313,6 +340,11 @@ input MovieFilterType { created_at: TimestampCriterionInput "Filter by last update time" updated_at: TimestampCriterionInput + + "Filter by related scenes that meet this criteria" + scenes_filter: SceneFilterType + "Filter by related studios that meet this criteria" + studios_filter: StudioFilterType } input StudioFilterType { @@ -346,6 +378,12 @@ input StudioFilterType { child_count: IntCriterionInput "Filter by autotag ignore value" ignore_auto_tag: Boolean + "Filter by related scenes that meet this criteria" + scenes_filter: SceneFilterType + "Filter by related images that meet this criteria" + images_filter: ImageFilterType + "Filter by related galleries that meet this criteria" + galleries_filter: GalleryFilterType "Filter by creation time" created_at: TimestampCriterionInput "Filter by last update time" @@ -411,6 +449,17 @@ input GalleryFilterType { code: StringCriterionInput "Filter by photographer" photographer: StringCriterionInput + + "Filter by related scenes that meet this criteria" + scenes_filter: SceneFilterType + "Filter by related images that meet this criteria" + images_filter: ImageFilterType + "Filter by related performers that meet this criteria" + performers_filter: PerformerFilterType + "Filter by related studios that meet this criteria" + studios_filter: StudioFilterType + "Filter by related tags that meet this criteria" + tags_filter: TagFilterType } input TagFilterType { @@ -463,6 +512,13 @@ input TagFilterType { "Filter by autotag ignore value" ignore_auto_tag: Boolean + "Filter by related scenes that meet this criteria" + scenes_filter: SceneFilterType + "Filter by related images that meet this criteria" + images_filter: ImageFilterType + "Filter by related galleries that meet this criteria" + galleries_filter: GalleryFilterType + "Filter by creation time" created_at: TimestampCriterionInput @@ -528,6 +584,15 @@ input ImageFilterType { code: StringCriterionInput "Filter by photographer" photographer: StringCriterionInput + + "Filter by related galleries that meet this criteria" + galleries_filter: GalleryFilterType + "Filter by related performers that meet this criteria" + performers_filter: PerformerFilterType + "Filter by related studios that meet this criteria" + studios_filter: StudioFilterType + "Filter by related tags that meet this criteria" + tags_filter: TagFilterType } enum CriterionModifier { diff --git a/pkg/match/cache.go b/pkg/match/cache.go index 6946f65dbbc..002d67116ca 100644 --- a/pkg/match/cache.go +++ b/pkg/match/cache.go @@ -98,10 +98,12 @@ func getSingleLetterTags(ctx context.Context, c *Cache, reader models.TagAutoTag Value: singleFirstCharacterRegex, Modifier: models.CriterionModifierMatchesRegex, }, - Or: &models.TagFilterType{ - Aliases: &models.StringCriterionInput{ - Value: singleFirstCharacterRegex, - Modifier: models.CriterionModifierMatchesRegex, + OperatorFilter: models.OperatorFilter[models.TagFilterType]{ + Or: &models.TagFilterType{ + Aliases: &models.StringCriterionInput{ + Value: singleFirstCharacterRegex, + Modifier: models.CriterionModifierMatchesRegex, + }, }, }, }, &models.FindFilterType{ diff --git a/pkg/models/filter.go b/pkg/models/filter.go index 1513b0bbea6..577aef42be9 100644 --- a/pkg/models/filter.go +++ b/pkg/models/filter.go @@ -6,6 +6,27 @@ import ( "strconv" ) +type OperatorFilter[T any] struct { + And *T `json:"AND"` + Or *T `json:"OR"` + Not *T `json:"NOT"` +} + +// SubFilter returns the subfilter of the operator filter. +// Only one of And, Or, or Not should be set, so it returns the first of these that are not nil. +func (f *OperatorFilter[T]) SubFilter() *T { + if f.And != nil { + return f.And + } + if f.Or != nil { + return f.Or + } + if f.Not != nil { + return f.Not + } + return nil +} + type CriterionModifier string const ( diff --git a/pkg/models/gallery.go b/pkg/models/gallery.go index 0145ff5e629..73fa287d2e4 100644 --- a/pkg/models/gallery.go +++ b/pkg/models/gallery.go @@ -1,9 +1,7 @@ package models type GalleryFilterType struct { - And *GalleryFilterType `json:"AND"` - Or *GalleryFilterType `json:"OR"` - Not *GalleryFilterType `json:"NOT"` + OperatorFilter[GalleryFilterType] ID *IntCriterionInput `json:"id"` Title *StringCriterionInput `json:"title"` Code *StringCriterionInput `json:"code"` @@ -51,6 +49,16 @@ type GalleryFilterType struct { URL *StringCriterionInput `json:"url"` // Filter by date Date *DateCriterionInput `json:"date"` + // Filter by related scenes that meet this criteria + ScenesFilter *SceneFilterType `json:"scenes_filter"` + // Filter by related images that meet this criteria + ImagesFilter *ImageFilterType `json:"images_filter"` + // Filter by related performers that meet this criteria + PerformersFilter *PerformerFilterType `json:"performers_filter"` + // Filter by related studios that meet this criteria + StudiosFilter *StudioFilterType `json:"studios_filter"` + // Filter by related tags that meet this criteria + TagsFilter *TagFilterType `json:"tags_filter"` // Filter by created at CreatedAt *TimestampCriterionInput `json:"created_at"` // Filter by updated at diff --git a/pkg/models/image.go b/pkg/models/image.go index 8dca7399143..6026070fadf 100644 --- a/pkg/models/image.go +++ b/pkg/models/image.go @@ -3,9 +3,7 @@ package models import "context" type ImageFilterType struct { - And *ImageFilterType `json:"AND"` - Or *ImageFilterType `json:"OR"` - Not *ImageFilterType `json:"NOT"` + OperatorFilter[ImageFilterType] ID *IntCriterionInput `json:"id"` Title *StringCriterionInput `json:"title"` Code *StringCriterionInput `json:"code"` @@ -51,6 +49,14 @@ type ImageFilterType struct { PerformerAge *IntCriterionInput `json:"performer_age"` // Filter to only include images with these galleries Galleries *MultiCriterionInput `json:"galleries"` + // Filter by related galleries that meet this criteria + GalleriesFilter *GalleryFilterType `json:"galleries_filter"` + // Filter by related performers that meet this criteria + PerformersFilter *PerformerFilterType `json:"performers_filter"` + // Filter by related studios that meet this criteria + StudiosFilter *StudioFilterType `json:"studios_filter"` + // Filter by related tags that meet this criteria + TagsFilter *TagFilterType `json:"tags_filter"` // Filter by created at CreatedAt *TimestampCriterionInput `json:"created_at"` // Filter by updated at diff --git a/pkg/models/movie.go b/pkg/models/movie.go index c4afab0e507..95c6efdd1a2 100644 --- a/pkg/models/movie.go +++ b/pkg/models/movie.go @@ -1,6 +1,7 @@ package models type MovieFilterType struct { + OperatorFilter[MovieFilterType] Name *StringCriterionInput `json:"name"` Director *StringCriterionInput `json:"director"` Synopsis *StringCriterionInput `json:"synopsis"` @@ -18,6 +19,10 @@ type MovieFilterType struct { Performers *MultiCriterionInput `json:"performers"` // Filter by date Date *DateCriterionInput `json:"date"` + // Filter by related scenes that meet this criteria + ScenesFilter *SceneFilterType `json:"scenes_filter"` + // Filter by related studios that meet this criteria + StudiosFilter *StudioFilterType `json:"studios_filter"` // Filter by created at CreatedAt *TimestampCriterionInput `json:"created_at"` // Filter by updated at diff --git a/pkg/models/performer.go b/pkg/models/performer.go index 9f5b1b51f49..75b0f85af67 100644 --- a/pkg/models/performer.go +++ b/pkg/models/performer.go @@ -108,9 +108,7 @@ type CircumcisionCriterionInput struct { } type PerformerFilterType struct { - And *PerformerFilterType `json:"AND"` - Or *PerformerFilterType `json:"OR"` - Not *PerformerFilterType `json:"NOT"` + OperatorFilter[PerformerFilterType] Name *StringCriterionInput `json:"name"` Disambiguation *StringCriterionInput `json:"disambiguation"` Details *StringCriterionInput `json:"details"` @@ -188,6 +186,14 @@ type PerformerFilterType struct { Birthdate *DateCriterionInput `json:"birth_date"` // Filter by death date DeathDate *DateCriterionInput `json:"death_date"` + // Filter by related scenes that meet this criteria + ScenesFilter *SceneFilterType `json:"scenes_filter"` + // Filter by related images that meet this criteria + ImagesFilter *ImageFilterType `json:"images_filter"` + // Filter by related galleries that meet this criteria + GalleriesFilter *GalleryFilterType `json:"galleries_filter"` + // Filter by related tags that meet this criteria + TagsFilter *TagFilterType `json:"tags_filter"` // Filter by created at CreatedAt *TimestampCriterionInput `json:"created_at"` // Filter by updated at diff --git a/pkg/models/scene.go b/pkg/models/scene.go index c7a87151c6d..8a2ffde8d5d 100644 --- a/pkg/models/scene.go +++ b/pkg/models/scene.go @@ -9,9 +9,7 @@ type PHashDuplicationCriterionInput struct { } type SceneFilterType struct { - And *SceneFilterType `json:"AND"` - Or *SceneFilterType `json:"OR"` - Not *SceneFilterType `json:"NOT"` + OperatorFilter[SceneFilterType] ID *IntCriterionInput `json:"id"` Title *StringCriterionInput `json:"title"` Code *StringCriterionInput `json:"code"` @@ -97,6 +95,18 @@ type SceneFilterType struct { LastPlayedAt *TimestampCriterionInput `json:"last_played_at"` // Filter by date Date *DateCriterionInput `json:"date"` + // Filter by related galleries that meet this criteria + GalleriesFilter *GalleryFilterType `json:"galleries_filter"` + // Filter by related performers that meet this criteria + PerformersFilter *PerformerFilterType `json:"performers_filter"` + // Filter by related studios that meet this criteria + StudiosFilter *StudioFilterType `json:"studios_filter"` + // Filter by related tags that meet this criteria + TagsFilter *TagFilterType `json:"tags_filter"` + // Filter by related movies that meet this criteria + MoviesFilter *MovieFilterType `json:"movies_filter"` + // Filter by related markers that meet this criteria + MarkersFilter *SceneMarkerFilterType `json:"markers_filter"` // Filter by created at CreatedAt *TimestampCriterionInput `json:"created_at"` // Filter by updated at diff --git a/pkg/models/scene_marker.go b/pkg/models/scene_marker.go index 4a10c0e2178..59186ca5957 100644 --- a/pkg/models/scene_marker.go +++ b/pkg/models/scene_marker.go @@ -19,6 +19,8 @@ type SceneMarkerFilterType struct { SceneCreatedAt *TimestampCriterionInput `json:"scene_created_at"` // Filter by scenes updated at SceneUpdatedAt *TimestampCriterionInput `json:"scene_updated_at"` + // Filter by related scenes that meet this criteria + SceneFilter *SceneFilterType `json:"scene_filter"` } type MarkerStringsResultType struct { diff --git a/pkg/models/studio.go b/pkg/models/studio.go index 9cc6b907e1d..0f8b5d15300 100644 --- a/pkg/models/studio.go +++ b/pkg/models/studio.go @@ -1,9 +1,7 @@ package models type StudioFilterType struct { - And *StudioFilterType `json:"AND"` - Or *StudioFilterType `json:"OR"` - Not *StudioFilterType `json:"NOT"` + OperatorFilter[StudioFilterType] Name *StringCriterionInput `json:"name"` Details *StringCriterionInput `json:"details"` // Filter to only include studios with this parent studio @@ -32,6 +30,12 @@ type StudioFilterType struct { ChildCount *IntCriterionInput `json:"child_count"` // Filter by autotag ignore value IgnoreAutoTag *bool `json:"ignore_auto_tag"` + // Filter by related scenes that meet this criteria + ScenesFilter *SceneFilterType `json:"scenes_filter"` + // Filter by related images that meet this criteria + ImagesFilter *ImageFilterType `json:"images_filter"` + // Filter by related galleries that meet this criteria + GalleriesFilter *GalleryFilterType `json:"galleries_filter"` // Filter by created at CreatedAt *TimestampCriterionInput `json:"created_at"` // Filter by updated at diff --git a/pkg/models/tag.go b/pkg/models/tag.go index 710d1953e99..d51ec9787b0 100644 --- a/pkg/models/tag.go +++ b/pkg/models/tag.go @@ -1,9 +1,7 @@ package models type TagFilterType struct { - And *TagFilterType `json:"AND"` - Or *TagFilterType `json:"OR"` - Not *TagFilterType `json:"NOT"` + OperatorFilter[TagFilterType] // Filter by tag name Name *StringCriterionInput `json:"name"` // Filter by tag aliases @@ -34,6 +32,12 @@ type TagFilterType struct { ChildCount *IntCriterionInput `json:"child_count"` // Filter by autotag ignore value IgnoreAutoTag *bool `json:"ignore_auto_tag"` + // Filter by related scenes that meet this criteria + ScenesFilter *SceneFilterType `json:"scenes_filter"` + // Filter by related images that meet this criteria + ImagesFilter *ImageFilterType `json:"images_filter"` + // Filter by related galleries that meet this criteria + GalleriesFilter *GalleryFilterType `json:"galleries_filter"` // Filter by created at CreatedAt *TimestampCriterionInput `json:"created_at"` // Filter by updated at diff --git a/pkg/sqlite/blob.go b/pkg/sqlite/blob.go index 31b406fc552..241b63d23cf 100644 --- a/pkg/sqlite/blob.go +++ b/pkg/sqlite/blob.go @@ -346,8 +346,8 @@ func (qb *BlobStore) delete(ctx context.Context, checksum string) error { } type blobJoinQueryBuilder struct { - repository - blobStore *BlobStore + repository repository + blobStore *BlobStore joinTable string } @@ -381,7 +381,7 @@ func (qb *blobJoinQueryBuilder) UpdateImage(ctx context.Context, id int, blobCol } sqlQuery := fmt.Sprintf("UPDATE %s SET %s = ? WHERE id = ?", qb.joinTable, blobCol) - if _, err := qb.tx.Exec(ctx, sqlQuery, checksum, id); err != nil { + if _, err := dbWrapper.Exec(ctx, sqlQuery, checksum, id); err != nil { return err } @@ -428,7 +428,7 @@ func (qb *blobJoinQueryBuilder) DestroyImage(ctx context.Context, id int, blobCo } updateQuery := fmt.Sprintf("UPDATE %s SET %s = NULL WHERE id = ?", qb.joinTable, blobCol) - if _, err = qb.tx.Exec(ctx, updateQuery, id); err != nil { + if _, err = dbWrapper.Exec(ctx, updateQuery, id); err != nil { return err } @@ -441,7 +441,7 @@ func (qb *blobJoinQueryBuilder) HasImage(ctx context.Context, id int, blobCol st "joinCol": blobCol, }) - c, err := qb.runCountQuery(ctx, stmt, []interface{}{id}) + c, err := qb.repository.runCountQuery(ctx, stmt, []interface{}{id}) if err != nil { return false, err } diff --git a/pkg/sqlite/criterion_handlers.go b/pkg/sqlite/criterion_handlers.go index 5718947cbe8..243f1f54e13 100644 --- a/pkg/sqlite/criterion_handlers.go +++ b/pkg/sqlite/criterion_handlers.go @@ -2,13 +2,308 @@ package sqlite import ( "context" + "database/sql" "fmt" + "path/filepath" + "regexp" + "strconv" + "strings" "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/utils" ) +type criterionHandler interface { + handle(ctx context.Context, f *filterBuilder) +} + +type criterionHandlerFunc func(ctx context.Context, f *filterBuilder) + +func (h criterionHandlerFunc) handle(ctx context.Context, f *filterBuilder) { + h(ctx, f) +} + +type compoundHandler []criterionHandler + +func (h compoundHandler) handle(ctx context.Context, f *filterBuilder) { + for _, h := range h { + h.handle(ctx, f) + } +} + // shared criterion handlers go here +func stringCriterionHandler(c *models.StringCriterionInput, column string) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if c != nil { + if modifier := c.Modifier; c.Modifier.IsValid() { + switch modifier { + case models.CriterionModifierIncludes: + f.whereClauses = append(f.whereClauses, getStringSearchClause([]string{column}, c.Value, false)) + case models.CriterionModifierExcludes: + f.whereClauses = append(f.whereClauses, getStringSearchClause([]string{column}, c.Value, true)) + case models.CriterionModifierEquals: + f.addWhere(column+" LIKE ?", c.Value) + case models.CriterionModifierNotEquals: + f.addWhere(column+" NOT LIKE ?", c.Value) + case models.CriterionModifierMatchesRegex: + if _, err := regexp.Compile(c.Value); err != nil { + f.setError(err) + return + } + f.addWhere(fmt.Sprintf("(%s IS NOT NULL AND %[1]s regexp ?)", column), 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 %[1]s NOT regexp ?)", column), c.Value) + case models.CriterionModifierIsNull: + f.addWhere("(" + column + " IS NULL OR TRIM(" + column + ") = '')") + case models.CriterionModifierNotNull: + f.addWhere("(" + column + " IS NOT NULL AND TRIM(" + column + ") != '')") + default: + panic("unsupported string filter modifier") + } + } + } + } +} + +func enumCriterionHandler(modifier models.CriterionModifier, values []string, column string) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if modifier.IsValid() { + switch modifier { + case models.CriterionModifierIncludes, models.CriterionModifierEquals: + if len(values) > 0 { + f.whereClauses = append(f.whereClauses, getEnumSearchClause(column, values, false)) + } + case models.CriterionModifierExcludes, models.CriterionModifierNotEquals: + if len(values) > 0 { + f.whereClauses = append(f.whereClauses, getEnumSearchClause(column, values, true)) + } + case models.CriterionModifierIsNull: + f.addWhere("(" + column + " IS NULL OR TRIM(" + column + ") = '')") + case models.CriterionModifierNotNull: + f.addWhere("(" + column + " IS NOT NULL AND TRIM(" + column + ") != '')") + default: + panic("unsupported string filter modifier") + } + } + } +} + +func pathCriterionHandler(c *models.StringCriterionInput, pathColumn string, basenameColumn string, addJoinFn func(f *filterBuilder)) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if c != nil { + if addJoinFn != nil { + addJoinFn(f) + } + addWildcards := true + not := false + + if modifier := c.Modifier; c.Modifier.IsValid() { + switch modifier { + case models.CriterionModifierIncludes: + f.whereClauses = append(f.whereClauses, getPathSearchClauseMany(pathColumn, basenameColumn, c.Value, addWildcards, not)) + case models.CriterionModifierExcludes: + not = true + f.whereClauses = append(f.whereClauses, getPathSearchClauseMany(pathColumn, basenameColumn, c.Value, addWildcards, not)) + case models.CriterionModifierEquals: + addWildcards = false + f.whereClauses = append(f.whereClauses, getPathSearchClause(pathColumn, basenameColumn, c.Value, addWildcards, not)) + case models.CriterionModifierNotEquals: + addWildcards = false + not = true + f.whereClauses = append(f.whereClauses, getPathSearchClause(pathColumn, basenameColumn, c.Value, addWildcards, not)) + case models.CriterionModifierMatchesRegex: + if _, err := regexp.Compile(c.Value); err != nil { + f.setError(err) + return + } + 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 + } + 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)) + 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)) + default: + panic("unsupported string filter modifier") + } + } + } + } +} + +func getPathSearchClause(pathColumn, basenameColumn, p string, addWildcards, not bool) sqlClause { + if addWildcards { + p = "%" + p + "%" + } + + filepathColumn := fmt.Sprintf("%s || '%s' || %s", pathColumn, string(filepath.Separator), basenameColumn) + ret := makeClause(fmt.Sprintf("%s LIKE ?", filepathColumn), p) + + if not { + ret = ret.not() + } + + return ret +} + +// getPathSearchClauseMany splits the query string p on whitespace +// Used for backwards compatibility for the includes/excludes modifiers +func getPathSearchClauseMany(pathColumn, basenameColumn, p string, addWildcards, not bool) sqlClause { + q := strings.TrimSpace(p) + trimmedQuery := strings.Trim(q, "\"") + + if trimmedQuery == q { + q = regexp.MustCompile(`\s+`).ReplaceAllString(q, " ") + queryWords := strings.Split(q, " ") + + var ret []sqlClause + // Search for any word + for _, word := range queryWords { + ret = append(ret, getPathSearchClause(pathColumn, basenameColumn, word, addWildcards, not)) + } + + if !not { + return orClauses(ret...) + } + + return andClauses(ret...) + } + + return getPathSearchClause(pathColumn, basenameColumn, trimmedQuery, addWildcards, not) +} + +func intCriterionHandler(c *models.IntCriterionInput, column string, addJoinFn func(f *filterBuilder)) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if c != nil { + if addJoinFn != nil { + addJoinFn(f) + } + clause, args := getIntCriterionWhereClause(column, *c) + f.addWhere(clause, args...) + } + } +} + +func floatCriterionHandler(c *models.FloatCriterionInput, column string, addJoinFn func(f *filterBuilder)) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if c != nil { + if addJoinFn != nil { + addJoinFn(f) + } + clause, args := getFloatCriterionWhereClause(column, *c) + f.addWhere(clause, args...) + } + } +} + +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 { + addJoinFn(f) + } + clause, args := getIntCriterionWhereClause("cast("+column+" as int)", *durationFilter) + f.addWhere(clause, args...) + } + } +} + +func boolCriterionHandler(c *bool, column string, addJoinFn func(f *filterBuilder)) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if c != nil { + if addJoinFn != nil { + addJoinFn(f) + } + var v string + if *c { + v = "1" + } else { + v = "0" + } + + f.addWhere(column + " = " + v) + } + } +} + +type dateCriterionHandler struct { + c *models.DateCriterionInput + column string + joinFn func(f *filterBuilder) +} + +func (h *dateCriterionHandler) handle(ctx context.Context, f *filterBuilder) { + if h.c != nil { + if h.joinFn != nil { + h.joinFn(f) + } + clause, args := getDateCriterionWhereClause(h.column, *h.c) + f.addWhere(clause, args...) + } +} + +type timestampCriterionHandler struct { + c *models.TimestampCriterionInput + column string + joinFn func(f *filterBuilder) +} + +func (h *timestampCriterionHandler) handle(ctx context.Context, f *filterBuilder) { + if h.c != nil { + if h.joinFn != nil { + h.joinFn(f) + } + clause, args := getTimestampCriterionWhereClause(h.column, *h.c) + f.addWhere(clause, args...) + } +} + +func yearFilterCriterionHandler(year *models.IntCriterionInput, col string) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if year != nil && year.Modifier.IsValid() { + clause, args := getIntCriterionWhereClause("cast(strftime('%Y', "+col+") as int)", *year) + f.addWhere(clause, args...) + } + } +} + +func resolutionCriterionHandler(resolution *models.ResolutionCriterionInput, heightColumn string, widthColumn string, addJoinFn func(f *filterBuilder)) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if resolution != nil && resolution.Value.IsValid() { + if addJoinFn != nil { + addJoinFn(f) + } + + min := resolution.Value.GetMinResolution() + max := resolution.Value.GetMaxResolution() + + widthHeight := fmt.Sprintf("MIN(%s, %s)", widthColumn, heightColumn) + + switch resolution.Modifier { + case models.CriterionModifierEquals: + f.addWhere(fmt.Sprintf("%s BETWEEN %d AND %d", widthHeight, min, max)) + case models.CriterionModifierNotEquals: + f.addWhere(fmt.Sprintf("%s NOT BETWEEN %d AND %d", widthHeight, min, max)) + case models.CriterionModifierLessThan: + f.addWhere(fmt.Sprintf("%s < %d", widthHeight, min)) + case models.CriterionModifierGreaterThan: + f.addWhere(fmt.Sprintf("%s > %d", widthHeight, max)) + } + } + } +} + func orientationCriterionHandler(orientation *models.OrientationCriterionInput, heightColumn string, widthColumn string, addJoinFn func(f *filterBuilder)) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if orientation != nil { @@ -41,3 +336,692 @@ func orientationCriterionHandler(orientation *models.OrientationCriterionInput, } } } + +// handle for MultiCriterion where there is a join table between the new +// objects +type joinedMultiCriterionHandlerBuilder struct { + // table containing the primary objects + primaryTable string + // table joining primary and foreign objects + joinTable string + // alias for join table, if required + joinAs string + // foreign key of the primary object on the join table + primaryFK string + // foreign key of the foreign object on the join table + foreignFK string + + addJoinTable func(f *filterBuilder) +} + +func (m *joinedMultiCriterionHandlerBuilder) handler(c *models.MultiCriterionInput) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if c != nil { + // make local copy so we can modify it + criterion := *c + + joinAlias := m.joinAs + if joinAlias == "" { + joinAlias = m.joinTable + } + + if criterion.Modifier == models.CriterionModifierIsNull || criterion.Modifier == models.CriterionModifierNotNull { + var notClause string + if criterion.Modifier == models.CriterionModifierNotNull { + notClause = "NOT" + } + + m.addJoinTable(f) + + f.addWhere(utils.StrFormat("{table}.{column} IS {not} NULL", utils.StrFormatMap{ + "table": joinAlias, + "column": m.foreignFK, + "not": notClause, + })) + return + } + + if len(criterion.Value) == 0 && len(criterion.Excludes) == 0 { + return + } + + // combine excludes if excludes modifier is selected + if criterion.Modifier == models.CriterionModifierExcludes { + criterion.Modifier = models.CriterionModifierIncludesAll + criterion.Excludes = append(criterion.Excludes, criterion.Value...) + criterion.Value = nil + } + + if len(criterion.Value) > 0 { + whereClause := "" + havingClause := "" + + var args []interface{} + for _, tagID := range criterion.Value { + args = append(args, tagID) + } + + switch criterion.Modifier { + case models.CriterionModifierIncludes: + // includes any of the provided ids + m.addJoinTable(f) + whereClause = fmt.Sprintf("%s.%s IN %s", joinAlias, m.foreignFK, getInBinding(len(criterion.Value))) + case models.CriterionModifierEquals: + // includes only the provided ids + m.addJoinTable(f) + whereClause = utils.StrFormat("{joinAlias}.{foreignFK} IN {inBinding} AND (SELECT COUNT(*) FROM {joinTable} s WHERE s.{primaryFK} = {primaryTable}.id) = ?", utils.StrFormatMap{ + "joinAlias": joinAlias, + "foreignFK": m.foreignFK, + "inBinding": getInBinding(len(criterion.Value)), + "joinTable": m.joinTable, + "primaryFK": m.primaryFK, + "primaryTable": m.primaryTable, + }) + havingClause = fmt.Sprintf("count(distinct %s.%s) IS %d", joinAlias, m.foreignFK, len(criterion.Value)) + args = append(args, len(criterion.Value)) + case models.CriterionModifierNotEquals: + f.setError(fmt.Errorf("not equals modifier is not supported for multi criterion input")) + case models.CriterionModifierIncludesAll: + // includes all of the provided ids + m.addJoinTable(f) + whereClause = fmt.Sprintf("%s.%s IN %s", joinAlias, m.foreignFK, getInBinding(len(criterion.Value))) + havingClause = fmt.Sprintf("count(distinct %s.%s) IS %d", joinAlias, m.foreignFK, len(criterion.Value)) + } + + f.addWhere(whereClause, args...) + f.addHaving(havingClause) + } + + if len(criterion.Excludes) > 0 { + var args []interface{} + for _, tagID := range criterion.Excludes { + args = append(args, tagID) + } + + // excludes all of the provided ids + // need to use actual join table name for this + // .id NOT IN (select . from where . in ) + whereClause := fmt.Sprintf("%[1]s.id NOT IN (SELECT %[3]s.%[2]s from %[3]s where %[3]s.%[4]s in %[5]s)", m.primaryTable, m.primaryFK, m.joinTable, m.foreignFK, getInBinding(len(criterion.Excludes))) + + f.addWhere(whereClause, args...) + } + } + } +} + +type multiCriterionHandlerBuilder struct { + primaryTable string + foreignTable string + joinTable string + primaryFK string + foreignFK string + + // function that will be called to perform any necessary joins + addJoinsFunc func(f *filterBuilder) +} + +func (m *multiCriterionHandlerBuilder) handler(criterion *models.MultiCriterionInput) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if criterion != nil { + if criterion.Modifier == models.CriterionModifierIsNull || criterion.Modifier == models.CriterionModifierNotNull { + var notClause string + if criterion.Modifier == models.CriterionModifierNotNull { + notClause = "NOT" + } + + table := m.primaryTable + if m.joinTable != "" { + table = m.joinTable + f.addLeftJoin(table, "", fmt.Sprintf("%s.%s = %s.id", table, m.primaryFK, m.primaryTable)) + } + + f.addWhere(fmt.Sprintf("%s.%s IS %s NULL", table, m.foreignFK, notClause)) + return + } + + if len(criterion.Value) == 0 { + return + } + + var args []interface{} + for _, tagID := range criterion.Value { + args = append(args, tagID) + } + + if m.addJoinsFunc != nil { + m.addJoinsFunc(f) + } + + whereClause, havingClause := getMultiCriterionClause(m.primaryTable, m.foreignTable, m.joinTable, m.primaryFK, m.foreignFK, criterion) + f.addWhere(whereClause, args...) + f.addHaving(havingClause) + } + } +} + +type countCriterionHandlerBuilder struct { + primaryTable string + joinTable string + primaryFK string +} + +func (m *countCriterionHandlerBuilder) handler(criterion *models.IntCriterionInput) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if criterion != nil { + clause, args := getCountCriterionClause(m.primaryTable, m.joinTable, m.primaryFK, *criterion) + + f.addWhere(clause, args...) + } + } +} + +// handler for StringCriterion for string list fields +type stringListCriterionHandlerBuilder struct { + // table joining primary and foreign objects + joinTable string + // string field on the join table + stringColumn string + + addJoinTable func(f *filterBuilder) +} + +func (m *stringListCriterionHandlerBuilder) handler(criterion *models.StringCriterionInput) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if criterion != nil { + m.addJoinTable(f) + + stringCriterionHandler(criterion, m.joinTable+"."+m.stringColumn)(ctx, f) + } + } +} + +func studioCriterionHandler(primaryTable string, studios *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if studios == nil { + return + } + + studiosCopy := *studios + switch studiosCopy.Modifier { + case models.CriterionModifierEquals: + studiosCopy.Modifier = models.CriterionModifierIncludesAll + case models.CriterionModifierNotEquals: + studiosCopy.Modifier = models.CriterionModifierExcludes + } + + hh := hierarchicalMultiCriterionHandlerBuilder{ + primaryTable: primaryTable, + foreignTable: studioTable, + foreignFK: studioIDColumn, + parentFK: "parent_id", + } + + hh.handler(&studiosCopy)(ctx, f) + } +} + +type hierarchicalMultiCriterionHandlerBuilder struct { + primaryTable string + foreignTable string + foreignFK string + + parentFK string + childFK string + relationsTable string +} + +func getHierarchicalValues(ctx context.Context, values []string, table, relationsTable, parentFK string, childFK string, depth *int) (string, error) { + var args []interface{} + + if parentFK == "" { + parentFK = "parent_id" + } + if childFK == "" { + childFK = "child_id" + } + + depthVal := 0 + if depth != nil { + depthVal = *depth + } + + if depthVal == 0 { + valid := true + var valuesClauses []string + for _, value := range values { + id, err := strconv.Atoi(value) + // In case of invalid value just run the query. + // Building VALUES() based on provided values just saves a query when depth is 0. + if err != nil { + valid = false + break + } + + valuesClauses = append(valuesClauses, fmt.Sprintf("(%d,%d)", id, id)) + } + + if valid { + return "VALUES" + strings.Join(valuesClauses, ","), nil + } + } + + for _, value := range values { + args = append(args, value) + } + inCount := len(args) + + var depthCondition string + if depthVal != -1 { + depthCondition = fmt.Sprintf("WHERE depth < %d", depthVal) + } + + withClauseMap := utils.StrFormatMap{ + "table": table, + "relationsTable": relationsTable, + "inBinding": getInBinding(inCount), + "recursiveSelect": "", + "parentFK": parentFK, + "childFK": childFK, + "depthCondition": depthCondition, + "unionClause": "", + } + + if relationsTable != "" { + withClauseMap["recursiveSelect"] = utils.StrFormat(`SELECT p.root_id, c.{childFK}, depth + 1 FROM {relationsTable} AS c +INNER JOIN items as p ON c.{parentFK} = p.item_id +`, withClauseMap) + } else { + withClauseMap["recursiveSelect"] = utils.StrFormat(`SELECT p.root_id, c.id, depth + 1 FROM {table} as c +INNER JOIN items as p ON c.{parentFK} = p.item_id +`, withClauseMap) + } + + if depthVal != 0 { + withClauseMap["unionClause"] = utils.StrFormat(` +UNION {recursiveSelect} {depthCondition} +`, withClauseMap) + } + + withClause := utils.StrFormat(`items AS ( +SELECT id as root_id, id as item_id, 0 as depth FROM {table} +WHERE id in {inBinding} +{unionClause}) +`, withClauseMap) + + query := fmt.Sprintf("WITH RECURSIVE %s SELECT 'VALUES' || GROUP_CONCAT('(' || root_id || ', ' || item_id || ')') AS val FROM items", withClause) + + var valuesClause sql.NullString + err := dbWrapper.Get(ctx, &valuesClause, query, args...) + if err != nil { + return "", fmt.Errorf("failed to get hierarchical values: %w", err) + } + + // if no values are found, just return a values string with the values only + if !valuesClause.Valid { + for i, value := range values { + values[i] = fmt.Sprintf("(%s, %s)", value, value) + } + valuesClause.String = "VALUES" + strings.Join(values, ",") + } + + return valuesClause.String, nil +} + +func addHierarchicalConditionClauses(f *filterBuilder, criterion models.HierarchicalMultiCriterionInput, table, idColumn string) { + switch criterion.Modifier { + case models.CriterionModifierIncludes: + f.addWhere(fmt.Sprintf("%s.%s IS NOT NULL", table, idColumn)) + case models.CriterionModifierIncludesAll: + f.addWhere(fmt.Sprintf("%s.%s IS NOT NULL", table, idColumn)) + f.addHaving(fmt.Sprintf("count(distinct %s.%s) IS %d", table, idColumn, len(criterion.Value))) + case models.CriterionModifierExcludes: + f.addWhere(fmt.Sprintf("%s.%s IS NULL", table, idColumn)) + } +} + +func (m *hierarchicalMultiCriterionHandlerBuilder) handler(c *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if c != nil { + // make a copy so we don't modify the original + criterion := *c + + // don't support equals/not equals + if criterion.Modifier == models.CriterionModifierEquals || criterion.Modifier == models.CriterionModifierNotEquals { + f.setError(fmt.Errorf("modifier %s is not supported for hierarchical multi criterion", criterion.Modifier)) + return + } + + if criterion.Modifier == models.CriterionModifierIsNull || criterion.Modifier == models.CriterionModifierNotNull { + var notClause string + if criterion.Modifier == models.CriterionModifierNotNull { + notClause = "NOT" + } + + f.addWhere(utils.StrFormat("{table}.{column} IS {not} NULL", utils.StrFormatMap{ + "table": m.primaryTable, + "column": m.foreignFK, + "not": notClause, + })) + return + } + + if len(criterion.Value) == 0 && len(criterion.Excludes) == 0 { + return + } + + // combine excludes if excludes modifier is selected + if criterion.Modifier == models.CriterionModifierExcludes { + criterion.Modifier = models.CriterionModifierIncludesAll + criterion.Excludes = append(criterion.Excludes, criterion.Value...) + criterion.Value = nil + } + + if len(criterion.Value) > 0 { + valuesClause, err := getHierarchicalValues(ctx, criterion.Value, m.foreignTable, m.relationsTable, m.parentFK, m.childFK, criterion.Depth) + if err != nil { + f.setError(err) + return + } + + switch criterion.Modifier { + case models.CriterionModifierIncludes: + f.addWhere(fmt.Sprintf("%s.%s IN (SELECT column2 FROM (%s))", m.primaryTable, m.foreignFK, valuesClause)) + case models.CriterionModifierIncludesAll: + f.addWhere(fmt.Sprintf("%s.%s IN (SELECT column2 FROM (%s))", m.primaryTable, m.foreignFK, valuesClause)) + f.addHaving(fmt.Sprintf("count(distinct %s.%s) IS %d", m.primaryTable, m.foreignFK, len(criterion.Value))) + } + } + + if len(criterion.Excludes) > 0 { + valuesClause, err := getHierarchicalValues(ctx, criterion.Excludes, m.foreignTable, m.relationsTable, m.parentFK, m.childFK, criterion.Depth) + if err != nil { + f.setError(err) + return + } + + f.addWhere(fmt.Sprintf("%s.%s NOT IN (SELECT column2 FROM (%s)) OR %[1]s.%[2]s IS NULL", m.primaryTable, m.foreignFK, valuesClause)) + } + } + } +} + +type joinedHierarchicalMultiCriterionHandlerBuilder struct { + primaryTable string + primaryKey string + foreignTable string + foreignFK string + + parentFK string + childFK string + relationsTable string + + joinAs string + joinTable string + primaryFK string +} + +func (m *joinedHierarchicalMultiCriterionHandlerBuilder) addHierarchicalConditionClauses(f *filterBuilder, criterion models.HierarchicalMultiCriterionInput, table, idColumn string) { + primaryKey := m.primaryKey + if primaryKey == "" { + primaryKey = "id" + } + + switch criterion.Modifier { + case models.CriterionModifierEquals: + // includes only the provided ids + f.addWhere(fmt.Sprintf("%s.%s IS NOT NULL", table, idColumn)) + f.addHaving(fmt.Sprintf("count(distinct %s.%s) IS %d", table, idColumn, len(criterion.Value))) + f.addWhere(utils.StrFormat("(SELECT COUNT(*) FROM {joinTable} s WHERE s.{primaryFK} = {primaryTable}.{primaryKey}) = ?", utils.StrFormatMap{ + "joinTable": m.joinTable, + "primaryFK": m.primaryFK, + "primaryTable": m.primaryTable, + "primaryKey": primaryKey, + }), len(criterion.Value)) + case models.CriterionModifierNotEquals: + f.setError(fmt.Errorf("not equals modifier is not supported for hierarchical multi criterion input")) + default: + addHierarchicalConditionClauses(f, criterion, table, idColumn) + } +} + +func (m *joinedHierarchicalMultiCriterionHandlerBuilder) handler(c *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if c != nil { + // make a copy so we don't modify the original + criterion := *c + joinAlias := m.joinAs + primaryKey := m.primaryKey + if primaryKey == "" { + primaryKey = "id" + } + + if criterion.Modifier == models.CriterionModifierEquals && criterion.Depth != nil && *criterion.Depth != 0 { + f.setError(fmt.Errorf("depth is not supported for equals modifier in hierarchical multi criterion input")) + return + } + + if criterion.Modifier == models.CriterionModifierIsNull || criterion.Modifier == models.CriterionModifierNotNull { + var notClause string + if criterion.Modifier == models.CriterionModifierNotNull { + notClause = "NOT" + } + + f.addLeftJoin(m.joinTable, joinAlias, fmt.Sprintf("%s.%s = %s.%s", joinAlias, m.primaryFK, m.primaryTable, primaryKey)) + + f.addWhere(utils.StrFormat("{table}.{column} IS {not} NULL", utils.StrFormatMap{ + "table": joinAlias, + "column": m.foreignFK, + "not": notClause, + })) + return + } + + // combine excludes if excludes modifier is selected + if criterion.Modifier == models.CriterionModifierExcludes { + criterion.Modifier = models.CriterionModifierIncludesAll + criterion.Excludes = append(criterion.Excludes, criterion.Value...) + criterion.Value = nil + } + + if len(criterion.Value) == 0 && len(criterion.Excludes) == 0 { + return + } + + if len(criterion.Value) > 0 { + valuesClause, err := getHierarchicalValues(ctx, criterion.Value, m.foreignTable, m.relationsTable, m.parentFK, m.childFK, criterion.Depth) + if err != nil { + f.setError(err) + return + } + + joinTable := utils.StrFormat(`( + SELECT j.*, d.column1 AS root_id, d.column2 AS item_id FROM {joinTable} AS j + INNER JOIN ({valuesClause}) AS d ON j.{foreignFK} = d.column2 + ) + `, utils.StrFormatMap{ + "joinTable": m.joinTable, + "foreignFK": m.foreignFK, + "valuesClause": valuesClause, + }) + + f.addLeftJoin(joinTable, joinAlias, fmt.Sprintf("%s.%s = %s.%s", joinAlias, m.primaryFK, m.primaryTable, primaryKey)) + + m.addHierarchicalConditionClauses(f, criterion, joinAlias, "root_id") + } + + if len(criterion.Excludes) > 0 { + valuesClause, err := getHierarchicalValues(ctx, criterion.Excludes, m.foreignTable, m.relationsTable, m.parentFK, m.childFK, criterion.Depth) + if err != nil { + f.setError(err) + return + } + + joinTable := utils.StrFormat(`( + SELECT j2.*, e.column1 AS root_id, e.column2 AS item_id FROM {joinTable} AS j2 + INNER JOIN ({valuesClause}) AS e ON j2.{foreignFK} = e.column2 + ) + `, utils.StrFormatMap{ + "joinTable": m.joinTable, + "foreignFK": m.foreignFK, + "valuesClause": valuesClause, + }) + + joinAlias2 := joinAlias + "2" + + f.addLeftJoin(joinTable, joinAlias2, fmt.Sprintf("%s.%s = %s.%s", joinAlias2, m.primaryFK, m.primaryTable, primaryKey)) + + // modify for exclusion + criterionCopy := criterion + criterionCopy.Modifier = models.CriterionModifierExcludes + criterionCopy.Value = c.Excludes + + m.addHierarchicalConditionClauses(f, criterionCopy, joinAlias2, "root_id") + } + } + } +} + +type joinedPerformerTagsHandler struct { + criterion *models.HierarchicalMultiCriterionInput + + primaryTable string // eg scenes + joinTable string // eg performers_scenes + joinPrimaryKey string // eg scene_id +} + +func (h *joinedPerformerTagsHandler) handle(ctx context.Context, f *filterBuilder) { + tags := h.criterion + + if tags != nil { + criterion := tags.CombineExcludes() + + // validate the modifier + switch criterion.Modifier { + case models.CriterionModifierIncludesAll, models.CriterionModifierIncludes, models.CriterionModifierExcludes, models.CriterionModifierIsNull, models.CriterionModifierNotNull: + // valid + default: + f.setError(fmt.Errorf("invalid modifier %s for performer tags", criterion.Modifier)) + } + + strFormatMap := utils.StrFormatMap{ + "primaryTable": h.primaryTable, + "joinTable": h.joinTable, + "joinPrimaryKey": h.joinPrimaryKey, + "inBinding": getInBinding(len(criterion.Value)), + } + + if criterion.Modifier == models.CriterionModifierIsNull || criterion.Modifier == models.CriterionModifierNotNull { + var notClause string + if criterion.Modifier == models.CriterionModifierNotNull { + notClause = "NOT" + } + + f.addLeftJoin(h.joinTable, "", utils.StrFormat("{primaryTable}.id = {joinTable}.{joinPrimaryKey}", strFormatMap)) + f.addLeftJoin("performers_tags", "", utils.StrFormat("{joinTable}.performer_id = performers_tags.performer_id", strFormatMap)) + + f.addWhere(fmt.Sprintf("performers_tags.tag_id IS %s NULL", notClause)) + return + } + + if len(criterion.Value) == 0 && len(criterion.Excludes) == 0 { + return + } + + if len(criterion.Value) > 0 { + valuesClause, err := getHierarchicalValues(ctx, criterion.Value, tagTable, "tags_relations", "", "", criterion.Depth) + if err != nil { + f.setError(err) + return + } + + f.addWith(utils.StrFormat(`performer_tags AS ( +SELECT ps.{joinPrimaryKey} as primaryID, t.column1 AS root_tag_id FROM {joinTable} ps +INNER JOIN performers_tags pt ON pt.performer_id = ps.performer_id +INNER JOIN (`+valuesClause+`) t ON t.column2 = pt.tag_id +)`, strFormatMap)) + + f.addLeftJoin("performer_tags", "", utils.StrFormat("performer_tags.primaryID = {primaryTable}.id", strFormatMap)) + + addHierarchicalConditionClauses(f, criterion, "performer_tags", "root_tag_id") + } + + if len(criterion.Excludes) > 0 { + valuesClause, err := getHierarchicalValues(ctx, criterion.Excludes, tagTable, "tags_relations", "", "", criterion.Depth) + if err != nil { + f.setError(err) + return + } + + clause := utils.StrFormat("{primaryTable}.id NOT IN (SELECT {joinTable}.{joinPrimaryKey} FROM {joinTable} INNER JOIN performers_tags ON {joinTable}.performer_id = performers_tags.performer_id WHERE performers_tags.tag_id IN (SELECT column2 FROM (%s)))", strFormatMap) + f.addWhere(fmt.Sprintf(clause, valuesClause)) + } + } +} + +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) +} + +type relatedFilterHandler struct { + relatedIDCol string + relatedRepo repository + relatedHandler criterionHandler + joinFn func(f *filterBuilder) +} + +func (h *relatedFilterHandler) handle(ctx context.Context, f *filterBuilder) { + ff := filterBuilderFromHandler(ctx, h.relatedHandler) + if ff.err != nil { + f.setError(ff.err) + return + } + + if ff.empty() { + return + } + + subQuery := h.relatedRepo.newQuery() + selectIDs(&subQuery, subQuery.repository.tableName) + if err := subQuery.addFilter(ff); err != nil { + f.setError(err) + return + } + + if h.joinFn != nil { + h.joinFn(f) + } + + f.addWhere(fmt.Sprintf("%s IN ("+subQuery.toSQL(false)+")", h.relatedIDCol), subQuery.args...) +} diff --git a/pkg/sqlite/database.go b/pkg/sqlite/database.go index 90d3706a544..4da53c3528e 100644 --- a/pkg/sqlite/database.go +++ b/pkg/sqlite/database.go @@ -61,7 +61,7 @@ func (e *MismatchedSchemaVersionError) Error() string { return fmt.Sprintf("schema version %d is incompatible with required schema version %d", e.CurrentSchemaVersion, e.RequiredSchemaVersion) } -type Database struct { +type storeRepository struct { Blobs *BlobStore File *FileStore Folder *FolderStore @@ -75,6 +75,10 @@ type Database struct { Studio *StudioStore Tag *TagStore Movie *MovieStore +} + +type Database struct { + *storeRepository db *sqlx.DB dbPath string @@ -87,23 +91,32 @@ type Database struct { func NewDatabase() *Database { fileStore := NewFileStore() folderStore := NewFolderStore() + galleryStore := NewGalleryStore(fileStore, folderStore) blobStore := NewBlobStore(BlobStoreOptions{}) + performerStore := NewPerformerStore(blobStore) + studioStore := NewStudioStore(blobStore) + tagStore := NewTagStore(blobStore) - ret := &Database{ + r := &storeRepository{} + *r = storeRepository{ Blobs: blobStore, File: fileStore, Folder: folderStore, - Scene: NewSceneStore(fileStore, blobStore), + Scene: NewSceneStore(r, blobStore), SceneMarker: NewSceneMarkerStore(), - Image: NewImageStore(fileStore), - Gallery: NewGalleryStore(fileStore, folderStore), + Image: NewImageStore(r), + Gallery: galleryStore, GalleryChapter: NewGalleryChapterStore(), - Performer: NewPerformerStore(blobStore), - Studio: NewStudioStore(blobStore), - Tag: NewTagStore(blobStore), + Performer: performerStore, + Studio: studioStore, + Tag: tagStore, Movie: NewMovieStore(blobStore), SavedFilter: NewSavedFilterStore(), - lockChan: make(chan struct{}, 1), + } + + ret := &Database{ + storeRepository: r, + lockChan: make(chan struct{}, 1), } return ret @@ -370,7 +383,7 @@ func (db *Database) Analyze(ctx context.Context) error { } func (db *Database) ExecSQL(ctx context.Context, query string, args []interface{}) (*int64, *int64, error) { - wrapper := dbWrapper{} + wrapper := dbWrapperType{} result, err := wrapper.Exec(ctx, query, args...) if err != nil { @@ -393,7 +406,7 @@ func (db *Database) ExecSQL(ctx context.Context, query string, args []interface{ } func (db *Database) QuerySQL(ctx context.Context, query string, args []interface{}) ([]string, [][]interface{}, error) { - wrapper := dbWrapper{} + wrapper := dbWrapperType{} rows, err := wrapper.QueryxContext(ctx, query, args...) if err != nil && !errors.Is(err, sql.ErrNoRows) { diff --git a/pkg/sqlite/file.go b/pkg/sqlite/file.go index c071320c6bf..6cd74eb34cd 100644 --- a/pkg/sqlite/file.go +++ b/pkg/sqlite/file.go @@ -947,7 +947,6 @@ func (qb *FileStore) setQuerySort(query *queryBuilder, findFilter *models.FindFi func (qb *FileStore) captionRepository() *captionRepository { return &captionRepository{ repository: repository{ - tx: qb.tx, tableName: videoCaptionsTable, idColumn: fileIDColumn, }, diff --git a/pkg/sqlite/filter.go b/pkg/sqlite/filter.go index abf3336a7a3..f4b5e7e7726 100644 --- a/pkg/sqlite/filter.go +++ b/pkg/sqlite/filter.go @@ -2,19 +2,55 @@ package sqlite import ( "context" - "database/sql" "errors" "fmt" - "path/filepath" - "regexp" - "strconv" "strings" - "github.com/stashapp/stash/pkg/utils" - "github.com/stashapp/stash/pkg/models" ) +func illegalFilterCombination(type1, type2 string) error { + return fmt.Errorf("cannot have %s and %s in the same filter", type1, type2) +} + +func validateFilterCombination[T any](sf models.OperatorFilter[T]) error { + const and = "AND" + const or = "OR" + const not = "NOT" + + if sf.And != nil { + if sf.Or != nil { + return illegalFilterCombination(and, or) + } + if sf.Not != nil { + return illegalFilterCombination(and, not) + } + } + + if sf.Or != nil { + if sf.Not != nil { + return illegalFilterCombination(or, not) + } + } + + return nil +} + +func handleSubFilter[T any](ctx context.Context, handler criterionHandler, f *filterBuilder, subFilter models.OperatorFilter[T]) { + subQuery := &filterBuilder{} + handler.handle(ctx, subQuery) + + if subFilter.And != nil { + f.and(subQuery) + } + if subFilter.Or != nil { + f.or(subQuery) + } + if subFilter.Not != nil { + f.not(subQuery) + } +} + type sqlClause struct { sql string args []interface{} @@ -54,16 +90,6 @@ func andClauses(clauses ...sqlClause) sqlClause { return joinClauses("AND", clauses...) } -type criterionHandler interface { - handle(ctx context.Context, f *filterBuilder) -} - -type criterionHandlerFunc func(ctx context.Context, f *filterBuilder) - -func (h criterionHandlerFunc) handle(ctx context.Context, f *filterBuilder) { - h(ctx, f) -} - type join struct { table string as string @@ -143,6 +169,16 @@ type filterBuilder struct { err error } +func (f *filterBuilder) empty() bool { + return f == nil || (len(f.whereClauses) == 0 && len(f.joins) == 0 && len(f.havingClauses) == 0 && f.subFilter == nil) +} + +func filterBuilderFromHandler(ctx context.Context, handler criterionHandler) *filterBuilder { + f := &filterBuilder{} + handler.handle(ctx, f) + return f +} + var errSubFilterAlreadySet = errors.New(`sub-filter already set`) // sub-filter operator values @@ -388,876 +424,3 @@ func (f *filterBuilder) andClauses(input []sqlClause) (string, []interface{}) { return "", nil } - -func stringCriterionHandler(c *models.StringCriterionInput, column string) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if c != nil { - if modifier := c.Modifier; c.Modifier.IsValid() { - switch modifier { - case models.CriterionModifierIncludes: - f.whereClauses = append(f.whereClauses, getStringSearchClause([]string{column}, c.Value, false)) - case models.CriterionModifierExcludes: - f.whereClauses = append(f.whereClauses, getStringSearchClause([]string{column}, c.Value, true)) - case models.CriterionModifierEquals: - f.addWhere(column+" LIKE ?", c.Value) - case models.CriterionModifierNotEquals: - f.addWhere(column+" NOT LIKE ?", c.Value) - case models.CriterionModifierMatchesRegex: - if _, err := regexp.Compile(c.Value); err != nil { - f.setError(err) - return - } - f.addWhere(fmt.Sprintf("(%s IS NOT NULL AND %[1]s regexp ?)", column), 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 %[1]s NOT regexp ?)", column), c.Value) - case models.CriterionModifierIsNull: - f.addWhere("(" + column + " IS NULL OR TRIM(" + column + ") = '')") - case models.CriterionModifierNotNull: - f.addWhere("(" + column + " IS NOT NULL AND TRIM(" + column + ") != '')") - default: - panic("unsupported string filter modifier") - } - } - } - } -} - -func enumCriterionHandler(modifier models.CriterionModifier, values []string, column string) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if modifier.IsValid() { - switch modifier { - case models.CriterionModifierIncludes, models.CriterionModifierEquals: - if len(values) > 0 { - f.whereClauses = append(f.whereClauses, getEnumSearchClause(column, values, false)) - } - case models.CriterionModifierExcludes, models.CriterionModifierNotEquals: - if len(values) > 0 { - f.whereClauses = append(f.whereClauses, getEnumSearchClause(column, values, true)) - } - case models.CriterionModifierIsNull: - f.addWhere("(" + column + " IS NULL OR TRIM(" + column + ") = '')") - case models.CriterionModifierNotNull: - f.addWhere("(" + column + " IS NOT NULL AND TRIM(" + column + ") != '')") - default: - panic("unsupported string filter modifier") - } - } - } -} - -func pathCriterionHandler(c *models.StringCriterionInput, pathColumn string, basenameColumn string, addJoinFn func(f *filterBuilder)) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if c != nil { - if addJoinFn != nil { - addJoinFn(f) - } - addWildcards := true - not := false - - if modifier := c.Modifier; c.Modifier.IsValid() { - switch modifier { - case models.CriterionModifierIncludes: - f.whereClauses = append(f.whereClauses, getPathSearchClauseMany(pathColumn, basenameColumn, c.Value, addWildcards, not)) - case models.CriterionModifierExcludes: - not = true - f.whereClauses = append(f.whereClauses, getPathSearchClauseMany(pathColumn, basenameColumn, c.Value, addWildcards, not)) - case models.CriterionModifierEquals: - addWildcards = false - f.whereClauses = append(f.whereClauses, getPathSearchClause(pathColumn, basenameColumn, c.Value, addWildcards, not)) - case models.CriterionModifierNotEquals: - addWildcards = false - not = true - f.whereClauses = append(f.whereClauses, getPathSearchClause(pathColumn, basenameColumn, c.Value, addWildcards, not)) - case models.CriterionModifierMatchesRegex: - if _, err := regexp.Compile(c.Value); err != nil { - f.setError(err) - return - } - 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 - } - 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)) - 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)) - default: - panic("unsupported string filter modifier") - } - } - } - } -} - -func getPathSearchClause(pathColumn, basenameColumn, p string, addWildcards, not bool) sqlClause { - if addWildcards { - p = "%" + p + "%" - } - - filepathColumn := fmt.Sprintf("%s || '%s' || %s", pathColumn, string(filepath.Separator), basenameColumn) - ret := makeClause(fmt.Sprintf("%s LIKE ?", filepathColumn), p) - - if not { - ret = ret.not() - } - - return ret -} - -// getPathSearchClauseMany splits the query string p on whitespace -// Used for backwards compatibility for the includes/excludes modifiers -func getPathSearchClauseMany(pathColumn, basenameColumn, p string, addWildcards, not bool) sqlClause { - q := strings.TrimSpace(p) - trimmedQuery := strings.Trim(q, "\"") - - if trimmedQuery == q { - q = regexp.MustCompile(`\s+`).ReplaceAllString(q, " ") - queryWords := strings.Split(q, " ") - - var ret []sqlClause - // Search for any word - for _, word := range queryWords { - ret = append(ret, getPathSearchClause(pathColumn, basenameColumn, word, addWildcards, not)) - } - - if !not { - return orClauses(ret...) - } - - return andClauses(ret...) - } - - return getPathSearchClause(pathColumn, basenameColumn, trimmedQuery, addWildcards, not) -} - -func intCriterionHandler(c *models.IntCriterionInput, column string, addJoinFn func(f *filterBuilder)) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if c != nil { - if addJoinFn != nil { - addJoinFn(f) - } - clause, args := getIntCriterionWhereClause(column, *c) - f.addWhere(clause, args...) - } - } -} - -func floatCriterionHandler(c *models.FloatCriterionInput, column string, addJoinFn func(f *filterBuilder)) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if c != nil { - if addJoinFn != nil { - addJoinFn(f) - } - clause, args := getFloatCriterionWhereClause(column, *c) - f.addWhere(clause, args...) - } - } -} - -func boolCriterionHandler(c *bool, column string, addJoinFn func(f *filterBuilder)) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if c != nil { - if addJoinFn != nil { - addJoinFn(f) - } - var v string - if *c { - v = "1" - } else { - v = "0" - } - - f.addWhere(column + " = " + v) - } - } -} - -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 { - // table containing the primary objects - primaryTable string - // table joining primary and foreign objects - joinTable string - // alias for join table, if required - joinAs string - // foreign key of the primary object on the join table - primaryFK string - // foreign key of the foreign object on the join table - foreignFK string - - addJoinTable func(f *filterBuilder) -} - -func (m *joinedMultiCriterionHandlerBuilder) handler(c *models.MultiCriterionInput) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if c != nil { - // make local copy so we can modify it - criterion := *c - - joinAlias := m.joinAs - if joinAlias == "" { - joinAlias = m.joinTable - } - - if criterion.Modifier == models.CriterionModifierIsNull || criterion.Modifier == models.CriterionModifierNotNull { - var notClause string - if criterion.Modifier == models.CriterionModifierNotNull { - notClause = "NOT" - } - - m.addJoinTable(f) - - f.addWhere(utils.StrFormat("{table}.{column} IS {not} NULL", utils.StrFormatMap{ - "table": joinAlias, - "column": m.foreignFK, - "not": notClause, - })) - return - } - - if len(criterion.Value) == 0 && len(criterion.Excludes) == 0 { - return - } - - // combine excludes if excludes modifier is selected - if criterion.Modifier == models.CriterionModifierExcludes { - criterion.Modifier = models.CriterionModifierIncludesAll - criterion.Excludes = append(criterion.Excludes, criterion.Value...) - criterion.Value = nil - } - - if len(criterion.Value) > 0 { - whereClause := "" - havingClause := "" - - var args []interface{} - for _, tagID := range criterion.Value { - args = append(args, tagID) - } - - switch criterion.Modifier { - case models.CriterionModifierIncludes: - // includes any of the provided ids - m.addJoinTable(f) - whereClause = fmt.Sprintf("%s.%s IN %s", joinAlias, m.foreignFK, getInBinding(len(criterion.Value))) - case models.CriterionModifierEquals: - // includes only the provided ids - m.addJoinTable(f) - whereClause = utils.StrFormat("{joinAlias}.{foreignFK} IN {inBinding} AND (SELECT COUNT(*) FROM {joinTable} s WHERE s.{primaryFK} = {primaryTable}.id) = ?", utils.StrFormatMap{ - "joinAlias": joinAlias, - "foreignFK": m.foreignFK, - "inBinding": getInBinding(len(criterion.Value)), - "joinTable": m.joinTable, - "primaryFK": m.primaryFK, - "primaryTable": m.primaryTable, - }) - havingClause = fmt.Sprintf("count(distinct %s.%s) IS %d", joinAlias, m.foreignFK, len(criterion.Value)) - args = append(args, len(criterion.Value)) - case models.CriterionModifierNotEquals: - f.setError(fmt.Errorf("not equals modifier is not supported for multi criterion input")) - case models.CriterionModifierIncludesAll: - // includes all of the provided ids - m.addJoinTable(f) - whereClause = fmt.Sprintf("%s.%s IN %s", joinAlias, m.foreignFK, getInBinding(len(criterion.Value))) - havingClause = fmt.Sprintf("count(distinct %s.%s) IS %d", joinAlias, m.foreignFK, len(criterion.Value)) - } - - f.addWhere(whereClause, args...) - f.addHaving(havingClause) - } - - if len(criterion.Excludes) > 0 { - var args []interface{} - for _, tagID := range criterion.Excludes { - args = append(args, tagID) - } - - // excludes all of the provided ids - // need to use actual join table name for this - // .id NOT IN (select . from where . in ) - whereClause := fmt.Sprintf("%[1]s.id NOT IN (SELECT %[3]s.%[2]s from %[3]s where %[3]s.%[4]s in %[5]s)", m.primaryTable, m.primaryFK, m.joinTable, m.foreignFK, getInBinding(len(criterion.Excludes))) - - f.addWhere(whereClause, args...) - } - } - } -} - -type multiCriterionHandlerBuilder struct { - primaryTable string - foreignTable string - joinTable string - primaryFK string - foreignFK string - - // function that will be called to perform any necessary joins - addJoinsFunc func(f *filterBuilder) -} - -func (m *multiCriterionHandlerBuilder) handler(criterion *models.MultiCriterionInput) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if criterion != nil { - if criterion.Modifier == models.CriterionModifierIsNull || criterion.Modifier == models.CriterionModifierNotNull { - var notClause string - if criterion.Modifier == models.CriterionModifierNotNull { - notClause = "NOT" - } - - table := m.primaryTable - if m.joinTable != "" { - table = m.joinTable - f.addLeftJoin(table, "", fmt.Sprintf("%s.%s = %s.id", table, m.primaryFK, m.primaryTable)) - } - - f.addWhere(fmt.Sprintf("%s.%s IS %s NULL", table, m.foreignFK, notClause)) - return - } - - if len(criterion.Value) == 0 { - return - } - - var args []interface{} - for _, tagID := range criterion.Value { - args = append(args, tagID) - } - - if m.addJoinsFunc != nil { - m.addJoinsFunc(f) - } - - whereClause, havingClause := getMultiCriterionClause(m.primaryTable, m.foreignTable, m.joinTable, m.primaryFK, m.foreignFK, criterion) - f.addWhere(whereClause, args...) - f.addHaving(havingClause) - } - } -} - -type countCriterionHandlerBuilder struct { - primaryTable string - joinTable string - primaryFK string -} - -func (m *countCriterionHandlerBuilder) handler(criterion *models.IntCriterionInput) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if criterion != nil { - clause, args := getCountCriterionClause(m.primaryTable, m.joinTable, m.primaryFK, *criterion) - - f.addWhere(clause, args...) - } - } -} - -// handler for StringCriterion for string list fields -type stringListCriterionHandlerBuilder struct { - // table joining primary and foreign objects - joinTable string - // string field on the join table - stringColumn string - - addJoinTable func(f *filterBuilder) -} - -func (m *stringListCriterionHandlerBuilder) handler(criterion *models.StringCriterionInput) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if criterion != nil { - m.addJoinTable(f) - - stringCriterionHandler(criterion, m.joinTable+"."+m.stringColumn)(ctx, f) - } - } -} - -func studioCriterionHandler(primaryTable string, studios *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if studios == nil { - return - } - - studiosCopy := *studios - switch studiosCopy.Modifier { - case models.CriterionModifierEquals: - studiosCopy.Modifier = models.CriterionModifierIncludesAll - case models.CriterionModifierNotEquals: - studiosCopy.Modifier = models.CriterionModifierExcludes - } - - hh := hierarchicalMultiCriterionHandlerBuilder{ - tx: dbWrapper{}, - - primaryTable: primaryTable, - foreignTable: studioTable, - foreignFK: studioIDColumn, - parentFK: "parent_id", - } - - hh.handler(&studiosCopy)(ctx, f) - } -} - -type hierarchicalMultiCriterionHandlerBuilder struct { - tx dbWrapper - - primaryTable string - foreignTable string - foreignFK string - - parentFK string - childFK string - relationsTable string -} - -func getHierarchicalValues(ctx context.Context, tx dbWrapper, values []string, table, relationsTable, parentFK string, childFK string, depth *int) (string, error) { - var args []interface{} - - if parentFK == "" { - parentFK = "parent_id" - } - if childFK == "" { - childFK = "child_id" - } - - depthVal := 0 - if depth != nil { - depthVal = *depth - } - - if depthVal == 0 { - valid := true - var valuesClauses []string - for _, value := range values { - id, err := strconv.Atoi(value) - // In case of invalid value just run the query. - // Building VALUES() based on provided values just saves a query when depth is 0. - if err != nil { - valid = false - break - } - - valuesClauses = append(valuesClauses, fmt.Sprintf("(%d,%d)", id, id)) - } - - if valid { - return "VALUES" + strings.Join(valuesClauses, ","), nil - } - } - - for _, value := range values { - args = append(args, value) - } - inCount := len(args) - - var depthCondition string - if depthVal != -1 { - depthCondition = fmt.Sprintf("WHERE depth < %d", depthVal) - } - - withClauseMap := utils.StrFormatMap{ - "table": table, - "relationsTable": relationsTable, - "inBinding": getInBinding(inCount), - "recursiveSelect": "", - "parentFK": parentFK, - "childFK": childFK, - "depthCondition": depthCondition, - "unionClause": "", - } - - if relationsTable != "" { - withClauseMap["recursiveSelect"] = utils.StrFormat(`SELECT p.root_id, c.{childFK}, depth + 1 FROM {relationsTable} AS c -INNER JOIN items as p ON c.{parentFK} = p.item_id -`, withClauseMap) - } else { - withClauseMap["recursiveSelect"] = utils.StrFormat(`SELECT p.root_id, c.id, depth + 1 FROM {table} as c -INNER JOIN items as p ON c.{parentFK} = p.item_id -`, withClauseMap) - } - - if depthVal != 0 { - withClauseMap["unionClause"] = utils.StrFormat(` -UNION {recursiveSelect} {depthCondition} -`, withClauseMap) - } - - withClause := utils.StrFormat(`items AS ( -SELECT id as root_id, id as item_id, 0 as depth FROM {table} -WHERE id in {inBinding} -{unionClause}) -`, withClauseMap) - - query := fmt.Sprintf("WITH RECURSIVE %s SELECT 'VALUES' || GROUP_CONCAT('(' || root_id || ', ' || item_id || ')') AS val FROM items", withClause) - - var valuesClause sql.NullString - err := tx.Get(ctx, &valuesClause, query, args...) - if err != nil { - return "", fmt.Errorf("failed to get hierarchical values: %w", err) - } - - // if no values are found, just return a values string with the values only - if !valuesClause.Valid { - for i, value := range values { - values[i] = fmt.Sprintf("(%s, %s)", value, value) - } - valuesClause.String = "VALUES" + strings.Join(values, ",") - } - - return valuesClause.String, nil -} - -func addHierarchicalConditionClauses(f *filterBuilder, criterion models.HierarchicalMultiCriterionInput, table, idColumn string) { - switch criterion.Modifier { - case models.CriterionModifierIncludes: - f.addWhere(fmt.Sprintf("%s.%s IS NOT NULL", table, idColumn)) - case models.CriterionModifierIncludesAll: - f.addWhere(fmt.Sprintf("%s.%s IS NOT NULL", table, idColumn)) - f.addHaving(fmt.Sprintf("count(distinct %s.%s) IS %d", table, idColumn, len(criterion.Value))) - case models.CriterionModifierExcludes: - f.addWhere(fmt.Sprintf("%s.%s IS NULL", table, idColumn)) - } -} - -func (m *hierarchicalMultiCriterionHandlerBuilder) handler(c *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if c != nil { - // make a copy so we don't modify the original - criterion := *c - - // don't support equals/not equals - if criterion.Modifier == models.CriterionModifierEquals || criterion.Modifier == models.CriterionModifierNotEquals { - f.setError(fmt.Errorf("modifier %s is not supported for hierarchical multi criterion", criterion.Modifier)) - return - } - - if criterion.Modifier == models.CriterionModifierIsNull || criterion.Modifier == models.CriterionModifierNotNull { - var notClause string - if criterion.Modifier == models.CriterionModifierNotNull { - notClause = "NOT" - } - - f.addWhere(utils.StrFormat("{table}.{column} IS {not} NULL", utils.StrFormatMap{ - "table": m.primaryTable, - "column": m.foreignFK, - "not": notClause, - })) - return - } - - if len(criterion.Value) == 0 && len(criterion.Excludes) == 0 { - return - } - - // combine excludes if excludes modifier is selected - if criterion.Modifier == models.CriterionModifierExcludes { - criterion.Modifier = models.CriterionModifierIncludesAll - criterion.Excludes = append(criterion.Excludes, criterion.Value...) - criterion.Value = nil - } - - if len(criterion.Value) > 0 { - valuesClause, err := getHierarchicalValues(ctx, m.tx, criterion.Value, m.foreignTable, m.relationsTable, m.parentFK, m.childFK, criterion.Depth) - if err != nil { - f.setError(err) - return - } - - switch criterion.Modifier { - case models.CriterionModifierIncludes: - f.addWhere(fmt.Sprintf("%s.%s IN (SELECT column2 FROM (%s))", m.primaryTable, m.foreignFK, valuesClause)) - case models.CriterionModifierIncludesAll: - f.addWhere(fmt.Sprintf("%s.%s IN (SELECT column2 FROM (%s))", m.primaryTable, m.foreignFK, valuesClause)) - f.addHaving(fmt.Sprintf("count(distinct %s.%s) IS %d", m.primaryTable, m.foreignFK, len(criterion.Value))) - } - } - - if len(criterion.Excludes) > 0 { - valuesClause, err := getHierarchicalValues(ctx, m.tx, criterion.Excludes, m.foreignTable, m.relationsTable, m.parentFK, m.childFK, criterion.Depth) - if err != nil { - f.setError(err) - return - } - - f.addWhere(fmt.Sprintf("%s.%s NOT IN (SELECT column2 FROM (%s)) OR %[1]s.%[2]s IS NULL", m.primaryTable, m.foreignFK, valuesClause)) - } - } - } -} - -type joinedHierarchicalMultiCriterionHandlerBuilder struct { - tx dbWrapper - - primaryTable string - primaryKey string - foreignTable string - foreignFK string - - parentFK string - childFK string - relationsTable string - - joinAs string - joinTable string - primaryFK string -} - -func (m *joinedHierarchicalMultiCriterionHandlerBuilder) addHierarchicalConditionClauses(f *filterBuilder, criterion models.HierarchicalMultiCriterionInput, table, idColumn string) { - primaryKey := m.primaryKey - if primaryKey == "" { - primaryKey = "id" - } - - switch criterion.Modifier { - case models.CriterionModifierEquals: - // includes only the provided ids - f.addWhere(fmt.Sprintf("%s.%s IS NOT NULL", table, idColumn)) - f.addHaving(fmt.Sprintf("count(distinct %s.%s) IS %d", table, idColumn, len(criterion.Value))) - f.addWhere(utils.StrFormat("(SELECT COUNT(*) FROM {joinTable} s WHERE s.{primaryFK} = {primaryTable}.{primaryKey}) = ?", utils.StrFormatMap{ - "joinTable": m.joinTable, - "primaryFK": m.primaryFK, - "primaryTable": m.primaryTable, - "primaryKey": primaryKey, - }), len(criterion.Value)) - case models.CriterionModifierNotEquals: - f.setError(fmt.Errorf("not equals modifier is not supported for hierarchical multi criterion input")) - default: - addHierarchicalConditionClauses(f, criterion, table, idColumn) - } -} - -func (m *joinedHierarchicalMultiCriterionHandlerBuilder) handler(c *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if c != nil { - // make a copy so we don't modify the original - criterion := *c - joinAlias := m.joinAs - primaryKey := m.primaryKey - if primaryKey == "" { - primaryKey = "id" - } - - if criterion.Modifier == models.CriterionModifierEquals && criterion.Depth != nil && *criterion.Depth != 0 { - f.setError(fmt.Errorf("depth is not supported for equals modifier in hierarchical multi criterion input")) - return - } - - if criterion.Modifier == models.CriterionModifierIsNull || criterion.Modifier == models.CriterionModifierNotNull { - var notClause string - if criterion.Modifier == models.CriterionModifierNotNull { - notClause = "NOT" - } - - f.addLeftJoin(m.joinTable, joinAlias, fmt.Sprintf("%s.%s = %s.%s", joinAlias, m.primaryFK, m.primaryTable, primaryKey)) - - f.addWhere(utils.StrFormat("{table}.{column} IS {not} NULL", utils.StrFormatMap{ - "table": joinAlias, - "column": m.foreignFK, - "not": notClause, - })) - return - } - - // combine excludes if excludes modifier is selected - if criterion.Modifier == models.CriterionModifierExcludes { - criterion.Modifier = models.CriterionModifierIncludesAll - criterion.Excludes = append(criterion.Excludes, criterion.Value...) - criterion.Value = nil - } - - if len(criterion.Value) == 0 && len(criterion.Excludes) == 0 { - return - } - - if len(criterion.Value) > 0 { - valuesClause, err := getHierarchicalValues(ctx, m.tx, criterion.Value, m.foreignTable, m.relationsTable, m.parentFK, m.childFK, criterion.Depth) - if err != nil { - f.setError(err) - return - } - - joinTable := utils.StrFormat(`( - SELECT j.*, d.column1 AS root_id, d.column2 AS item_id FROM {joinTable} AS j - INNER JOIN ({valuesClause}) AS d ON j.{foreignFK} = d.column2 - ) - `, utils.StrFormatMap{ - "joinTable": m.joinTable, - "foreignFK": m.foreignFK, - "valuesClause": valuesClause, - }) - - f.addLeftJoin(joinTable, joinAlias, fmt.Sprintf("%s.%s = %s.%s", joinAlias, m.primaryFK, m.primaryTable, primaryKey)) - - m.addHierarchicalConditionClauses(f, criterion, joinAlias, "root_id") - } - - if len(criterion.Excludes) > 0 { - valuesClause, err := getHierarchicalValues(ctx, m.tx, criterion.Excludes, m.foreignTable, m.relationsTable, m.parentFK, m.childFK, criterion.Depth) - if err != nil { - f.setError(err) - return - } - - joinTable := utils.StrFormat(`( - SELECT j2.*, e.column1 AS root_id, e.column2 AS item_id FROM {joinTable} AS j2 - INNER JOIN ({valuesClause}) AS e ON j2.{foreignFK} = e.column2 - ) - `, utils.StrFormatMap{ - "joinTable": m.joinTable, - "foreignFK": m.foreignFK, - "valuesClause": valuesClause, - }) - - joinAlias2 := joinAlias + "2" - - f.addLeftJoin(joinTable, joinAlias2, fmt.Sprintf("%s.%s = %s.%s", joinAlias2, m.primaryFK, m.primaryTable, primaryKey)) - - // modify for exclusion - criterionCopy := criterion - criterionCopy.Modifier = models.CriterionModifierExcludes - criterionCopy.Value = c.Excludes - - m.addHierarchicalConditionClauses(f, criterionCopy, joinAlias2, "root_id") - } - } - } -} - -type joinedPerformerTagsHandler struct { - criterion *models.HierarchicalMultiCriterionInput - - primaryTable string // eg scenes - joinTable string // eg performers_scenes - joinPrimaryKey string // eg scene_id -} - -func (h *joinedPerformerTagsHandler) handle(ctx context.Context, f *filterBuilder) { - tags := h.criterion - - if tags != nil { - criterion := tags.CombineExcludes() - - // validate the modifier - switch criterion.Modifier { - case models.CriterionModifierIncludesAll, models.CriterionModifierIncludes, models.CriterionModifierExcludes, models.CriterionModifierIsNull, models.CriterionModifierNotNull: - // valid - default: - f.setError(fmt.Errorf("invalid modifier %s for performer tags", criterion.Modifier)) - } - - strFormatMap := utils.StrFormatMap{ - "primaryTable": h.primaryTable, - "joinTable": h.joinTable, - "joinPrimaryKey": h.joinPrimaryKey, - "inBinding": getInBinding(len(criterion.Value)), - } - - if criterion.Modifier == models.CriterionModifierIsNull || criterion.Modifier == models.CriterionModifierNotNull { - var notClause string - if criterion.Modifier == models.CriterionModifierNotNull { - notClause = "NOT" - } - - f.addLeftJoin(h.joinTable, "", utils.StrFormat("{primaryTable}.id = {joinTable}.{joinPrimaryKey}", strFormatMap)) - f.addLeftJoin("performers_tags", "", utils.StrFormat("{joinTable}.performer_id = performers_tags.performer_id", strFormatMap)) - - f.addWhere(fmt.Sprintf("performers_tags.tag_id IS %s NULL", notClause)) - return - } - - if len(criterion.Value) == 0 && len(criterion.Excludes) == 0 { - return - } - - if len(criterion.Value) > 0 { - valuesClause, err := getHierarchicalValues(ctx, dbWrapper{}, criterion.Value, tagTable, "tags_relations", "", "", criterion.Depth) - if err != nil { - f.setError(err) - return - } - - f.addWith(utils.StrFormat(`performer_tags AS ( -SELECT ps.{joinPrimaryKey} as primaryID, t.column1 AS root_tag_id FROM {joinTable} ps -INNER JOIN performers_tags pt ON pt.performer_id = ps.performer_id -INNER JOIN (`+valuesClause+`) t ON t.column2 = pt.tag_id -)`, strFormatMap)) - - f.addLeftJoin("performer_tags", "", utils.StrFormat("performer_tags.primaryID = {primaryTable}.id", strFormatMap)) - - addHierarchicalConditionClauses(f, criterion, "performer_tags", "root_tag_id") - } - - if len(criterion.Excludes) > 0 { - valuesClause, err := getHierarchicalValues(ctx, dbWrapper{}, criterion.Excludes, tagTable, "tags_relations", "", "", criterion.Depth) - if err != nil { - f.setError(err) - return - } - - clause := utils.StrFormat("{primaryTable}.id NOT IN (SELECT {joinTable}.{joinPrimaryKey} FROM {joinTable} INNER JOIN performers_tags ON {joinTable}.performer_id = performers_tags.performer_id WHERE performers_tags.tag_id IN (SELECT column2 FROM (%s)))", strFormatMap) - f.addWhere(fmt.Sprintf(clause, valuesClause)) - } - } -} - -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 7ddb514d054..b7f7552c2b6 100644 --- a/pkg/sqlite/gallery.go +++ b/pkg/sqlite/gallery.go @@ -6,7 +6,6 @@ import ( "errors" "fmt" "path/filepath" - "regexp" "github.com/doug-martin/goqu/v9" "github.com/doug-martin/goqu/v9/exp" @@ -113,9 +112,75 @@ func (r *galleryRowRecord) fromPartial(o models.GalleryPartial) { r.setTimestamp("updated_at", o.UpdatedAt) } -type GalleryStore struct { +type galleryRepositoryType struct { repository + performers joinRepository + images joinRepository + tags joinRepository + scenes joinRepository + files filesRepository +} + +func (r *galleryRepositoryType) addGalleriesFilesTable(f *filterBuilder) { + f.addLeftJoin(galleriesFilesTable, "", "galleries_files.gallery_id = galleries.id") +} + +func (r *galleryRepositoryType) addFilesTable(f *filterBuilder) { + r.addGalleriesFilesTable(f) + f.addLeftJoin(fileTable, "", "galleries_files.file_id = files.id") +} + +func (r *galleryRepositoryType) addFoldersTable(f *filterBuilder) { + r.addFilesTable(f) + f.addLeftJoin(folderTable, "", "files.parent_folder_id = folders.id") +} + +var ( + galleryRepository = galleryRepositoryType{ + repository: repository{ + tableName: galleryTable, + idColumn: idColumn, + }, + performers: joinRepository{ + repository: repository{ + tableName: performersGalleriesTable, + idColumn: galleryIDColumn, + }, + fkColumn: "performer_id", + }, + tags: joinRepository{ + repository: repository{ + tableName: galleriesTagsTable, + idColumn: galleryIDColumn, + }, + fkColumn: "tag_id", + foreignTable: tagTable, + orderBy: "tags.name ASC", + }, + images: joinRepository{ + repository: repository{ + tableName: galleriesImagesTable, + idColumn: galleryIDColumn, + }, + fkColumn: "image_id", + }, + scenes: joinRepository{ + repository: repository{ + tableName: galleriesScenesTable, + idColumn: galleryIDColumn, + }, + fkColumn: sceneIDColumn, + }, + files: filesRepository{ + repository: repository{ + tableName: galleriesFilesTable, + idColumn: galleryIDColumn, + }, + }, + } +) +type GalleryStore struct { tableMgr *table fileStore *FileStore @@ -124,10 +189,6 @@ type GalleryStore struct { func NewGalleryStore(fileStore *FileStore, folderStore *FolderStore) *GalleryStore { return &GalleryStore{ - repository: repository{ - tableName: galleryTable, - idColumn: idColumn, - }, tableMgr: galleryTableMgr, fileStore: fileStore, folderStore: folderStore, @@ -309,7 +370,7 @@ func (qb *GalleryStore) Destroy(ctx context.Context, id int) error { } func (qb *GalleryStore) GetFiles(ctx context.Context, id int) ([]models.File, error) { - fileIDs, err := qb.filesRepository().get(ctx, id) + fileIDs, err := galleryRepository.files.get(ctx, id) if err != nil { return nil, err } @@ -328,7 +389,7 @@ func (qb *GalleryStore) GetFiles(ctx context.Context, id int) ([]models.File, er func (qb *GalleryStore) GetManyFileIDs(ctx context.Context, ids []int) ([][]models.FileID, error) { const primaryOnly = false - return qb.filesRepository().getMany(ctx, ids, primaryOnly) + return galleryRepository.files.getMany(ctx, ids, primaryOnly) } // returns nil, nil if not found @@ -617,116 +678,6 @@ func (qb *GalleryStore) All(ctx context.Context) ([]*models.Gallery, error) { return qb.getMany(ctx, qb.selectDataset()) } -func (qb *GalleryStore) validateFilter(galleryFilter *models.GalleryFilterType) error { - const and = "AND" - const or = "OR" - const not = "NOT" - - if galleryFilter.And != nil { - if galleryFilter.Or != nil { - return illegalFilterCombination(and, or) - } - if galleryFilter.Not != nil { - return illegalFilterCombination(and, not) - } - - return qb.validateFilter(galleryFilter.And) - } - - if galleryFilter.Or != nil { - if galleryFilter.Not != nil { - return illegalFilterCombination(or, not) - } - - return qb.validateFilter(galleryFilter.Or) - } - - if galleryFilter.Not != nil { - return qb.validateFilter(galleryFilter.Not) - } - - return nil -} - -func (qb *GalleryStore) makeFilter(ctx context.Context, galleryFilter *models.GalleryFilterType) *filterBuilder { - query := &filterBuilder{} - - if galleryFilter.And != nil { - query.and(qb.makeFilter(ctx, galleryFilter.And)) - } - if galleryFilter.Or != nil { - query.or(qb.makeFilter(ctx, galleryFilter.Or)) - } - if galleryFilter.Not != nil { - 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.Code, "galleries.code")) - query.handleCriterion(ctx, stringCriterionHandler(galleryFilter.Details, "galleries.details")) - query.handleCriterion(ctx, stringCriterionHandler(galleryFilter.Photographer, "galleries.photographer")) - - query.handleCriterion(ctx, criterionHandlerFunc(func(ctx context.Context, f *filterBuilder) { - if galleryFilter.Checksum != nil { - qb.addGalleriesFilesTable(f) - f.addLeftJoin(fingerprintTable, "fingerprints_md5", "galleries_files.file_id = fingerprints_md5.file_id AND fingerprints_md5.type = 'md5'") - } - - stringCriterionHandler(galleryFilter.Checksum, "fingerprints_md5.fingerprint")(ctx, f) - })) - - query.handleCriterion(ctx, criterionHandlerFunc(func(ctx context.Context, f *filterBuilder) { - if galleryFilter.IsZip != nil { - qb.addGalleriesFilesTable(f) - if *galleryFilter.IsZip { - - f.addWhere("galleries_files.file_id IS NOT NULL") - } else { - f.addWhere("galleries_files.file_id IS NULL") - } - } - })) - - query.handleCriterion(ctx, qb.galleryPathCriterionHandler(galleryFilter.Path)) - query.handleCriterion(ctx, galleryFileCountCriterionHandler(qb, galleryFilter.FileCount)) - query.handleCriterion(ctx, intCriterionHandler(galleryFilter.Rating100, "galleries.rating", nil)) - query.handleCriterion(ctx, galleryURLsCriterionHandler(galleryFilter.URL)) - query.handleCriterion(ctx, boolCriterionHandler(galleryFilter.Organized, "galleries.organized", nil)) - query.handleCriterion(ctx, galleryIsMissingCriterionHandler(qb, galleryFilter.IsMissing)) - query.handleCriterion(ctx, galleryTagsCriterionHandler(qb, galleryFilter.Tags)) - query.handleCriterion(ctx, galleryTagCountCriterionHandler(qb, galleryFilter.TagCount)) - query.handleCriterion(ctx, galleryPerformersCriterionHandler(qb, galleryFilter.Performers)) - query.handleCriterion(ctx, galleryPerformerCountCriterionHandler(qb, galleryFilter.PerformerCount)) - query.handleCriterion(ctx, hasChaptersCriterionHandler(galleryFilter.HasChapters)) - query.handleCriterion(ctx, galleryScenesCriterionHandler(qb, galleryFilter.Scenes)) - query.handleCriterion(ctx, studioCriterionHandler(galleryTable, galleryFilter.Studios)) - query.handleCriterion(ctx, galleryPerformerTagsCriterionHandler(qb, galleryFilter.PerformerTags)) - query.handleCriterion(ctx, galleryAverageResolutionCriterionHandler(qb, galleryFilter.AverageResolution)) - 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 -} - -func (qb *GalleryStore) addGalleriesFilesTable(f *filterBuilder) { - f.addLeftJoin(galleriesFilesTable, "", "galleries_files.gallery_id = galleries.id") -} - -func (qb *GalleryStore) addFilesTable(f *filterBuilder) { - qb.addGalleriesFilesTable(f) - f.addLeftJoin(fileTable, "", "galleries_files.file_id = files.id") -} - -func (qb *GalleryStore) addFoldersTable(f *filterBuilder) { - qb.addFilesTable(f) - f.addLeftJoin(folderTable, "", "files.parent_folder_id = folders.id") -} - func (qb *GalleryStore) makeQuery(ctx context.Context, galleryFilter *models.GalleryFilterType, findFilter *models.FindFilterType) (*queryBuilder, error) { if galleryFilter == nil { galleryFilter = &models.GalleryFilterType{} @@ -735,7 +686,7 @@ func (qb *GalleryStore) makeQuery(ctx context.Context, galleryFilter *models.Gal findFilter = &models.FindFilterType{} } - query := qb.newQuery() + query := galleryRepository.newQuery() distinctIDs(&query, galleryTable) if q := findFilter.Q; q != nil && *q != "" { @@ -773,10 +724,9 @@ func (qb *GalleryStore) makeQuery(ctx context.Context, galleryFilter *models.Gal query.parseQueryString(searchColumns, *q) } - if err := qb.validateFilter(galleryFilter); err != nil { - return nil, err - } - filter := qb.makeFilter(ctx, galleryFilter) + filter := filterBuilderFromHandler(ctx, &galleryFilterHandler{ + galleryFilter: galleryFilter, + }) if err := query.addFilter(filter); err != nil { return nil, err @@ -818,290 +768,6 @@ func (qb *GalleryStore) QueryCount(ctx context.Context, galleryFilter *models.Ga return query.executeCount(ctx) } -func galleryURLsCriterionHandler(url *models.StringCriterionInput) criterionHandlerFunc { - h := stringListCriterionHandlerBuilder{ - joinTable: galleriesURLsTable, - stringColumn: galleriesURLColumn, - addJoinTable: func(f *filterBuilder) { - galleriesURLsTableMgr.join(f, "", "galleries.id") - }, - } - - return h.handler(url) -} - -func (qb *GalleryStore) getMultiCriterionHandlerBuilder(foreignTable, joinTable, foreignFK string, addJoinsFunc func(f *filterBuilder)) multiCriterionHandlerBuilder { - return multiCriterionHandlerBuilder{ - primaryTable: galleryTable, - foreignTable: foreignTable, - joinTable: joinTable, - primaryFK: galleryIDColumn, - foreignFK: foreignFK, - addJoinsFunc: addJoinsFunc, - } -} - -func (qb *GalleryStore) galleryPathCriterionHandler(c *models.StringCriterionInput) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if c != nil { - qb.addFoldersTable(f) - f.addLeftJoin(folderTable, "gallery_folder", "galleries.folder_id = gallery_folder.id") - - const pathColumn = "folders.path" - const basenameColumn = "files.basename" - const folderPathColumn = "gallery_folder.path" - - addWildcards := true - not := false - - if modifier := c.Modifier; c.Modifier.IsValid() { - switch modifier { - case models.CriterionModifierIncludes: - 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 := 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: - addWildcards = false - clause := getPathSearchClause(pathColumn, basenameColumn, c.Value, addWildcards, not) - clause2 := makeClause(folderPathColumn+" LIKE ?", c.Value) - f.whereClauses = append(f.whereClauses, orClauses(clause, clause2)) - case models.CriterionModifierNotEquals: - addWildcards = false - not = true - clause := getPathSearchClause(pathColumn, basenameColumn, c.Value, addWildcards, not) - clause2 := makeClause(folderPathColumn+" NOT LIKE ?", c.Value) - f.whereClauses = append(f.whereClauses, orClauses(clause, clause2)) - case models.CriterionModifierMatchesRegex: - if _, err := regexp.Compile(c.Value); err != nil { - f.setError(err) - return - } - 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 - } - 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.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)) - f.whereClauses = append(f.whereClauses, orClauses(clause, clause2)) - default: - panic("unsupported string filter modifier") - } - } - } - } -} - -func galleryFileCountCriterionHandler(qb *GalleryStore, fileCount *models.IntCriterionInput) criterionHandlerFunc { - h := countCriterionHandlerBuilder{ - primaryTable: galleryTable, - joinTable: galleriesFilesTable, - primaryFK: galleryIDColumn, - } - - return h.handler(fileCount) -} - -func galleryIsMissingCriterionHandler(qb *GalleryStore, isMissing *string) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if isMissing != nil && *isMissing != "" { - switch *isMissing { - case "url": - galleriesURLsTableMgr.join(f, "", "galleries.id") - f.addWhere("gallery_urls.url IS NULL") - case "scenes": - f.addLeftJoin("scenes_galleries", "scenes_join", "scenes_join.gallery_id = galleries.id") - f.addWhere("scenes_join.gallery_id IS NULL") - case "studio": - f.addWhere("galleries.studio_id IS NULL") - case "performers": - qb.performersRepository().join(f, "performers_join", "galleries.id") - f.addWhere("performers_join.gallery_id IS NULL") - case "date": - f.addWhere("galleries.date IS NULL OR galleries.date IS \"\"") - case "tags": - qb.tagsRepository().join(f, "tags_join", "galleries.id") - f.addWhere("tags_join.gallery_id IS NULL") - default: - f.addWhere("(galleries." + *isMissing + " IS NULL OR TRIM(galleries." + *isMissing + ") = '')") - } - } - } -} - -func galleryTagsCriterionHandler(qb *GalleryStore, tags *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { - h := joinedHierarchicalMultiCriterionHandlerBuilder{ - tx: qb.tx, - - primaryTable: galleryTable, - foreignTable: tagTable, - foreignFK: "tag_id", - - relationsTable: "tags_relations", - joinAs: "image_tag", - joinTable: galleriesTagsTable, - primaryFK: galleryIDColumn, - } - - return h.handler(tags) -} - -func galleryTagCountCriterionHandler(qb *GalleryStore, tagCount *models.IntCriterionInput) criterionHandlerFunc { - h := countCriterionHandlerBuilder{ - primaryTable: galleryTable, - joinTable: galleriesTagsTable, - primaryFK: galleryIDColumn, - } - - return h.handler(tagCount) -} - -func galleryScenesCriterionHandler(qb *GalleryStore, scenes *models.MultiCriterionInput) criterionHandlerFunc { - addJoinsFunc := func(f *filterBuilder) { - qb.scenesRepository().join(f, "", "galleries.id") - f.addLeftJoin("scenes", "", "scenes_galleries.scene_id = scenes.id") - } - h := qb.getMultiCriterionHandlerBuilder(sceneTable, galleriesScenesTable, "scene_id", addJoinsFunc) - return h.handler(scenes) -} - -func galleryPerformersCriterionHandler(qb *GalleryStore, performers *models.MultiCriterionInput) criterionHandlerFunc { - h := joinedMultiCriterionHandlerBuilder{ - primaryTable: galleryTable, - joinTable: performersGalleriesTable, - joinAs: "performers_join", - primaryFK: galleryIDColumn, - foreignFK: performerIDColumn, - - addJoinTable: func(f *filterBuilder) { - qb.performersRepository().join(f, "performers_join", "galleries.id") - }, - } - - return h.handler(performers) -} - -func galleryPerformerCountCriterionHandler(qb *GalleryStore, performerCount *models.IntCriterionInput) criterionHandlerFunc { - h := countCriterionHandlerBuilder{ - primaryTable: galleryTable, - joinTable: performersGalleriesTable, - primaryFK: galleryIDColumn, - } - - return h.handler(performerCount) -} - -func galleryImageCountCriterionHandler(qb *GalleryStore, imageCount *models.IntCriterionInput) criterionHandlerFunc { - h := countCriterionHandlerBuilder{ - primaryTable: galleryTable, - joinTable: galleriesImagesTable, - primaryFK: galleryIDColumn, - } - - return h.handler(imageCount) -} - -func hasChaptersCriterionHandler(hasChapters *string) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if hasChapters != nil { - f.addLeftJoin("galleries_chapters", "", "galleries_chapters.gallery_id = galleries.id") - if *hasChapters == "true" { - f.addHaving("count(galleries_chapters.gallery_id) > 0") - } else { - f.addWhere("galleries_chapters.id IS NULL") - } - } - } -} - -func galleryPerformerTagsCriterionHandler(qb *GalleryStore, tags *models.HierarchicalMultiCriterionInput) criterionHandler { - return &joinedPerformerTagsHandler{ - criterion: tags, - primaryTable: galleryTable, - joinTable: performersGalleriesTable, - joinPrimaryKey: galleryIDColumn, - } -} - -func galleryPerformerFavoriteCriterionHandler(performerfavorite *bool) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if performerfavorite != nil { - f.addLeftJoin("performers_galleries", "", "galleries.id = performers_galleries.gallery_id") - - if *performerfavorite { - // contains at least one favorite - f.addLeftJoin("performers", "", "performers.id = performers_galleries.performer_id") - f.addWhere("performers.favorite = 1") - } else { - // contains zero favorites - f.addLeftJoin(`(SELECT performers_galleries.gallery_id as id FROM performers_galleries -JOIN performers ON performers.id = performers_galleries.performer_id -GROUP BY performers_galleries.gallery_id HAVING SUM(performers.favorite) = 0)`, "nofaves", "galleries.id = nofaves.id") - f.addWhere("performers_galleries.gallery_id IS NULL OR nofaves.id IS NOT NULL") - } - } - } -} - -func galleryPerformerAgeCriterionHandler(performerAge *models.IntCriterionInput) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if performerAge != nil { - f.addInnerJoin("performers_galleries", "", "galleries.id = performers_galleries.gallery_id") - f.addInnerJoin("performers", "", "performers_galleries.performer_id = performers.id") - - f.addWhere("galleries.date != '' AND performers.birthdate != ''") - f.addWhere("galleries.date IS NOT NULL AND performers.birthdate IS NOT NULL") - - ageCalc := "cast(strftime('%Y.%m%d', galleries.date) - strftime('%Y.%m%d', performers.birthdate) as int)" - whereClause, args := getIntWhereClause(ageCalc, performerAge.Modifier, performerAge.Value, performerAge.Value2) - f.addWhere(whereClause, args...) - } - } -} - -func galleryAverageResolutionCriterionHandler(qb *GalleryStore, resolution *models.ResolutionCriterionInput) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if resolution != nil && resolution.Value.IsValid() { - qb.imagesRepository().join(f, "images_join", "galleries.id") - f.addLeftJoin("images", "", "images_join.image_id = images.id") - f.addLeftJoin("images_files", "", "images.id = images_files.image_id") - f.addLeftJoin("image_files", "", "images_files.file_id = image_files.file_id") - - min := resolution.Value.GetMinResolution() - max := resolution.Value.GetMaxResolution() - - const widthHeight = "avg(MIN(image_files.width, image_files.height))" - - switch resolution.Modifier { - case models.CriterionModifierEquals: - f.addHaving(fmt.Sprintf("%s BETWEEN %d AND %d", widthHeight, min, max)) - case models.CriterionModifierNotEquals: - f.addHaving(fmt.Sprintf("%s NOT BETWEEN %d AND %d", widthHeight, min, max)) - case models.CriterionModifierLessThan: - f.addHaving(fmt.Sprintf("%s < %d", widthHeight, min)) - case models.CriterionModifierGreaterThan: - f.addHaving(fmt.Sprintf("%s > %d", widthHeight, max)) - } - } - } -} - var gallerySortOptions = sortOptions{ "created_at", "date", @@ -1194,92 +860,36 @@ func (qb *GalleryStore) GetURLs(ctx context.Context, galleryID int) ([]string, e return galleriesURLsTableMgr.get(ctx, galleryID) } -func (qb *GalleryStore) filesRepository() *filesRepository { - return &filesRepository{ - repository: repository{ - tx: qb.tx, - tableName: galleriesFilesTable, - idColumn: galleryIDColumn, - }, - } -} - func (qb *GalleryStore) AddFileID(ctx context.Context, id int, fileID models.FileID) error { const firstPrimary = false return galleriesFilesTableMgr.insertJoins(ctx, id, firstPrimary, []models.FileID{fileID}) } -func (qb *GalleryStore) performersRepository() *joinRepository { - return &joinRepository{ - repository: repository{ - tx: qb.tx, - tableName: performersGalleriesTable, - idColumn: galleryIDColumn, - }, - fkColumn: "performer_id", - } -} - func (qb *GalleryStore) GetPerformerIDs(ctx context.Context, id int) ([]int, error) { - return qb.performersRepository().getIDs(ctx, id) -} - -func (qb *GalleryStore) tagsRepository() *joinRepository { - return &joinRepository{ - repository: repository{ - tx: qb.tx, - tableName: galleriesTagsTable, - idColumn: galleryIDColumn, - }, - fkColumn: "tag_id", - foreignTable: tagTable, - orderBy: "tags.name ASC", - } + return galleryRepository.performers.getIDs(ctx, id) } func (qb *GalleryStore) GetTagIDs(ctx context.Context, id int) ([]int, error) { - return qb.tagsRepository().getIDs(ctx, id) -} - -func (qb *GalleryStore) imagesRepository() *joinRepository { - return &joinRepository{ - repository: repository{ - tx: qb.tx, - tableName: galleriesImagesTable, - idColumn: galleryIDColumn, - }, - fkColumn: "image_id", - } + return galleryRepository.tags.getIDs(ctx, id) } func (qb *GalleryStore) GetImageIDs(ctx context.Context, galleryID int) ([]int, error) { - return qb.imagesRepository().getIDs(ctx, galleryID) + return galleryRepository.images.getIDs(ctx, galleryID) } func (qb *GalleryStore) AddImages(ctx context.Context, galleryID int, imageIDs ...int) error { - return qb.imagesRepository().insertOrIgnore(ctx, galleryID, imageIDs...) + return galleryRepository.images.insertOrIgnore(ctx, galleryID, imageIDs...) } func (qb *GalleryStore) RemoveImages(ctx context.Context, galleryID int, imageIDs ...int) error { - return qb.imagesRepository().destroyJoins(ctx, galleryID, imageIDs...) + return galleryRepository.images.destroyJoins(ctx, galleryID, imageIDs...) } func (qb *GalleryStore) UpdateImages(ctx context.Context, galleryID int, imageIDs []int) error { // Delete the existing joins and then create new ones - return qb.imagesRepository().replace(ctx, galleryID, imageIDs) -} - -func (qb *GalleryStore) scenesRepository() *joinRepository { - return &joinRepository{ - repository: repository{ - tx: qb.tx, - tableName: galleriesScenesTable, - idColumn: galleryIDColumn, - }, - fkColumn: sceneIDColumn, - } + return galleryRepository.images.replace(ctx, galleryID, imageIDs) } func (qb *GalleryStore) GetSceneIDs(ctx context.Context, id int) ([]int, error) { - return qb.scenesRepository().getIDs(ctx, id) + return galleryRepository.scenes.getIDs(ctx, id) } diff --git a/pkg/sqlite/gallery_filter.go b/pkg/sqlite/gallery_filter.go new file mode 100644 index 00000000000..abca78b105c --- /dev/null +++ b/pkg/sqlite/gallery_filter.go @@ -0,0 +1,432 @@ +package sqlite + +import ( + "context" + "fmt" + "path/filepath" + "regexp" + + "github.com/stashapp/stash/pkg/models" +) + +type galleryFilterHandler struct { + galleryFilter *models.GalleryFilterType +} + +func (qb *galleryFilterHandler) validate() error { + galleryFilter := qb.galleryFilter + if galleryFilter == nil { + return nil + } + + if err := validateFilterCombination(galleryFilter.OperatorFilter); err != nil { + return err + } + + if subFilter := galleryFilter.SubFilter(); subFilter != nil { + sqb := &galleryFilterHandler{galleryFilter: subFilter} + if err := sqb.validate(); err != nil { + return err + } + } + + return nil +} + +func (qb *galleryFilterHandler) handle(ctx context.Context, f *filterBuilder) { + galleryFilter := qb.galleryFilter + if galleryFilter == nil { + return + } + + if err := qb.validate(); err != nil { + f.setError(err) + return + } + + sf := galleryFilter.SubFilter() + if sf != nil { + sub := &galleryFilterHandler{sf} + handleSubFilter(ctx, sub, f, galleryFilter.OperatorFilter) + } + + f.handleCriterion(ctx, qb.criterionHandler()) +} + +func (qb *galleryFilterHandler) criterionHandler() criterionHandler { + filter := qb.galleryFilter + return compoundHandler{ + intCriterionHandler(filter.ID, "galleries.id", nil), + stringCriterionHandler(filter.Title, "galleries.title"), + stringCriterionHandler(filter.Code, "galleries.code"), + stringCriterionHandler(filter.Details, "galleries.details"), + stringCriterionHandler(filter.Photographer, "galleries.photographer"), + + criterionHandlerFunc(func(ctx context.Context, f *filterBuilder) { + if filter.Checksum != nil { + galleryRepository.addGalleriesFilesTable(f) + f.addLeftJoin(fingerprintTable, "fingerprints_md5", "galleries_files.file_id = fingerprints_md5.file_id AND fingerprints_md5.type = 'md5'") + } + + stringCriterionHandler(filter.Checksum, "fingerprints_md5.fingerprint")(ctx, f) + }), + + criterionHandlerFunc(func(ctx context.Context, f *filterBuilder) { + if filter.IsZip != nil { + galleryRepository.addGalleriesFilesTable(f) + if *filter.IsZip { + + f.addWhere("galleries_files.file_id IS NOT NULL") + } else { + f.addWhere("galleries_files.file_id IS NULL") + } + } + }), + + qb.pathCriterionHandler(filter.Path), + qb.fileCountCriterionHandler(filter.FileCount), + intCriterionHandler(filter.Rating100, "galleries.rating", nil), + qb.urlsCriterionHandler(filter.URL), + boolCriterionHandler(filter.Organized, "galleries.organized", nil), + qb.missingCriterionHandler(filter.IsMissing), + qb.tagsCriterionHandler(filter.Tags), + qb.tagCountCriterionHandler(filter.TagCount), + qb.performersCriterionHandler(filter.Performers), + qb.performerCountCriterionHandler(filter.PerformerCount), + qb.scenesCriterionHandler(filter.Scenes), + qb.hasChaptersCriterionHandler(filter.HasChapters), + studioCriterionHandler(galleryTable, filter.Studios), + qb.performerTagsCriterionHandler(filter.PerformerTags), + qb.averageResolutionCriterionHandler(filter.AverageResolution), + qb.imageCountCriterionHandler(filter.ImageCount), + qb.performerFavoriteCriterionHandler(filter.PerformerFavorite), + qb.performerAgeCriterionHandler(filter.PerformerAge), + &dateCriterionHandler{filter.Date, "galleries.date", nil}, + ×tampCriterionHandler{filter.CreatedAt, "galleries.created_at", nil}, + ×tampCriterionHandler{filter.UpdatedAt, "galleries.updated_at", nil}, + + &relatedFilterHandler{ + relatedIDCol: "scenes_galleries.scene_id", + relatedRepo: sceneRepository.repository, + relatedHandler: &sceneFilterHandler{filter.ScenesFilter}, + joinFn: func(f *filterBuilder) { + galleryRepository.scenes.innerJoin(f, "", "galleries.id") + }, + }, + + &relatedFilterHandler{ + relatedIDCol: "galleries_images.image_id", + relatedRepo: imageRepository.repository, + relatedHandler: &imageFilterHandler{filter.ImagesFilter}, + joinFn: func(f *filterBuilder) { + galleryRepository.images.innerJoin(f, "", "galleries.id") + }, + }, + + &relatedFilterHandler{ + relatedIDCol: "performers_join.performer_id", + relatedRepo: performerRepository.repository, + relatedHandler: &performerFilterHandler{filter.PerformersFilter}, + joinFn: func(f *filterBuilder) { + galleryRepository.performers.innerJoin(f, "performers_join", "galleries.id") + }, + }, + + &relatedFilterHandler{ + relatedIDCol: "galleries.studio_id", + relatedRepo: studioRepository.repository, + relatedHandler: &studioFilterHandler{filter.StudiosFilter}, + }, + + &relatedFilterHandler{ + relatedIDCol: "gallery_tag.tag_id", + relatedRepo: tagRepository.repository, + relatedHandler: &tagFilterHandler{filter.TagsFilter}, + joinFn: func(f *filterBuilder) { + galleryRepository.tags.innerJoin(f, "gallery_tag", "galleries.id") + }, + }, + } +} + +func (qb *galleryFilterHandler) urlsCriterionHandler(url *models.StringCriterionInput) criterionHandlerFunc { + h := stringListCriterionHandlerBuilder{ + joinTable: galleriesURLsTable, + stringColumn: galleriesURLColumn, + addJoinTable: func(f *filterBuilder) { + galleriesURLsTableMgr.join(f, "", "galleries.id") + }, + } + + return h.handler(url) +} + +func (qb *galleryFilterHandler) getMultiCriterionHandlerBuilder(foreignTable, joinTable, foreignFK string, addJoinsFunc func(f *filterBuilder)) multiCriterionHandlerBuilder { + return multiCriterionHandlerBuilder{ + primaryTable: galleryTable, + foreignTable: foreignTable, + joinTable: joinTable, + primaryFK: galleryIDColumn, + foreignFK: foreignFK, + addJoinsFunc: addJoinsFunc, + } +} + +func (qb *galleryFilterHandler) pathCriterionHandler(c *models.StringCriterionInput) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if c != nil { + galleryRepository.addFoldersTable(f) + f.addLeftJoin(folderTable, "gallery_folder", "galleries.folder_id = gallery_folder.id") + + const pathColumn = "folders.path" + const basenameColumn = "files.basename" + const folderPathColumn = "gallery_folder.path" + + addWildcards := true + not := false + + if modifier := c.Modifier; c.Modifier.IsValid() { + switch modifier { + case models.CriterionModifierIncludes: + 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 := 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: + addWildcards = false + clause := getPathSearchClause(pathColumn, basenameColumn, c.Value, addWildcards, not) + clause2 := makeClause(folderPathColumn+" LIKE ?", c.Value) + f.whereClauses = append(f.whereClauses, orClauses(clause, clause2)) + case models.CriterionModifierNotEquals: + addWildcards = false + not = true + clause := getPathSearchClause(pathColumn, basenameColumn, c.Value, addWildcards, not) + clause2 := makeClause(folderPathColumn+" NOT LIKE ?", c.Value) + f.whereClauses = append(f.whereClauses, orClauses(clause, clause2)) + case models.CriterionModifierMatchesRegex: + if _, err := regexp.Compile(c.Value); err != nil { + f.setError(err) + return + } + 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 + } + 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.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)) + f.whereClauses = append(f.whereClauses, orClauses(clause, clause2)) + default: + panic("unsupported string filter modifier") + } + } + } + } +} + +func (qb *galleryFilterHandler) fileCountCriterionHandler(fileCount *models.IntCriterionInput) criterionHandlerFunc { + h := countCriterionHandlerBuilder{ + primaryTable: galleryTable, + joinTable: galleriesFilesTable, + primaryFK: galleryIDColumn, + } + + return h.handler(fileCount) +} + +func (qb *galleryFilterHandler) missingCriterionHandler(isMissing *string) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if isMissing != nil && *isMissing != "" { + switch *isMissing { + case "url": + galleriesURLsTableMgr.join(f, "", "galleries.id") + f.addWhere("gallery_urls.url IS NULL") + case "scenes": + f.addLeftJoin("scenes_galleries", "scenes_join", "scenes_join.gallery_id = galleries.id") + f.addWhere("scenes_join.gallery_id IS NULL") + case "studio": + f.addWhere("galleries.studio_id IS NULL") + case "performers": + galleryRepository.performers.join(f, "performers_join", "galleries.id") + f.addWhere("performers_join.gallery_id IS NULL") + case "date": + f.addWhere("galleries.date IS NULL OR galleries.date IS \"\"") + case "tags": + galleryRepository.tags.join(f, "tags_join", "galleries.id") + f.addWhere("tags_join.gallery_id IS NULL") + default: + f.addWhere("(galleries." + *isMissing + " IS NULL OR TRIM(galleries." + *isMissing + ") = '')") + } + } + } +} + +func (qb *galleryFilterHandler) tagsCriterionHandler(tags *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { + h := joinedHierarchicalMultiCriterionHandlerBuilder{ + primaryTable: galleryTable, + foreignTable: tagTable, + foreignFK: "tag_id", + + relationsTable: "tags_relations", + joinAs: "gallery_tag", + joinTable: galleriesTagsTable, + primaryFK: galleryIDColumn, + } + + return h.handler(tags) +} + +func (qb *galleryFilterHandler) tagCountCriterionHandler(tagCount *models.IntCriterionInput) criterionHandlerFunc { + h := countCriterionHandlerBuilder{ + primaryTable: galleryTable, + joinTable: galleriesTagsTable, + primaryFK: galleryIDColumn, + } + + return h.handler(tagCount) +} + +func (qb *galleryFilterHandler) scenesCriterionHandler(scenes *models.MultiCriterionInput) criterionHandlerFunc { + addJoinsFunc := func(f *filterBuilder) { + galleryRepository.scenes.join(f, "", "galleries.id") + f.addLeftJoin("scenes", "", "scenes_galleries.scene_id = scenes.id") + } + h := qb.getMultiCriterionHandlerBuilder(sceneTable, galleriesScenesTable, "scene_id", addJoinsFunc) + return h.handler(scenes) +} + +func (qb *galleryFilterHandler) performersCriterionHandler(performers *models.MultiCriterionInput) criterionHandlerFunc { + h := joinedMultiCriterionHandlerBuilder{ + primaryTable: galleryTable, + joinTable: performersGalleriesTable, + joinAs: "performers_join", + primaryFK: galleryIDColumn, + foreignFK: performerIDColumn, + + addJoinTable: func(f *filterBuilder) { + galleryRepository.performers.join(f, "performers_join", "galleries.id") + }, + } + + return h.handler(performers) +} + +func (qb *galleryFilterHandler) performerCountCriterionHandler(performerCount *models.IntCriterionInput) criterionHandlerFunc { + h := countCriterionHandlerBuilder{ + primaryTable: galleryTable, + joinTable: performersGalleriesTable, + primaryFK: galleryIDColumn, + } + + return h.handler(performerCount) +} + +func (qb *galleryFilterHandler) imageCountCriterionHandler(imageCount *models.IntCriterionInput) criterionHandlerFunc { + h := countCriterionHandlerBuilder{ + primaryTable: galleryTable, + joinTable: galleriesImagesTable, + primaryFK: galleryIDColumn, + } + + return h.handler(imageCount) +} + +func (qb *galleryFilterHandler) hasChaptersCriterionHandler(hasChapters *string) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if hasChapters != nil { + f.addLeftJoin("galleries_chapters", "", "galleries_chapters.gallery_id = galleries.id") + if *hasChapters == "true" { + f.addHaving("count(galleries_chapters.gallery_id) > 0") + } else { + f.addWhere("galleries_chapters.id IS NULL") + } + } + } +} + +func (qb *galleryFilterHandler) performerTagsCriterionHandler(tags *models.HierarchicalMultiCriterionInput) criterionHandler { + return &joinedPerformerTagsHandler{ + criterion: tags, + primaryTable: galleryTable, + joinTable: performersGalleriesTable, + joinPrimaryKey: galleryIDColumn, + } +} + +func (qb *galleryFilterHandler) performerFavoriteCriterionHandler(performerfavorite *bool) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if performerfavorite != nil { + f.addLeftJoin("performers_galleries", "", "galleries.id = performers_galleries.gallery_id") + + if *performerfavorite { + // contains at least one favorite + f.addLeftJoin("performers", "", "performers.id = performers_galleries.performer_id") + f.addWhere("performers.favorite = 1") + } else { + // contains zero favorites + f.addLeftJoin(`(SELECT performers_galleries.gallery_id as id FROM performers_galleries +JOIN performers ON performers.id = performers_galleries.performer_id +GROUP BY performers_galleries.gallery_id HAVING SUM(performers.favorite) = 0)`, "nofaves", "galleries.id = nofaves.id") + f.addWhere("performers_galleries.gallery_id IS NULL OR nofaves.id IS NOT NULL") + } + } + } +} + +func (qb *galleryFilterHandler) performerAgeCriterionHandler(performerAge *models.IntCriterionInput) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if performerAge != nil { + f.addInnerJoin("performers_galleries", "", "galleries.id = performers_galleries.gallery_id") + f.addInnerJoin("performers", "", "performers_galleries.performer_id = performers.id") + + f.addWhere("galleries.date != '' AND performers.birthdate != ''") + f.addWhere("galleries.date IS NOT NULL AND performers.birthdate IS NOT NULL") + + ageCalc := "cast(strftime('%Y.%m%d', galleries.date) - strftime('%Y.%m%d', performers.birthdate) as int)" + whereClause, args := getIntWhereClause(ageCalc, performerAge.Modifier, performerAge.Value, performerAge.Value2) + f.addWhere(whereClause, args...) + } + } +} + +func (qb *galleryFilterHandler) averageResolutionCriterionHandler(resolution *models.ResolutionCriterionInput) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if resolution != nil && resolution.Value.IsValid() { + galleryRepository.images.join(f, "images_join", "galleries.id") + f.addLeftJoin("images", "", "images_join.image_id = images.id") + f.addLeftJoin("images_files", "", "images.id = images_files.image_id") + f.addLeftJoin("image_files", "", "images_files.file_id = image_files.file_id") + + min := resolution.Value.GetMinResolution() + max := resolution.Value.GetMaxResolution() + + const widthHeight = "avg(MIN(image_files.width, image_files.height))" + + switch resolution.Modifier { + case models.CriterionModifierEquals: + f.addHaving(fmt.Sprintf("%s BETWEEN %d AND %d", widthHeight, min, max)) + case models.CriterionModifierNotEquals: + f.addHaving(fmt.Sprintf("%s NOT BETWEEN %d AND %d", widthHeight, min, max)) + case models.CriterionModifierLessThan: + f.addHaving(fmt.Sprintf("%s < %d", widthHeight, min)) + case models.CriterionModifierGreaterThan: + f.addHaving(fmt.Sprintf("%s > %d", widthHeight, max)) + } + } + } +} diff --git a/pkg/sqlite/gallery_test.go b/pkg/sqlite/gallery_test.go index c57ba08b84a..08908220b75 100644 --- a/pkg/sqlite/gallery_test.go +++ b/pkg/sqlite/gallery_test.go @@ -1534,10 +1534,12 @@ func TestGalleryQueryPathOr(t *testing.T) { Value: gallery1Path, Modifier: models.CriterionModifierEquals, }, - Or: &models.GalleryFilterType{ - Path: &models.StringCriterionInput{ - Value: gallery2Path, - Modifier: models.CriterionModifierEquals, + OperatorFilter: models.OperatorFilter[models.GalleryFilterType]{ + Or: &models.GalleryFilterType{ + Path: &models.StringCriterionInput{ + Value: gallery2Path, + Modifier: models.CriterionModifierEquals, + }, }, }, } @@ -1568,10 +1570,12 @@ func TestGalleryQueryPathAndRating(t *testing.T) { Value: galleryPath, Modifier: models.CriterionModifierEquals, }, - And: &models.GalleryFilterType{ - Rating100: &models.IntCriterionInput{ - Value: *galleryRating, - Modifier: models.CriterionModifierEquals, + OperatorFilter: models.OperatorFilter[models.GalleryFilterType]{ + And: &models.GalleryFilterType{ + Rating100: &models.IntCriterionInput{ + Value: *galleryRating, + Modifier: models.CriterionModifierEquals, + }, }, }, } @@ -1609,8 +1613,10 @@ func TestGalleryQueryPathNotRating(t *testing.T) { galleryFilter := models.GalleryFilterType{ Path: &pathCriterion, - Not: &models.GalleryFilterType{ - Rating100: &ratingCriterion, + OperatorFilter: models.OperatorFilter[models.GalleryFilterType]{ + Not: &models.GalleryFilterType{ + Rating100: &ratingCriterion, + }, }, } @@ -1641,8 +1647,10 @@ func TestGalleryIllegalQuery(t *testing.T) { } galleryFilter := &models.GalleryFilterType{ - And: &subFilter, - Or: &subFilter, + OperatorFilter: models.OperatorFilter[models.GalleryFilterType]{ + And: &subFilter, + Or: &subFilter, + }, } withTxn(func(ctx context.Context) error { diff --git a/pkg/sqlite/image.go b/pkg/sqlite/image.go index 02cd09ec731..dc4ed920fe5 100644 --- a/pkg/sqlite/image.go +++ b/pkg/sqlite/image.go @@ -112,24 +112,87 @@ func (r *imageRowRecord) fromPartial(i models.ImagePartial) { r.setTimestamp("updated_at", i.UpdatedAt) } -type ImageStore struct { +type imageRepositoryType struct { repository + performers joinRepository + galleries joinRepository + tags joinRepository + files filesRepository +} - tableMgr *table - oCounterManager +func (r *imageRepositoryType) addImagesFilesTable(f *filterBuilder) { + f.addLeftJoin(imagesFilesTable, "", "images_files.image_id = images.id") +} - fileStore *FileStore +func (r *imageRepositoryType) addFilesTable(f *filterBuilder) { + r.addImagesFilesTable(f) + f.addLeftJoin(fileTable, "", "images_files.file_id = files.id") } -func NewImageStore(fileStore *FileStore) *ImageStore { - return &ImageStore{ +func (r *imageRepositoryType) addFoldersTable(f *filterBuilder) { + r.addFilesTable(f) + f.addLeftJoin(folderTable, "", "files.parent_folder_id = folders.id") +} + +func (r *imageRepositoryType) addImageFilesTable(f *filterBuilder) { + r.addImagesFilesTable(f) + f.addLeftJoin(imageFileTable, "", "image_files.file_id = images_files.file_id") +} + +var ( + imageRepository = imageRepositoryType{ repository: repository{ tableName: imageTable, idColumn: idColumn, }, + + performers: joinRepository{ + repository: repository{ + tableName: performersImagesTable, + idColumn: imageIDColumn, + }, + fkColumn: performerIDColumn, + }, + + galleries: joinRepository{ + repository: repository{ + tableName: galleriesImagesTable, + idColumn: imageIDColumn, + }, + fkColumn: galleryIDColumn, + }, + + files: filesRepository{ + repository: repository{ + tableName: imagesFilesTable, + idColumn: imageIDColumn, + }, + }, + + tags: joinRepository{ + repository: repository{ + tableName: imagesTagsTable, + idColumn: imageIDColumn, + }, + fkColumn: tagIDColumn, + foreignTable: tagTable, + orderBy: "tags.name ASC", + }, + } +) + +type ImageStore struct { + tableMgr *table + oCounterManager + + repo *storeRepository +} + +func NewImageStore(r *storeRepository) *ImageStore { + return &ImageStore{ tableMgr: imageTableMgr, oCounterManager: oCounterManager{imageTableMgr}, - fileStore: fileStore, + repo: r, } } @@ -418,13 +481,13 @@ func (qb *ImageStore) getMany(ctx context.Context, q *goqu.SelectDataset) ([]*mo } func (qb *ImageStore) GetFiles(ctx context.Context, id int) ([]models.File, error) { - fileIDs, err := qb.filesRepository().get(ctx, id) + fileIDs, err := imageRepository.files.get(ctx, id) if err != nil { return nil, err } // use fileStore to load files - files, err := qb.fileStore.Find(ctx, fileIDs...) + files, err := qb.repo.File.Find(ctx, fileIDs...) if err != nil { return nil, err } @@ -434,7 +497,7 @@ func (qb *ImageStore) GetFiles(ctx context.Context, id int) ([]models.File, erro func (qb *ImageStore) GetManyFileIDs(ctx context.Context, ids []int) ([][]models.FileID, error) { const primaryOnly = false - return qb.filesRepository().getMany(ctx, ids, primaryOnly) + return imageRepository.files.getMany(ctx, ids, primaryOnly) } func (qb *ImageStore) FindByFileID(ctx context.Context, fileID models.FileID) ([]*models.Image, error) { @@ -642,110 +705,6 @@ func (qb *ImageStore) All(ctx context.Context) ([]*models.Image, error) { return qb.getMany(ctx, qb.selectDataset()) } -func (qb *ImageStore) validateFilter(imageFilter *models.ImageFilterType) error { - const and = "AND" - const or = "OR" - const not = "NOT" - - if imageFilter.And != nil { - if imageFilter.Or != nil { - return illegalFilterCombination(and, or) - } - if imageFilter.Not != nil { - return illegalFilterCombination(and, not) - } - - return qb.validateFilter(imageFilter.And) - } - - if imageFilter.Or != nil { - if imageFilter.Not != nil { - return illegalFilterCombination(or, not) - } - - return qb.validateFilter(imageFilter.Or) - } - - if imageFilter.Not != nil { - return qb.validateFilter(imageFilter.Not) - } - - return nil -} - -func (qb *ImageStore) makeFilter(ctx context.Context, imageFilter *models.ImageFilterType) *filterBuilder { - query := &filterBuilder{} - - if imageFilter.And != nil { - query.and(qb.makeFilter(ctx, imageFilter.And)) - } - if imageFilter.Or != nil { - query.or(qb.makeFilter(ctx, imageFilter.Or)) - } - if imageFilter.Not != nil { - 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) - f.addInnerJoin(fingerprintTable, "fingerprints_md5", "images_files.file_id = fingerprints_md5.file_id AND fingerprints_md5.type = 'md5'") - } - - stringCriterionHandler(imageFilter.Checksum, "fingerprints_md5.fingerprint")(ctx, f) - })) - query.handleCriterion(ctx, stringCriterionHandler(imageFilter.Title, "images.title")) - query.handleCriterion(ctx, stringCriterionHandler(imageFilter.Code, "images.code")) - query.handleCriterion(ctx, stringCriterionHandler(imageFilter.Details, "images.details")) - query.handleCriterion(ctx, stringCriterionHandler(imageFilter.Photographer, "images.photographer")) - - query.handleCriterion(ctx, pathCriterionHandler(imageFilter.Path, "folders.path", "files.basename", qb.addFoldersTable)) - query.handleCriterion(ctx, imageFileCountCriterionHandler(qb, imageFilter.FileCount)) - query.handleCriterion(ctx, intCriterionHandler(imageFilter.Rating100, "images.rating", nil)) - query.handleCriterion(ctx, intCriterionHandler(imageFilter.OCounter, "images.o_counter", nil)) - query.handleCriterion(ctx, boolCriterionHandler(imageFilter.Organized, "images.organized", nil)) - query.handleCriterion(ctx, dateCriterionHandler(imageFilter.Date, "images.date")) - query.handleCriterion(ctx, imageURLsCriterionHandler(imageFilter.URL)) - - query.handleCriterion(ctx, resolutionCriterionHandler(imageFilter.Resolution, "image_files.height", "image_files.width", qb.addImageFilesTable)) - query.handleCriterion(ctx, orientationCriterionHandler(imageFilter.Orientation, "image_files.height", "image_files.width", qb.addImageFilesTable)) - query.handleCriterion(ctx, imageIsMissingCriterionHandler(qb, imageFilter.IsMissing)) - - query.handleCriterion(ctx, imageTagsCriterionHandler(qb, imageFilter.Tags)) - query.handleCriterion(ctx, imageTagCountCriterionHandler(qb, imageFilter.TagCount)) - query.handleCriterion(ctx, imageGalleriesCriterionHandler(qb, imageFilter.Galleries)) - query.handleCriterion(ctx, imagePerformersCriterionHandler(qb, imageFilter.Performers)) - query.handleCriterion(ctx, imagePerformerCountCriterionHandler(qb, imageFilter.PerformerCount)) - query.handleCriterion(ctx, studioCriterionHandler(imageTable, imageFilter.Studios)) - query.handleCriterion(ctx, imagePerformerTagsCriterionHandler(qb, imageFilter.PerformerTags)) - query.handleCriterion(ctx, imagePerformerFavoriteCriterionHandler(imageFilter.PerformerFavorite)) - query.handleCriterion(ctx, imagePerformerAgeCriterionHandler(imageFilter.PerformerAge)) - query.handleCriterion(ctx, timestampCriterionHandler(imageFilter.CreatedAt, "images.created_at")) - query.handleCriterion(ctx, timestampCriterionHandler(imageFilter.UpdatedAt, "images.updated_at")) - - return query -} - -func (qb *ImageStore) addImagesFilesTable(f *filterBuilder) { - f.addLeftJoin(imagesFilesTable, "", "images_files.image_id = images.id") -} - -func (qb *ImageStore) addFilesTable(f *filterBuilder) { - qb.addImagesFilesTable(f) - f.addLeftJoin(fileTable, "", "images_files.file_id = files.id") -} - -func (qb *ImageStore) addFoldersTable(f *filterBuilder) { - qb.addFilesTable(f) - f.addLeftJoin(folderTable, "", "files.parent_folder_id = folders.id") -} - -func (qb *ImageStore) addImageFilesTable(f *filterBuilder) { - qb.addImagesFilesTable(f) - f.addLeftJoin(imageFileTable, "", "image_files.file_id = images_files.file_id") -} - func (qb *ImageStore) makeQuery(ctx context.Context, imageFilter *models.ImageFilterType, findFilter *models.FindFilterType) (*queryBuilder, error) { if imageFilter == nil { imageFilter = &models.ImageFilterType{} @@ -754,7 +713,7 @@ func (qb *ImageStore) makeQuery(ctx context.Context, imageFilter *models.ImageFi findFilter = &models.FindFilterType{} } - query := qb.newQuery() + query := imageRepository.newQuery() distinctIDs(&query, imageTable) if q := findFilter.Q; q != nil && *q != "" { @@ -782,10 +741,9 @@ func (qb *ImageStore) makeQuery(ctx context.Context, imageFilter *models.ImageFi query.parseQueryString(searchColumns, *q) } - if err := qb.validateFilter(imageFilter); err != nil { - return nil, err - } - filter := qb.makeFilter(ctx, imageFilter) + filter := filterBuilderFromHandler(ctx, &imageFilterHandler{ + imageFilter: imageFilter, + }) if err := query.addFilter(filter); err != nil { return nil, err @@ -824,7 +782,7 @@ func (qb *ImageStore) queryGroupedFields(ctx context.Context, options models.Ima return models.NewImageQueryResult(qb), nil } - aggregateQuery := qb.newQuery() + aggregateQuery := imageRepository.newQuery() if options.Count { aggregateQuery.addColumn("COUNT(DISTINCT temp.id) as total") @@ -868,7 +826,7 @@ func (qb *ImageStore) queryGroupedFields(ctx context.Context, options models.Ima Megapixels null.Float Size null.Float }{} - if err := qb.repository.queryStruct(ctx, aggregateQuery.toSQL(includeSortPagination), query.args, &out); err != nil { + if err := imageRepository.queryStruct(ctx, aggregateQuery.toSQL(includeSortPagination), query.args, &out); err != nil { return nil, err } @@ -888,171 +846,6 @@ func (qb *ImageStore) QueryCount(ctx context.Context, imageFilter *models.ImageF return query.executeCount(ctx) } -func imageFileCountCriterionHandler(qb *ImageStore, fileCount *models.IntCriterionInput) criterionHandlerFunc { - h := countCriterionHandlerBuilder{ - primaryTable: imageTable, - joinTable: imagesFilesTable, - primaryFK: imageIDColumn, - } - - return h.handler(fileCount) -} - -func imageIsMissingCriterionHandler(qb *ImageStore, isMissing *string) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if isMissing != nil && *isMissing != "" { - switch *isMissing { - case "studio": - f.addWhere("images.studio_id IS NULL") - case "performers": - qb.performersRepository().join(f, "performers_join", "images.id") - f.addWhere("performers_join.image_id IS NULL") - case "galleries": - qb.galleriesRepository().join(f, "galleries_join", "images.id") - f.addWhere("galleries_join.image_id IS NULL") - case "tags": - qb.tagsRepository().join(f, "tags_join", "images.id") - f.addWhere("tags_join.image_id IS NULL") - default: - f.addWhere("(images." + *isMissing + " IS NULL OR TRIM(images." + *isMissing + ") = '')") - } - } - } -} - -func imageURLsCriterionHandler(url *models.StringCriterionInput) criterionHandlerFunc { - h := stringListCriterionHandlerBuilder{ - joinTable: imagesURLsTable, - stringColumn: imageURLColumn, - addJoinTable: func(f *filterBuilder) { - imagesURLsTableMgr.join(f, "", "images.id") - }, - } - - return h.handler(url) -} - -func (qb *ImageStore) getMultiCriterionHandlerBuilder(foreignTable, joinTable, foreignFK string, addJoinsFunc func(f *filterBuilder)) multiCriterionHandlerBuilder { - return multiCriterionHandlerBuilder{ - primaryTable: imageTable, - foreignTable: foreignTable, - joinTable: joinTable, - primaryFK: imageIDColumn, - foreignFK: foreignFK, - addJoinsFunc: addJoinsFunc, - } -} - -func imageTagsCriterionHandler(qb *ImageStore, tags *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { - h := joinedHierarchicalMultiCriterionHandlerBuilder{ - tx: qb.tx, - - primaryTable: imageTable, - foreignTable: tagTable, - foreignFK: "tag_id", - - relationsTable: "tags_relations", - joinAs: "image_tag", - joinTable: imagesTagsTable, - primaryFK: imageIDColumn, - } - - return h.handler(tags) -} - -func imageTagCountCriterionHandler(qb *ImageStore, tagCount *models.IntCriterionInput) criterionHandlerFunc { - h := countCriterionHandlerBuilder{ - primaryTable: imageTable, - joinTable: imagesTagsTable, - primaryFK: imageIDColumn, - } - - return h.handler(tagCount) -} - -func imageGalleriesCriterionHandler(qb *ImageStore, galleries *models.MultiCriterionInput) criterionHandlerFunc { - addJoinsFunc := func(f *filterBuilder) { - if galleries.Modifier == models.CriterionModifierIncludes || galleries.Modifier == models.CriterionModifierIncludesAll { - f.addInnerJoin(galleriesImagesTable, "", "galleries_images.image_id = images.id") - f.addInnerJoin(galleryTable, "", "galleries_images.gallery_id = galleries.id") - } - } - h := qb.getMultiCriterionHandlerBuilder(galleryTable, galleriesImagesTable, galleryIDColumn, addJoinsFunc) - - return h.handler(galleries) -} - -func imagePerformersCriterionHandler(qb *ImageStore, performers *models.MultiCriterionInput) criterionHandlerFunc { - h := joinedMultiCriterionHandlerBuilder{ - primaryTable: imageTable, - joinTable: performersImagesTable, - joinAs: "performers_join", - primaryFK: imageIDColumn, - foreignFK: performerIDColumn, - - addJoinTable: func(f *filterBuilder) { - qb.performersRepository().join(f, "performers_join", "images.id") - }, - } - - return h.handler(performers) -} - -func imagePerformerCountCriterionHandler(qb *ImageStore, performerCount *models.IntCriterionInput) criterionHandlerFunc { - h := countCriterionHandlerBuilder{ - primaryTable: imageTable, - joinTable: performersImagesTable, - primaryFK: imageIDColumn, - } - - return h.handler(performerCount) -} - -func imagePerformerFavoriteCriterionHandler(performerfavorite *bool) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if performerfavorite != nil { - f.addLeftJoin("performers_images", "", "images.id = performers_images.image_id") - - if *performerfavorite { - // contains at least one favorite - f.addLeftJoin("performers", "", "performers.id = performers_images.performer_id") - f.addWhere("performers.favorite = 1") - } else { - // contains zero favorites - f.addLeftJoin(`(SELECT performers_images.image_id as id FROM performers_images -JOIN performers ON performers.id = performers_images.performer_id -GROUP BY performers_images.image_id HAVING SUM(performers.favorite) = 0)`, "nofaves", "images.id = nofaves.id") - f.addWhere("performers_images.image_id IS NULL OR nofaves.id IS NOT NULL") - } - } - } -} - -func imagePerformerAgeCriterionHandler(performerAge *models.IntCriterionInput) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if performerAge != nil { - f.addInnerJoin("performers_images", "", "images.id = performers_images.image_id") - f.addInnerJoin("performers", "", "performers_images.performer_id = performers.id") - - f.addWhere("images.date != '' AND performers.birthdate != ''") - f.addWhere("images.date IS NOT NULL AND performers.birthdate IS NOT NULL") - - ageCalc := "cast(strftime('%Y.%m%d', images.date) - strftime('%Y.%m%d', performers.birthdate) as int)" - whereClause, args := getIntWhereClause(ageCalc, performerAge.Modifier, performerAge.Value, performerAge.Value2) - f.addWhere(whereClause, args...) - } - } -} - -func imagePerformerTagsCriterionHandler(qb *ImageStore, tags *models.HierarchicalMultiCriterionInput) criterionHandler { - return &joinedPerformerTagsHandler{ - criterion: tags, - primaryTable: imageTable, - joinTable: performersImagesTable, - joinPrimaryKey: imageIDColumn, - } -} - var imageSortOptions = sortOptions{ "created_at", "date", @@ -1138,34 +931,13 @@ func (qb *ImageStore) setImageSortAndPagination(q *queryBuilder, findFilter *mod return nil } -func (qb *ImageStore) galleriesRepository() *joinRepository { - return &joinRepository{ - repository: repository{ - tx: qb.tx, - tableName: galleriesImagesTable, - idColumn: imageIDColumn, - }, - fkColumn: galleryIDColumn, - } -} - -func (qb *ImageStore) filesRepository() *filesRepository { - return &filesRepository{ - repository: repository{ - tx: qb.tx, - tableName: imagesFilesTable, - idColumn: imageIDColumn, - }, - } -} - func (qb *ImageStore) AddFileID(ctx context.Context, id int, fileID models.FileID) error { const firstPrimary = false return imagesFilesTableMgr.insertJoins(ctx, id, firstPrimary, []models.FileID{fileID}) } func (qb *ImageStore) GetGalleryIDs(ctx context.Context, imageID int) ([]int, error) { - return qb.galleriesRepository().getIDs(ctx, imageID) + return imageRepository.galleries.getIDs(ctx, imageID) } // func (qb *imageQueryBuilder) UpdateGalleries(ctx context.Context, imageID int, galleryIDs []int) error { @@ -1173,46 +945,22 @@ func (qb *ImageStore) GetGalleryIDs(ctx context.Context, imageID int) ([]int, er // return qb.galleriesRepository().replace(ctx, imageID, galleryIDs) // } -func (qb *ImageStore) performersRepository() *joinRepository { - return &joinRepository{ - repository: repository{ - tx: qb.tx, - tableName: performersImagesTable, - idColumn: imageIDColumn, - }, - fkColumn: performerIDColumn, - } -} - func (qb *ImageStore) GetPerformerIDs(ctx context.Context, imageID int) ([]int, error) { - return qb.performersRepository().getIDs(ctx, imageID) + return imageRepository.performers.getIDs(ctx, imageID) } func (qb *ImageStore) UpdatePerformers(ctx context.Context, imageID int, performerIDs []int) error { // Delete the existing joins and then create new ones - return qb.performersRepository().replace(ctx, imageID, performerIDs) -} - -func (qb *ImageStore) tagsRepository() *joinRepository { - return &joinRepository{ - repository: repository{ - tx: qb.tx, - tableName: imagesTagsTable, - idColumn: imageIDColumn, - }, - fkColumn: tagIDColumn, - foreignTable: tagTable, - orderBy: "tags.name ASC", - } + return imageRepository.performers.replace(ctx, imageID, performerIDs) } func (qb *ImageStore) GetTagIDs(ctx context.Context, imageID int) ([]int, error) { - return qb.tagsRepository().getIDs(ctx, imageID) + return imageRepository.tags.getIDs(ctx, imageID) } func (qb *ImageStore) UpdateTags(ctx context.Context, imageID int, tagIDs []int) error { // Delete the existing joins and then create new ones - return qb.tagsRepository().replace(ctx, imageID, tagIDs) + return imageRepository.tags.replace(ctx, imageID, tagIDs) } func (qb *ImageStore) GetURLs(ctx context.Context, imageID int) ([]string, error) { diff --git a/pkg/sqlite/image_filter.go b/pkg/sqlite/image_filter.go new file mode 100644 index 00000000000..4fef482714f --- /dev/null +++ b/pkg/sqlite/image_filter.go @@ -0,0 +1,290 @@ +package sqlite + +import ( + "context" + + "github.com/stashapp/stash/pkg/models" +) + +type imageFilterHandler struct { + imageFilter *models.ImageFilterType +} + +func (qb *imageFilterHandler) validate() error { + imageFilter := qb.imageFilter + if imageFilter == nil { + return nil + } + + if err := validateFilterCombination(imageFilter.OperatorFilter); err != nil { + return err + } + + if subFilter := imageFilter.SubFilter(); subFilter != nil { + sqb := &imageFilterHandler{imageFilter: subFilter} + if err := sqb.validate(); err != nil { + return err + } + } + + return nil +} + +func (qb *imageFilterHandler) handle(ctx context.Context, f *filterBuilder) { + imageFilter := qb.imageFilter + if imageFilter == nil { + return + } + + if err := qb.validate(); err != nil { + f.setError(err) + return + } + + sf := imageFilter.SubFilter() + if sf != nil { + sub := &imageFilterHandler{sf} + handleSubFilter(ctx, sub, f, imageFilter.OperatorFilter) + } + + f.handleCriterion(ctx, qb.criterionHandler()) +} + +func (qb *imageFilterHandler) criterionHandler() criterionHandler { + imageFilter := qb.imageFilter + return compoundHandler{ + intCriterionHandler(imageFilter.ID, "images.id", nil), + criterionHandlerFunc(func(ctx context.Context, f *filterBuilder) { + if imageFilter.Checksum != nil { + imageRepository.addImagesFilesTable(f) + f.addInnerJoin(fingerprintTable, "fingerprints_md5", "images_files.file_id = fingerprints_md5.file_id AND fingerprints_md5.type = 'md5'") + } + + stringCriterionHandler(imageFilter.Checksum, "fingerprints_md5.fingerprint")(ctx, f) + }), + stringCriterionHandler(imageFilter.Title, "images.title"), + stringCriterionHandler(imageFilter.Code, "images.code"), + stringCriterionHandler(imageFilter.Details, "images.details"), + stringCriterionHandler(imageFilter.Photographer, "images.photographer"), + + pathCriterionHandler(imageFilter.Path, "folders.path", "files.basename", imageRepository.addFoldersTable), + qb.fileCountCriterionHandler(imageFilter.FileCount), + intCriterionHandler(imageFilter.Rating100, "images.rating", nil), + intCriterionHandler(imageFilter.OCounter, "images.o_counter", nil), + boolCriterionHandler(imageFilter.Organized, "images.organized", nil), + &dateCriterionHandler{imageFilter.Date, "images.date", nil}, + qb.urlsCriterionHandler(imageFilter.URL), + + resolutionCriterionHandler(imageFilter.Resolution, "image_files.height", "image_files.width", imageRepository.addImageFilesTable), + orientationCriterionHandler(imageFilter.Orientation, "image_files.height", "image_files.width", imageRepository.addImageFilesTable), + qb.missingCriterionHandler(imageFilter.IsMissing), + + qb.tagsCriterionHandler(imageFilter.Tags), + qb.tagCountCriterionHandler(imageFilter.TagCount), + qb.galleriesCriterionHandler(imageFilter.Galleries), + qb.performersCriterionHandler(imageFilter.Performers), + qb.performerCountCriterionHandler(imageFilter.PerformerCount), + studioCriterionHandler(imageTable, imageFilter.Studios), + qb.performerTagsCriterionHandler(imageFilter.PerformerTags), + qb.performerFavoriteCriterionHandler(imageFilter.PerformerFavorite), + qb.performerAgeCriterionHandler(imageFilter.PerformerAge), + ×tampCriterionHandler{imageFilter.CreatedAt, "images.created_at", nil}, + ×tampCriterionHandler{imageFilter.UpdatedAt, "images.updated_at", nil}, + + &relatedFilterHandler{ + relatedIDCol: "galleries_images.gallery_id", + relatedRepo: galleryRepository.repository, + relatedHandler: &galleryFilterHandler{imageFilter.GalleriesFilter}, + joinFn: func(f *filterBuilder) { + imageRepository.galleries.innerJoin(f, "", "images.id") + }, + }, + + &relatedFilterHandler{ + relatedIDCol: "performers_join.performer_id", + relatedRepo: performerRepository.repository, + relatedHandler: &performerFilterHandler{imageFilter.PerformersFilter}, + joinFn: func(f *filterBuilder) { + imageRepository.performers.innerJoin(f, "performers_join", "images.id") + }, + }, + + &relatedFilterHandler{ + relatedIDCol: "images.studio_id", + relatedRepo: studioRepository.repository, + relatedHandler: &studioFilterHandler{imageFilter.StudiosFilter}, + }, + + &relatedFilterHandler{ + relatedIDCol: "image_tag.tag_id", + relatedRepo: tagRepository.repository, + relatedHandler: &tagFilterHandler{imageFilter.TagsFilter}, + joinFn: func(f *filterBuilder) { + imageRepository.tags.innerJoin(f, "image_tag", "images.id") + }, + }, + } +} + +func (qb *imageFilterHandler) fileCountCriterionHandler(fileCount *models.IntCriterionInput) criterionHandlerFunc { + h := countCriterionHandlerBuilder{ + primaryTable: imageTable, + joinTable: imagesFilesTable, + primaryFK: imageIDColumn, + } + + return h.handler(fileCount) +} + +func (qb *imageFilterHandler) missingCriterionHandler(isMissing *string) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if isMissing != nil && *isMissing != "" { + switch *isMissing { + case "studio": + f.addWhere("images.studio_id IS NULL") + case "performers": + imageRepository.performers.join(f, "performers_join", "images.id") + f.addWhere("performers_join.image_id IS NULL") + case "galleries": + imageRepository.galleries.join(f, "galleries_join", "images.id") + f.addWhere("galleries_join.image_id IS NULL") + case "tags": + imageRepository.tags.join(f, "tags_join", "images.id") + f.addWhere("tags_join.image_id IS NULL") + default: + f.addWhere("(images." + *isMissing + " IS NULL OR TRIM(images." + *isMissing + ") = '')") + } + } + } +} + +func (qb *imageFilterHandler) urlsCriterionHandler(url *models.StringCriterionInput) criterionHandlerFunc { + h := stringListCriterionHandlerBuilder{ + joinTable: imagesURLsTable, + stringColumn: imageURLColumn, + addJoinTable: func(f *filterBuilder) { + imagesURLsTableMgr.join(f, "", "images.id") + }, + } + + return h.handler(url) +} + +func (qb *imageFilterHandler) getMultiCriterionHandlerBuilder(foreignTable, joinTable, foreignFK string, addJoinsFunc func(f *filterBuilder)) multiCriterionHandlerBuilder { + return multiCriterionHandlerBuilder{ + primaryTable: imageTable, + foreignTable: foreignTable, + joinTable: joinTable, + primaryFK: imageIDColumn, + foreignFK: foreignFK, + addJoinsFunc: addJoinsFunc, + } +} + +func (qb *imageFilterHandler) tagsCriterionHandler(tags *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { + h := joinedHierarchicalMultiCriterionHandlerBuilder{ + primaryTable: imageTable, + foreignTable: tagTable, + foreignFK: "tag_id", + + relationsTable: "tags_relations", + joinAs: "image_tag", + joinTable: imagesTagsTable, + primaryFK: imageIDColumn, + } + + return h.handler(tags) +} + +func (qb *imageFilterHandler) tagCountCriterionHandler(tagCount *models.IntCriterionInput) criterionHandlerFunc { + h := countCriterionHandlerBuilder{ + primaryTable: imageTable, + joinTable: imagesTagsTable, + primaryFK: imageIDColumn, + } + + return h.handler(tagCount) +} + +func (qb *imageFilterHandler) galleriesCriterionHandler(galleries *models.MultiCriterionInput) criterionHandlerFunc { + addJoinsFunc := func(f *filterBuilder) { + if galleries.Modifier == models.CriterionModifierIncludes || galleries.Modifier == models.CriterionModifierIncludesAll { + f.addInnerJoin(galleriesImagesTable, "", "galleries_images.image_id = images.id") + f.addInnerJoin(galleryTable, "", "galleries_images.gallery_id = galleries.id") + } + } + h := qb.getMultiCriterionHandlerBuilder(galleryTable, galleriesImagesTable, galleryIDColumn, addJoinsFunc) + + return h.handler(galleries) +} + +func (qb *imageFilterHandler) performersCriterionHandler(performers *models.MultiCriterionInput) criterionHandlerFunc { + h := joinedMultiCriterionHandlerBuilder{ + primaryTable: imageTable, + joinTable: performersImagesTable, + joinAs: "performers_join", + primaryFK: imageIDColumn, + foreignFK: performerIDColumn, + + addJoinTable: func(f *filterBuilder) { + imageRepository.performers.join(f, "performers_join", "images.id") + }, + } + + return h.handler(performers) +} + +func (qb *imageFilterHandler) performerCountCriterionHandler(performerCount *models.IntCriterionInput) criterionHandlerFunc { + h := countCriterionHandlerBuilder{ + primaryTable: imageTable, + joinTable: performersImagesTable, + primaryFK: imageIDColumn, + } + + return h.handler(performerCount) +} + +func (qb *imageFilterHandler) performerFavoriteCriterionHandler(performerfavorite *bool) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if performerfavorite != nil { + f.addLeftJoin("performers_images", "", "images.id = performers_images.image_id") + + if *performerfavorite { + // contains at least one favorite + f.addLeftJoin("performers", "", "performers.id = performers_images.performer_id") + f.addWhere("performers.favorite = 1") + } else { + // contains zero favorites + f.addLeftJoin(`(SELECT performers_images.image_id as id FROM performers_images +JOIN performers ON performers.id = performers_images.performer_id +GROUP BY performers_images.image_id HAVING SUM(performers.favorite) = 0)`, "nofaves", "images.id = nofaves.id") + f.addWhere("performers_images.image_id IS NULL OR nofaves.id IS NOT NULL") + } + } + } +} + +func (qb *imageFilterHandler) performerAgeCriterionHandler(performerAge *models.IntCriterionInput) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if performerAge != nil { + f.addInnerJoin("performers_images", "", "images.id = performers_images.image_id") + f.addInnerJoin("performers", "", "performers_images.performer_id = performers.id") + + f.addWhere("images.date != '' AND performers.birthdate != ''") + f.addWhere("images.date IS NOT NULL AND performers.birthdate IS NOT NULL") + + ageCalc := "cast(strftime('%Y.%m%d', images.date) - strftime('%Y.%m%d', performers.birthdate) as int)" + whereClause, args := getIntWhereClause(ageCalc, performerAge.Modifier, performerAge.Value, performerAge.Value2) + f.addWhere(whereClause, args...) + } + } +} + +func (qb *imageFilterHandler) performerTagsCriterionHandler(tags *models.HierarchicalMultiCriterionInput) criterionHandler { + return &joinedPerformerTagsHandler{ + criterion: tags, + primaryTable: imageTable, + joinTable: performersImagesTable, + joinPrimaryKey: imageIDColumn, + } +} diff --git a/pkg/sqlite/image_test.go b/pkg/sqlite/image_test.go index 7a5b9ce1e1a..e1246ebbe12 100644 --- a/pkg/sqlite/image_test.go +++ b/pkg/sqlite/image_test.go @@ -1668,10 +1668,12 @@ func TestImageQueryPathOr(t *testing.T) { Value: image1Path, Modifier: models.CriterionModifierEquals, }, - Or: &models.ImageFilterType{ - Path: &models.StringCriterionInput{ - Value: image2Path, - Modifier: models.CriterionModifierEquals, + OperatorFilter: models.OperatorFilter[models.ImageFilterType]{ + Or: &models.ImageFilterType{ + Path: &models.StringCriterionInput{ + Value: image2Path, + Modifier: models.CriterionModifierEquals, + }, }, }, } @@ -1702,10 +1704,12 @@ func TestImageQueryPathAndRating(t *testing.T) { Value: imagePath, Modifier: models.CriterionModifierEquals, }, - And: &models.ImageFilterType{ - Rating100: &models.IntCriterionInput{ - Value: int(imageRating.Int64), - Modifier: models.CriterionModifierEquals, + OperatorFilter: models.OperatorFilter[models.ImageFilterType]{ + And: &models.ImageFilterType{ + Rating100: &models.IntCriterionInput{ + Value: int(imageRating.Int64), + Modifier: models.CriterionModifierEquals, + }, }, }, } @@ -1743,8 +1747,10 @@ func TestImageQueryPathNotRating(t *testing.T) { imageFilter := models.ImageFilterType{ Path: &pathCriterion, - Not: &models.ImageFilterType{ - Rating100: &ratingCriterion, + OperatorFilter: models.OperatorFilter[models.ImageFilterType]{ + Not: &models.ImageFilterType{ + Rating100: &ratingCriterion, + }, }, } @@ -1775,8 +1781,10 @@ func TestImageIllegalQuery(t *testing.T) { } imageFilter := &models.ImageFilterType{ - And: &subFilter, - Or: &subFilter, + OperatorFilter: models.OperatorFilter[models.ImageFilterType]{ + And: &subFilter, + Or: &subFilter, + }, } withTxn(func(ctx context.Context) error { diff --git a/pkg/sqlite/movies.go b/pkg/sqlite/movies.go index 0d7c429d0da..acbf036f2bb 100644 --- a/pkg/sqlite/movies.go +++ b/pkg/sqlite/movies.go @@ -96,8 +96,25 @@ func (r *movieRowRecord) fromPartial(o models.MoviePartial) { r.setTimestamp("updated_at", o.UpdatedAt) } -type MovieStore struct { +type movieRepositoryType struct { repository + scenes repository +} + +var ( + movieRepository = movieRepositoryType{ + repository: repository{ + tableName: movieTable, + idColumn: idColumn, + }, + scenes: repository{ + tableName: moviesScenesTable, + idColumn: movieIDColumn, + }, + } +) + +type MovieStore struct { blobJoinQueryBuilder tableMgr *table @@ -105,10 +122,6 @@ type MovieStore struct { func NewMovieStore(blobStore *BlobStore) *MovieStore { return &MovieStore{ - repository: repository{ - tableName: movieTable, - idColumn: idColumn, - }, blobJoinQueryBuilder: blobJoinQueryBuilder{ blobStore: blobStore, joinTable: movieTable, @@ -180,7 +193,7 @@ func (qb *MovieStore) Destroy(ctx context.Context, id int) error { return err } - return qb.destroyExisting(ctx, []int{id}) + return movieRepository.destroyExisting(ctx, []int{id}) } // returns nil, nil if not found @@ -327,25 +340,6 @@ func (qb *MovieStore) All(ctx context.Context) ([]*models.Movie, error) { )) } -func (qb *MovieStore) makeFilter(ctx context.Context, movieFilter *models.MovieFilterType) *filterBuilder { - query := &filterBuilder{} - - 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.Rating100, "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, studioCriterionHandler(movieTable, 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 -} - func (qb *MovieStore) makeQuery(ctx context.Context, movieFilter *models.MovieFilterType, findFilter *models.FindFilterType) (*queryBuilder, error) { if findFilter == nil { findFilter = &models.FindFilterType{} @@ -354,7 +348,7 @@ func (qb *MovieStore) makeQuery(ctx context.Context, movieFilter *models.MovieFi movieFilter = &models.MovieFilterType{} } - query := qb.newQuery() + query := movieRepository.newQuery() distinctIDs(&query, movieTable) if q := findFilter.Q; q != nil && *q != "" { @@ -362,7 +356,9 @@ func (qb *MovieStore) makeQuery(ctx context.Context, movieFilter *models.MovieFi query.parseQueryString(searchColumns, *q) } - filter := qb.makeFilter(ctx, movieFilter) + filter := filterBuilderFromHandler(ctx, &movieFilterHandler{ + movieFilter: movieFilter, + }) if err := query.addFilter(filter); err != nil { return nil, err @@ -407,71 +403,6 @@ func (qb *MovieStore) QueryCount(ctx context.Context, movieFilter *models.MovieF return query.executeCount(ctx) } -func movieIsMissingCriterionHandler(qb *MovieStore, isMissing *string) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if isMissing != nil && *isMissing != "" { - switch *isMissing { - case "front_image": - f.addWhere("movies.front_image_blob IS NULL") - case "back_image": - f.addWhere("movies.back_image_blob IS NULL") - case "scenes": - f.addLeftJoin("movies_scenes", "", "movies_scenes.movie_id = movies.id") - f.addWhere("movies_scenes.scene_id IS NULL") - default: - f.addWhere("(movies." + *isMissing + " IS NULL OR TRIM(movies." + *isMissing + ") = '')") - } - } - } -} - -func moviePerformersCriterionHandler(qb *MovieStore, performers *models.MultiCriterionInput) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if performers != nil { - if performers.Modifier == models.CriterionModifierIsNull || performers.Modifier == models.CriterionModifierNotNull { - var notClause string - if performers.Modifier == models.CriterionModifierNotNull { - notClause = "NOT" - } - - f.addLeftJoin("movies_scenes", "", "movies.id = movies_scenes.movie_id") - f.addLeftJoin("performers_scenes", "", "movies_scenes.scene_id = performers_scenes.scene_id") - - f.addWhere(fmt.Sprintf("performers_scenes.performer_id IS %s NULL", notClause)) - return - } - - if len(performers.Value) == 0 { - return - } - - var args []interface{} - for _, arg := range performers.Value { - args = append(args, arg) - } - - // Hack, can't apply args to join, nor inner join on a left join, so use CTE instead - f.addWith(`movies_performers AS ( - SELECT movies_scenes.movie_id, performers_scenes.performer_id - FROM movies_scenes - INNER JOIN performers_scenes ON movies_scenes.scene_id = performers_scenes.scene_id - WHERE performers_scenes.performer_id IN`+getInBinding(len(performers.Value))+` - )`, args...) - f.addLeftJoin("movies_performers", "", "movies.id = movies_performers.movie_id") - - switch performers.Modifier { - case models.CriterionModifierIncludes: - f.addWhere("movies_performers.performer_id IS NOT NULL") - case models.CriterionModifierIncludesAll: - f.addWhere("movies_performers.performer_id IS NOT NULL") - f.addHaving("COUNT(DISTINCT movies_performers.performer_id) = ?", len(performers.Value)) - case models.CriterionModifierExcludes: - f.addWhere("movies_performers.performer_id IS NULL") - } - } - } -} - var movieSortOptions = sortOptions{ "created_at", "date", @@ -516,7 +447,7 @@ func (qb *MovieStore) getMovieSort(findFilter *models.FindFilterType) (string, e func (qb *MovieStore) queryMovies(ctx context.Context, query string, args []interface{}) ([]*models.Movie, error) { const single = false var ret []*models.Movie - if err := qb.queryFunc(ctx, query, args, single, func(r *sqlx.Rows) error { + if err := movieRepository.queryFunc(ctx, query, args, single, func(r *sqlx.Rows) error { var f movieRow if err := r.StructScan(&f); err != nil { return err @@ -586,7 +517,7 @@ INNER JOIN performers_scenes ON performers_scenes.scene_id = movies_scenes.scene WHERE performers_scenes.performer_id = ? ` args := []interface{}{performerID} - return qb.runCountQuery(ctx, query, args) + return movieRepository.runCountQuery(ctx, query, args) } func (qb *MovieStore) FindByStudioID(ctx context.Context, studioID int) ([]*models.Movie, error) { @@ -604,5 +535,5 @@ FROM movies WHERE movies.studio_id = ? ` args := []interface{}{studioID} - return qb.runCountQuery(ctx, query, args) + return movieRepository.runCountQuery(ctx, query, args) } diff --git a/pkg/sqlite/movies_filter.go b/pkg/sqlite/movies_filter.go new file mode 100644 index 00000000000..78d5abf5d22 --- /dev/null +++ b/pkg/sqlite/movies_filter.go @@ -0,0 +1,150 @@ +package sqlite + +import ( + "context" + "fmt" + + "github.com/stashapp/stash/pkg/models" +) + +type movieFilterHandler struct { + movieFilter *models.MovieFilterType +} + +func (qb *movieFilterHandler) validate() error { + movieFilter := qb.movieFilter + if movieFilter == nil { + return nil + } + + if err := validateFilterCombination(movieFilter.OperatorFilter); err != nil { + return err + } + + if subFilter := movieFilter.SubFilter(); subFilter != nil { + sqb := &movieFilterHandler{movieFilter: subFilter} + if err := sqb.validate(); err != nil { + return err + } + } + + return nil +} + +func (qb *movieFilterHandler) handle(ctx context.Context, f *filterBuilder) { + movieFilter := qb.movieFilter + if movieFilter == nil { + return + } + + if err := qb.validate(); err != nil { + f.setError(err) + return + } + + sf := movieFilter.SubFilter() + if sf != nil { + sub := &movieFilterHandler{sf} + handleSubFilter(ctx, sub, f, movieFilter.OperatorFilter) + } + + f.handleCriterion(ctx, qb.criterionHandler()) +} + +func (qb *movieFilterHandler) criterionHandler() criterionHandler { + movieFilter := qb.movieFilter + return compoundHandler{ + stringCriterionHandler(movieFilter.Name, "movies.name"), + stringCriterionHandler(movieFilter.Director, "movies.director"), + stringCriterionHandler(movieFilter.Synopsis, "movies.synopsis"), + intCriterionHandler(movieFilter.Rating100, "movies.rating", nil), + floatIntCriterionHandler(movieFilter.Duration, "movies.duration", nil), + qb.missingCriterionHandler(movieFilter.IsMissing), + stringCriterionHandler(movieFilter.URL, "movies.url"), + studioCriterionHandler(movieTable, movieFilter.Studios), + qb.performersCriterionHandler(movieFilter.Performers), + &dateCriterionHandler{movieFilter.Date, "movies.date", nil}, + ×tampCriterionHandler{movieFilter.CreatedAt, "movies.created_at", nil}, + ×tampCriterionHandler{movieFilter.UpdatedAt, "movies.updated_at", nil}, + + &relatedFilterHandler{ + relatedIDCol: "movies_scenes.scene_id", + relatedRepo: sceneRepository.repository, + relatedHandler: &sceneFilterHandler{movieFilter.ScenesFilter}, + joinFn: func(f *filterBuilder) { + movieRepository.scenes.innerJoin(f, "", "movies.id") + }, + }, + + &relatedFilterHandler{ + relatedIDCol: "movies.studio_id", + relatedRepo: studioRepository.repository, + relatedHandler: &studioFilterHandler{movieFilter.StudiosFilter}, + }, + } +} + +func (qb *movieFilterHandler) missingCriterionHandler(isMissing *string) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if isMissing != nil && *isMissing != "" { + switch *isMissing { + case "front_image": + f.addWhere("movies.front_image_blob IS NULL") + case "back_image": + f.addWhere("movies.back_image_blob IS NULL") + case "scenes": + f.addLeftJoin("movies_scenes", "", "movies_scenes.movie_id = movies.id") + f.addWhere("movies_scenes.scene_id IS NULL") + default: + f.addWhere("(movies." + *isMissing + " IS NULL OR TRIM(movies." + *isMissing + ") = '')") + } + } + } +} + +func (qb *movieFilterHandler) performersCriterionHandler(performers *models.MultiCriterionInput) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if performers != nil { + if performers.Modifier == models.CriterionModifierIsNull || performers.Modifier == models.CriterionModifierNotNull { + var notClause string + if performers.Modifier == models.CriterionModifierNotNull { + notClause = "NOT" + } + + f.addLeftJoin("movies_scenes", "", "movies.id = movies_scenes.movie_id") + f.addLeftJoin("performers_scenes", "", "movies_scenes.scene_id = performers_scenes.scene_id") + + f.addWhere(fmt.Sprintf("performers_scenes.performer_id IS %s NULL", notClause)) + return + } + + if len(performers.Value) == 0 { + return + } + + var args []interface{} + for _, arg := range performers.Value { + args = append(args, arg) + } + + // Hack, can't apply args to join, nor inner join on a left join, so use CTE instead + f.addWith(`movies_performers AS ( + SELECT movies_scenes.movie_id, performers_scenes.performer_id + FROM movies_scenes + INNER JOIN performers_scenes ON movies_scenes.scene_id = performers_scenes.scene_id + WHERE performers_scenes.performer_id IN`+getInBinding(len(performers.Value))+` + )`, args...) + f.addLeftJoin("movies_performers", "", "movies.id = movies_performers.movie_id") + + switch performers.Modifier { + case models.CriterionModifierIncludes: + f.addWhere("movies_performers.performer_id IS NOT NULL") + case models.CriterionModifierIncludesAll: + f.addWhere("movies_performers.performer_id IS NOT NULL") + f.addHaving("COUNT(DISTINCT movies_performers.performer_id) = ?", len(performers.Value)) + case models.CriterionModifierExcludes: + f.addWhere("movies_performers.performer_id IS NULL") + } + } + } +} diff --git a/pkg/sqlite/performer.go b/pkg/sqlite/performer.go index dcdc92f0f0e..4ba05168d6e 100644 --- a/pkg/sqlite/performer.go +++ b/pkg/sqlite/performer.go @@ -5,8 +5,6 @@ import ( "database/sql" "errors" "fmt" - "strconv" - "strings" "github.com/doug-martin/goqu/v9" "github.com/doug-martin/goqu/v9/exp" @@ -176,19 +174,73 @@ func (r *performerRowRecord) fromPartial(o models.PerformerPartial) { r.setBool("ignore_auto_tag", o.IgnoreAutoTag) } -type PerformerStore struct { +type performerRepositoryType struct { repository - blobJoinQueryBuilder - tableMgr *table + tags joinRepository + stashIDs stashIDRepository + + scenes joinRepository + images joinRepository + galleries joinRepository } -func NewPerformerStore(blobStore *BlobStore) *PerformerStore { - return &PerformerStore{ +var ( + performerRepository = performerRepositoryType{ repository: repository{ tableName: performerTable, idColumn: idColumn, }, + tags: joinRepository{ + repository: repository{ + tableName: performersTagsTable, + idColumn: performerIDColumn, + }, + fkColumn: tagIDColumn, + foreignTable: tagTable, + orderBy: "tags.name ASC", + }, + stashIDs: stashIDRepository{ + repository{ + tableName: "performer_stash_ids", + idColumn: performerIDColumn, + }, + }, + scenes: joinRepository{ + repository: repository{ + tableName: performersScenesTable, + idColumn: performerIDColumn, + }, + fkColumn: sceneIDColumn, + foreignTable: sceneTable, + }, + images: joinRepository{ + repository: repository{ + tableName: performersImagesTable, + idColumn: performerIDColumn, + }, + fkColumn: imageIDColumn, + foreignTable: imageTable, + }, + galleries: joinRepository{ + repository: repository{ + tableName: performersGalleriesTable, + idColumn: performerIDColumn, + }, + fkColumn: galleryIDColumn, + foreignTable: galleryTable, + }, + } +) + +type PerformerStore struct { + blobJoinQueryBuilder + + tableMgr *table +} + +func NewPerformerStore(blobStore *BlobStore) *PerformerStore { + return &PerformerStore{ blobJoinQueryBuilder: blobJoinQueryBuilder{ blobStore: blobStore, joinTable: performerTable, @@ -312,7 +364,7 @@ func (qb *PerformerStore) Destroy(ctx context.Context, id int) error { return err } - return qb.destroyExisting(ctx, []int{id}) + return performerRepository.destroyExisting(ctx, []int{id}) } // returns nil, nil if not found @@ -525,161 +577,6 @@ func (qb *PerformerStore) QueryForAutoTag(ctx context.Context, words []string) ( return ret, nil } -func (qb *PerformerStore) validateFilter(filter *models.PerformerFilterType) error { - const and = "AND" - const or = "OR" - const not = "NOT" - - if filter.And != nil { - if filter.Or != nil { - return illegalFilterCombination(and, or) - } - if filter.Not != nil { - return illegalFilterCombination(and, not) - } - - return qb.validateFilter(filter.And) - } - - if filter.Or != nil { - if filter.Not != nil { - return illegalFilterCombination(or, not) - } - - return qb.validateFilter(filter.Or) - } - - if filter.Not != nil { - 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 *PerformerStore) makeFilter(ctx context.Context, filter *models.PerformerFilterType) *filterBuilder { - query := &filterBuilder{} - - if filter.And != nil { - query.and(qb.makeFilter(ctx, filter.And)) - } - if filter.Or != nil { - query.or(qb.makeFilter(ctx, filter.Or)) - } - if filter.Not != nil { - query.not(qb.makeFilter(ctx, filter.Not)) - } - - const tableName = performerTable - query.handleCriterion(ctx, stringCriterionHandler(filter.Name, tableName+".name")) - query.handleCriterion(ctx, stringCriterionHandler(filter.Disambiguation, tableName+".disambiguation")) - query.handleCriterion(ctx, stringCriterionHandler(filter.Details, tableName+".details")) - - query.handleCriterion(ctx, boolCriterionHandler(filter.FilterFavorites, tableName+".favorite", nil)) - query.handleCriterion(ctx, boolCriterionHandler(filter.IgnoreAutoTag, tableName+".ignore_auto_tag", nil)) - - query.handleCriterion(ctx, yearFilterCriterionHandler(filter.BirthYear, tableName+".birthdate")) - query.handleCriterion(ctx, yearFilterCriterionHandler(filter.DeathYear, tableName+".death_date")) - - query.handleCriterion(ctx, performerAgeFilterCriterionHandler(filter.Age)) - - query.handleCriterion(ctx, criterionHandlerFunc(func(ctx context.Context, f *filterBuilder) { - if gender := filter.Gender; gender != nil { - genderCopy := *gender - if genderCopy.Value.IsValid() && len(genderCopy.ValueList) == 0 { - genderCopy.ValueList = []models.GenderEnum{genderCopy.Value} - } - - v := utils.StringerSliceToStringSlice(genderCopy.ValueList) - enumCriterionHandler(genderCopy.Modifier, v, tableName+".gender")(ctx, f) - } - })) - - query.handleCriterion(ctx, performerIsMissingCriterionHandler(qb, filter.IsMissing)) - 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")) - - // 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, floatCriterionHandler(filter.PenisLength, tableName+".penis_length", nil)) - - query.handleCriterion(ctx, criterionHandlerFunc(func(ctx context.Context, f *filterBuilder) { - if circumcised := filter.Circumcised; circumcised != nil { - v := utils.StringerSliceToStringSlice(circumcised.Value) - enumCriterionHandler(circumcised.Modifier, v, tableName+".circumcised")(ctx, f) - } - })) - - 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.Rating100, 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)) - query.handleCriterion(ctx, criterionHandlerFunc(func(ctx context.Context, f *filterBuilder) { - if filter.StashID != nil { - qb.stashIDRepository().join(f, "performer_stash_ids", "performers.id") - 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", - }) - - query.handleCriterion(ctx, performerAliasCriterionHandler(qb, filter.Aliases)) - - query.handleCriterion(ctx, performerTagsCriterionHandler(qb, filter.Tags)) - - query.handleCriterion(ctx, performerStudiosCriterionHandler(qb, filter.Studios)) - - query.handleCriterion(ctx, performerAppearsWithCriterionHandler(qb, filter.Performers)) - - query.handleCriterion(ctx, performerTagCountCriterionHandler(qb, filter.TagCount)) - query.handleCriterion(ctx, performerSceneCountCriterionHandler(qb, filter.SceneCount)) - query.handleCriterion(ctx, performerImageCountCriterionHandler(qb, filter.ImageCount)) - query.handleCriterion(ctx, performerGalleryCountCriterionHandler(qb, filter.GalleryCount)) - query.handleCriterion(ctx, performerPlayCounterCriterionHandler(qb, filter.PlayCount)) - query.handleCriterion(ctx, performerOCounterCriterionHandler(qb, filter.OCounter)) - 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 *PerformerStore) makeQuery(ctx context.Context, performerFilter *models.PerformerFilterType, findFilter *models.FindFilterType) (*queryBuilder, error) { if performerFilter == nil { performerFilter = &models.PerformerFilterType{} @@ -688,7 +585,7 @@ func (qb *PerformerStore) makeQuery(ctx context.Context, performerFilter *models findFilter = &models.FindFilterType{} } - query := qb.newQuery() + query := performerRepository.newQuery() distinctIDs(&query, performerTable) if q := findFilter.Q; q != nil && *q != "" { @@ -697,10 +594,9 @@ func (qb *PerformerStore) makeQuery(ctx context.Context, performerFilter *models query.parseQueryString(searchColumns, *q) } - if err := qb.validateFilter(performerFilter); err != nil { - return nil, err - } - filter := qb.makeFilter(ctx, performerFilter) + filter := filterBuilderFromHandler(ctx, &performerFilterHandler{ + performerFilter: performerFilter, + }) if err := query.addFilter(filter); err != nil { return nil, err @@ -744,165 +640,16 @@ func (qb *PerformerStore) QueryCount(ctx context.Context, performerFilter *model return query.executeCount(ctx) } -// TODO - we need to provide a whitelist of possible values -func performerIsMissingCriterionHandler(qb *PerformerStore, isMissing *string) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if isMissing != nil && *isMissing != "" { - switch *isMissing { - case "scenes": // Deprecated: use `scene_count == 0` filter instead - f.addLeftJoin(performersScenesTable, "scenes_join", "scenes_join.performer_id = performers.id") - f.addWhere("scenes_join.scene_id IS NULL") - case "image": - f.addWhere("performers.image_blob IS NULL") - case "stash_id": - performersStashIDsTableMgr.join(f, "performer_stash_ids", "performers.id") - f.addWhere("performer_stash_ids.performer_id IS NULL") - case "aliases": - performersAliasesTableMgr.join(f, "", "performers.id") - f.addWhere("performer_aliases.alias IS NULL") - default: - f.addWhere("(performers." + *isMissing + " IS NULL OR TRIM(performers." + *isMissing + ") = '')") - } - } - } -} - -func yearFilterCriterionHandler(year *models.IntCriterionInput, col string) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if year != nil && year.Modifier.IsValid() { - clause, args := getIntCriterionWhereClause("cast(strftime('%Y', "+col+") as int)", *year) - f.addWhere(clause, args...) - } - } -} - -func performerAgeFilterCriterionHandler(age *models.IntCriterionInput) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if age != nil && age.Modifier.IsValid() { - clause, args := getIntCriterionWhereClause( - "cast(IFNULL(strftime('%Y.%m%d', performers.death_date), strftime('%Y.%m%d', 'now')) - strftime('%Y.%m%d', performers.birthdate) as int)", - *age, - ) - f.addWhere(clause, args...) - } - } -} - -func performerAliasCriterionHandler(qb *PerformerStore, alias *models.StringCriterionInput) criterionHandlerFunc { - h := stringListCriterionHandlerBuilder{ - joinTable: performersAliasesTable, - stringColumn: performerAliasColumn, - addJoinTable: func(f *filterBuilder) { - performersAliasesTableMgr.join(f, "", "performers.id") - }, - } - - return h.handler(alias) -} - -func performerTagsCriterionHandler(qb *PerformerStore, tags *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { - h := joinedHierarchicalMultiCriterionHandlerBuilder{ - tx: qb.tx, - - primaryTable: performerTable, - foreignTable: tagTable, - foreignFK: "tag_id", - - relationsTable: "tags_relations", - joinAs: "image_tag", - joinTable: performersTagsTable, - primaryFK: performerIDColumn, - } - - return h.handler(tags) -} - -func performerTagCountCriterionHandler(qb *PerformerStore, count *models.IntCriterionInput) criterionHandlerFunc { - h := countCriterionHandlerBuilder{ - primaryTable: performerTable, - joinTable: performersTagsTable, - primaryFK: performerIDColumn, - } - - return h.handler(count) -} - -func performerSceneCountCriterionHandler(qb *PerformerStore, count *models.IntCriterionInput) criterionHandlerFunc { - h := countCriterionHandlerBuilder{ - primaryTable: performerTable, - joinTable: performersScenesTable, - primaryFK: performerIDColumn, - } - - return h.handler(count) -} - -func performerImageCountCriterionHandler(qb *PerformerStore, count *models.IntCriterionInput) criterionHandlerFunc { - h := countCriterionHandlerBuilder{ - primaryTable: performerTable, - joinTable: performersImagesTable, - primaryFK: performerIDColumn, - } - - return h.handler(count) +func (qb *PerformerStore) sortByOCounter(direction string) string { + // need to sum the o_counter from scenes and images + return " ORDER BY (" + selectPerformerOCountSQL + ") " + direction } -func performerGalleryCountCriterionHandler(qb *PerformerStore, count *models.IntCriterionInput) criterionHandlerFunc { - h := countCriterionHandlerBuilder{ - primaryTable: performerTable, - joinTable: performersGalleriesTable, - primaryFK: performerIDColumn, - } - - return h.handler(count) +func (qb *PerformerStore) sortByPlayCount(direction string) string { + // need to sum the o_counter from scenes and images + return " ORDER BY (" + selectPerformerPlayCountSQL + ") " + direction } -// used for sorting and filtering on performer o-count -var selectPerformerOCountSQL = utils.StrFormat( - "SELECT SUM(o_counter) "+ - "FROM ("+ - "SELECT SUM(o_counter) as o_counter from {performers_images} s "+ - "LEFT JOIN {images} ON {images}.id = s.{images_id} "+ - "WHERE s.{performer_id} = {performers}.id "+ - "UNION ALL "+ - "SELECT COUNT({scenes_o_dates}.{o_date}) as o_counter from {performers_scenes} s "+ - "LEFT JOIN {scenes} ON {scenes}.id = s.{scene_id} "+ - "LEFT JOIN {scenes_o_dates} ON {scenes_o_dates}.{scene_id} = {scenes}.id "+ - "WHERE s.{performer_id} = {performers}.id "+ - ")", - map[string]interface{}{ - "performers_images": performersImagesTable, - "images": imageTable, - "performer_id": performerIDColumn, - "images_id": imageIDColumn, - "performers": performerTable, - "performers_scenes": performersScenesTable, - "scenes": sceneTable, - "scene_id": sceneIDColumn, - "scenes_o_dates": scenesODatesTable, - "o_date": sceneODateColumn, - }, -) - -// used for sorting and filtering play count on performer view count -var selectPerformerPlayCountSQL = utils.StrFormat( - "SELECT COUNT(DISTINCT {view_date}) FROM ("+ - "SELECT {view_date} FROM {performers_scenes} s "+ - "LEFT JOIN {scenes} ON {scenes}.id = s.{scene_id} "+ - "LEFT JOIN {scenes_view_dates} ON {scenes_view_dates}.{scene_id} = {scenes}.id "+ - "WHERE s.{performer_id} = {performers}.id"+ - ")", - map[string]interface{}{ - "performer_id": performerIDColumn, - "performers": performerTable, - "performers_scenes": performersScenesTable, - "scenes": sceneTable, - "scene_id": sceneIDColumn, - "scenes_view_dates": scenesViewDatesTable, - "view_date": sceneViewDateColumn, - }, -) - // used for sorting on performer last o_date var selectPerformerLastOAtSQL = utils.StrFormat( "SELECT MAX(o_date) FROM ("+ @@ -922,6 +669,11 @@ var selectPerformerLastOAtSQL = utils.StrFormat( }, ) +func (qb *PerformerStore) sortByLastOAt(direction string) string { + // need to get the o_dates from scenes + return " ORDER BY (" + selectPerformerLastOAtSQL + ") " + direction +} + // used for sorting on performer last view_date var selectPerformerLastPlayedAtSQL = utils.StrFormat( "SELECT MAX(view_date) FROM ("+ @@ -941,182 +693,6 @@ var selectPerformerLastPlayedAtSQL = utils.StrFormat( }, ) -func performerOCounterCriterionHandler(qb *PerformerStore, count *models.IntCriterionInput) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if count == nil { - return - } - - lhs := "(" + selectPerformerOCountSQL + ")" - clause, args := getIntCriterionWhereClause(lhs, *count) - - f.addWhere(clause, args...) - } -} - -func performerPlayCounterCriterionHandler(qb *PerformerStore, count *models.IntCriterionInput) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if count == nil { - return - } - - lhs := "(" + selectPerformerPlayCountSQL + ")" - clause, args := getIntCriterionWhereClause(lhs, *count) - - f.addWhere(clause, args...) - } -} - -func performerStudiosCriterionHandler(qb *PerformerStore, studios *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if studios != nil { - formatMaps := []utils.StrFormatMap{ - { - "primaryTable": sceneTable, - "joinTable": performersScenesTable, - "primaryFK": sceneIDColumn, - }, - { - "primaryTable": imageTable, - "joinTable": performersImagesTable, - "primaryFK": imageIDColumn, - }, - { - "primaryTable": galleryTable, - "joinTable": performersGalleriesTable, - "primaryFK": galleryIDColumn, - }, - } - - if studios.Modifier == models.CriterionModifierIsNull || studios.Modifier == models.CriterionModifierNotNull { - var notClause string - if studios.Modifier == models.CriterionModifierNotNull { - notClause = "NOT" - } - - var conditions []string - for _, c := range formatMaps { - f.addLeftJoin(c["joinTable"].(string), "", fmt.Sprintf("%s.performer_id = performers.id", c["joinTable"])) - f.addLeftJoin(c["primaryTable"].(string), "", fmt.Sprintf("%s.%s = %s.id", c["joinTable"], c["primaryFK"], c["primaryTable"])) - - conditions = append(conditions, fmt.Sprintf("%s.studio_id IS NULL", c["primaryTable"])) - } - - f.addWhere(fmt.Sprintf("%s (%s)", notClause, strings.Join(conditions, " AND "))) - return - } - - if len(studios.Value) == 0 { - return - } - - var clauseCondition string - - switch studios.Modifier { - case models.CriterionModifierIncludes: - // return performers who appear in scenes/images/galleries with any of the given studios - clauseCondition = "NOT" - case models.CriterionModifierExcludes: - // exclude performers who appear in scenes/images/galleries with any of the given studios - clauseCondition = "" - default: - return - } - - const derivedPerformerStudioTable = "performer_studio" - valuesClause, err := getHierarchicalValues(ctx, qb.tx, studios.Value, studioTable, "", "parent_id", "child_id", studios.Depth) - if err != nil { - f.setError(err) - return - } - f.addWith("studio(root_id, item_id) AS (" + valuesClause + ")") - - templStr := `SELECT performer_id FROM {primaryTable} - INNER JOIN {joinTable} ON {primaryTable}.id = {joinTable}.{primaryFK} - INNER JOIN studio ON {primaryTable}.studio_id = studio.item_id` - - var unions []string - for _, c := range formatMaps { - unions = append(unions, utils.StrFormat(templStr, c)) - } - - f.addWith(fmt.Sprintf("%s AS (%s)", derivedPerformerStudioTable, strings.Join(unions, " UNION "))) - - f.addLeftJoin(derivedPerformerStudioTable, "", fmt.Sprintf("performers.id = %s.performer_id", derivedPerformerStudioTable)) - f.addWhere(fmt.Sprintf("%s.performer_id IS %s NULL", derivedPerformerStudioTable, clauseCondition)) - } - } -} - -func performerAppearsWithCriterionHandler(qb *PerformerStore, performers *models.MultiCriterionInput) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if performers != nil { - formatMaps := []utils.StrFormatMap{ - { - "primaryTable": performersScenesTable, - "joinTable": performersScenesTable, - "primaryFK": sceneIDColumn, - }, - { - "primaryTable": performersImagesTable, - "joinTable": performersImagesTable, - "primaryFK": imageIDColumn, - }, - { - "primaryTable": performersGalleriesTable, - "joinTable": performersGalleriesTable, - "primaryFK": galleryIDColumn, - }, - } - - if len(performers.Value) == '0' { - return - } - - const derivedPerformerPerformersTable = "performer_performers" - - valuesClause := strings.Join(performers.Value, "),(") - - f.addWith("performer(id) AS (VALUES(" + valuesClause + "))") - - templStr := `SELECT {primaryTable}2.performer_id FROM {primaryTable} - INNER JOIN {primaryTable} AS {primaryTable}2 ON {primaryTable}.{primaryFK} = {primaryTable}2.{primaryFK} - INNER JOIN performer ON {primaryTable}.performer_id = performer.id - WHERE {primaryTable}2.performer_id != performer.id` - - if performers.Modifier == models.CriterionModifierIncludesAll && len(performers.Value) > 1 { - templStr += ` - GROUP BY {primaryTable}2.performer_id - HAVING(count(distinct {primaryTable}.performer_id) IS ` + strconv.Itoa(len(performers.Value)) + `)` - } - - var unions []string - for _, c := range formatMaps { - unions = append(unions, utils.StrFormat(templStr, c)) - } - - f.addWith(fmt.Sprintf("%s AS (%s)", derivedPerformerPerformersTable, strings.Join(unions, " UNION "))) - - f.addInnerJoin(derivedPerformerPerformersTable, "", fmt.Sprintf("performers.id = %s.performer_id", derivedPerformerPerformersTable)) - } - } -} - -func (qb *PerformerStore) sortByOCounter(direction string) string { - // need to sum the o_counter from scenes and images - return " ORDER BY (" + selectPerformerOCountSQL + ") " + direction -} - -func (qb *PerformerStore) sortByPlayCount(direction string) string { - // need to sum the o_counter from scenes and images - return " ORDER BY (" + selectPerformerPlayCountSQL + ") " + direction -} - -func (qb *PerformerStore) sortByLastOAt(direction string) string { - // need to get the o_dates from scenes - return " ORDER BY (" + selectPerformerLastOAtSQL + ") " + direction -} - func (qb *PerformerStore) sortByLastPlayedAt(direction string) string { // need to get the view_dates from scenes return " ORDER BY (" + selectPerformerLastPlayedAtSQL + ") " + direction @@ -1185,21 +761,8 @@ func (qb *PerformerStore) getPerformerSort(findFilter *models.FindFilterType) (s return sortQuery, nil } -func (qb *PerformerStore) tagsRepository() *joinRepository { - return &joinRepository{ - repository: repository{ - tx: qb.tx, - tableName: performersTagsTable, - idColumn: performerIDColumn, - }, - fkColumn: tagIDColumn, - foreignTable: tagTable, - orderBy: "tags.name ASC", - } -} - func (qb *PerformerStore) GetTagIDs(ctx context.Context, id int) ([]int, error) { - return qb.tagsRepository().getIDs(ctx, id) + return performerRepository.tags.getIDs(ctx, id) } func (qb *PerformerStore) GetImage(ctx context.Context, performerID int) ([]byte, error) { @@ -1218,16 +781,6 @@ func (qb *PerformerStore) destroyImage(ctx context.Context, performerID int) err return qb.blobJoinQueryBuilder.DestroyImage(ctx, performerID, performerImageBlobColumn) } -func (qb *PerformerStore) stashIDRepository() *stashIDRepository { - return &stashIDRepository{ - repository{ - tx: qb.tx, - tableName: "performer_stash_ids", - idColumn: performerIDColumn, - }, - } -} - func (qb *PerformerStore) GetAliases(ctx context.Context, performerID int) ([]string, error) { return performersAliasesTableMgr.get(ctx, performerID) } diff --git a/pkg/sqlite/performer_filter.go b/pkg/sqlite/performer_filter.go new file mode 100644 index 00000000000..100da424488 --- /dev/null +++ b/pkg/sqlite/performer_filter.go @@ -0,0 +1,516 @@ +package sqlite + +import ( + "context" + "fmt" + "strconv" + "strings" + + "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/utils" +) + +type performerFilterHandler struct { + performerFilter *models.PerformerFilterType +} + +func (qb *performerFilterHandler) validate() error { + filter := qb.performerFilter + if filter == nil { + return nil + } + + if err := validateFilterCombination(filter.OperatorFilter); err != nil { + return err + } + + if subFilter := filter.SubFilter(); subFilter != nil { + sqb := &performerFilterHandler{performerFilter: subFilter} + if err := sqb.validate(); err != nil { + return err + } + } + + // 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 *performerFilterHandler) handle(ctx context.Context, f *filterBuilder) { + filter := qb.performerFilter + if filter == nil { + return + } + + if err := qb.validate(); err != nil { + f.setError(err) + return + } + + sf := filter.SubFilter() + if sf != nil { + sub := &performerFilterHandler{sf} + handleSubFilter(ctx, sub, f, filter.OperatorFilter) + } + + f.handleCriterion(ctx, qb.criterionHandler()) +} + +func (qb *performerFilterHandler) criterionHandler() criterionHandler { + filter := qb.performerFilter + const tableName = performerTable + heightCmCrit := filter.HeightCm + + return compoundHandler{ + stringCriterionHandler(filter.Name, tableName+".name"), + stringCriterionHandler(filter.Disambiguation, tableName+".disambiguation"), + stringCriterionHandler(filter.Details, tableName+".details"), + + boolCriterionHandler(filter.FilterFavorites, tableName+".favorite", nil), + boolCriterionHandler(filter.IgnoreAutoTag, tableName+".ignore_auto_tag", nil), + + yearFilterCriterionHandler(filter.BirthYear, tableName+".birthdate"), + yearFilterCriterionHandler(filter.DeathYear, tableName+".death_date"), + + qb.performerAgeFilterCriterionHandler(filter.Age), + + criterionHandlerFunc(func(ctx context.Context, f *filterBuilder) { + if gender := filter.Gender; gender != nil { + genderCopy := *gender + if genderCopy.Value.IsValid() && len(genderCopy.ValueList) == 0 { + genderCopy.ValueList = []models.GenderEnum{genderCopy.Value} + } + + v := utils.StringerSliceToStringSlice(genderCopy.ValueList) + enumCriterionHandler(genderCopy.Modifier, v, tableName+".gender")(ctx, f) + } + }), + + qb.performerIsMissingCriterionHandler(filter.IsMissing), + stringCriterionHandler(filter.Ethnicity, tableName+".ethnicity"), + stringCriterionHandler(filter.Country, tableName+".country"), + stringCriterionHandler(filter.EyeColor, tableName+".eye_color"), + + // special handler for legacy height filter + criterionHandlerFunc(func(ctx context.Context, f *filterBuilder) { + if heightCmCrit == nil && filter.Height != nil { + heightCm, _ := strconv.Atoi(filter.Height.Value) // already validated + heightCmCrit = &models.IntCriterionInput{ + Value: heightCm, + Modifier: filter.Height.Modifier, + } + } + }), + + intCriterionHandler(heightCmCrit, tableName+".height", nil), + + stringCriterionHandler(filter.Measurements, tableName+".measurements"), + stringCriterionHandler(filter.FakeTits, tableName+".fake_tits"), + floatCriterionHandler(filter.PenisLength, tableName+".penis_length", nil), + + criterionHandlerFunc(func(ctx context.Context, f *filterBuilder) { + if circumcised := filter.Circumcised; circumcised != nil { + v := utils.StringerSliceToStringSlice(circumcised.Value) + enumCriterionHandler(circumcised.Modifier, v, tableName+".circumcised")(ctx, f) + } + }), + + stringCriterionHandler(filter.CareerLength, tableName+".career_length"), + stringCriterionHandler(filter.Tattoos, tableName+".tattoos"), + stringCriterionHandler(filter.Piercings, tableName+".piercings"), + intCriterionHandler(filter.Rating100, tableName+".rating", nil), + stringCriterionHandler(filter.HairColor, tableName+".hair_color"), + stringCriterionHandler(filter.URL, tableName+".url"), + intCriterionHandler(filter.Weight, tableName+".weight", nil), + criterionHandlerFunc(func(ctx context.Context, f *filterBuilder) { + if filter.StashID != nil { + performerRepository.stashIDs.join(f, "performer_stash_ids", "performers.id") + stringCriterionHandler(filter.StashID, "performer_stash_ids.stash_id")(ctx, f) + } + }), + &stashIDCriterionHandler{ + c: filter.StashIDEndpoint, + stashIDRepository: &performerRepository.stashIDs, + stashIDTableAs: "performer_stash_ids", + parentIDCol: "performers.id", + }, + + qb.aliasCriterionHandler(filter.Aliases), + + qb.tagsCriterionHandler(filter.Tags), + + qb.studiosCriterionHandler(filter.Studios), + + qb.appearsWithCriterionHandler(filter.Performers), + + qb.tagCountCriterionHandler(filter.TagCount), + qb.sceneCountCriterionHandler(filter.SceneCount), + qb.imageCountCriterionHandler(filter.ImageCount), + qb.galleryCountCriterionHandler(filter.GalleryCount), + qb.playCounterCriterionHandler(filter.PlayCount), + qb.oCounterCriterionHandler(filter.OCounter), + &dateCriterionHandler{filter.Birthdate, tableName + ".birthdate", nil}, + &dateCriterionHandler{filter.DeathDate, tableName + ".death_date", nil}, + ×tampCriterionHandler{filter.CreatedAt, tableName + ".created_at", nil}, + ×tampCriterionHandler{filter.UpdatedAt, tableName + ".updated_at", nil}, + + &relatedFilterHandler{ + relatedIDCol: "performers_scenes.scene_id", + relatedRepo: sceneRepository.repository, + relatedHandler: &sceneFilterHandler{filter.ScenesFilter}, + joinFn: func(f *filterBuilder) { + performerRepository.scenes.innerJoin(f, "", "performers.id") + }, + }, + + &relatedFilterHandler{ + relatedIDCol: "performers_images.image_id", + relatedRepo: imageRepository.repository, + relatedHandler: &imageFilterHandler{filter.ImagesFilter}, + joinFn: func(f *filterBuilder) { + performerRepository.images.innerJoin(f, "", "performers.id") + }, + }, + + &relatedFilterHandler{ + relatedIDCol: "performers_galleries.gallery_id", + relatedRepo: galleryRepository.repository, + relatedHandler: &galleryFilterHandler{filter.GalleriesFilter}, + joinFn: func(f *filterBuilder) { + performerRepository.galleries.innerJoin(f, "", "performers.id") + }, + }, + + &relatedFilterHandler{ + relatedIDCol: "performer_tag.tag_id", + relatedRepo: tagRepository.repository, + relatedHandler: &tagFilterHandler{filter.TagsFilter}, + joinFn: func(f *filterBuilder) { + performerRepository.tags.innerJoin(f, "performer_tag", "performers.id") + }, + }, + } +} + +// TODO - we need to provide a whitelist of possible values +func (qb *performerFilterHandler) performerIsMissingCriterionHandler(isMissing *string) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if isMissing != nil && *isMissing != "" { + switch *isMissing { + case "scenes": // Deprecated: use `scene_count == 0` filter instead + f.addLeftJoin(performersScenesTable, "scenes_join", "scenes_join.performer_id = performers.id") + f.addWhere("scenes_join.scene_id IS NULL") + case "image": + f.addWhere("performers.image_blob IS NULL") + case "stash_id": + performersStashIDsTableMgr.join(f, "performer_stash_ids", "performers.id") + f.addWhere("performer_stash_ids.performer_id IS NULL") + case "aliases": + performersAliasesTableMgr.join(f, "", "performers.id") + f.addWhere("performer_aliases.alias IS NULL") + default: + f.addWhere("(performers." + *isMissing + " IS NULL OR TRIM(performers." + *isMissing + ") = '')") + } + } + } +} + +func (qb *performerFilterHandler) performerAgeFilterCriterionHandler(age *models.IntCriterionInput) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if age != nil && age.Modifier.IsValid() { + clause, args := getIntCriterionWhereClause( + "cast(IFNULL(strftime('%Y.%m%d', performers.death_date), strftime('%Y.%m%d', 'now')) - strftime('%Y.%m%d', performers.birthdate) as int)", + *age, + ) + f.addWhere(clause, args...) + } + } +} + +func (qb *performerFilterHandler) aliasCriterionHandler(alias *models.StringCriterionInput) criterionHandlerFunc { + h := stringListCriterionHandlerBuilder{ + joinTable: performersAliasesTable, + stringColumn: performerAliasColumn, + addJoinTable: func(f *filterBuilder) { + performersAliasesTableMgr.join(f, "", "performers.id") + }, + } + + return h.handler(alias) +} + +func (qb *performerFilterHandler) tagsCriterionHandler(tags *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { + h := joinedHierarchicalMultiCriterionHandlerBuilder{ + primaryTable: performerTable, + foreignTable: tagTable, + foreignFK: "tag_id", + + relationsTable: "tags_relations", + joinAs: "performer_tag", + joinTable: performersTagsTable, + primaryFK: performerIDColumn, + } + + return h.handler(tags) +} + +func (qb *performerFilterHandler) tagCountCriterionHandler(count *models.IntCriterionInput) criterionHandlerFunc { + h := countCriterionHandlerBuilder{ + primaryTable: performerTable, + joinTable: performersTagsTable, + primaryFK: performerIDColumn, + } + + return h.handler(count) +} + +func (qb *performerFilterHandler) sceneCountCriterionHandler(count *models.IntCriterionInput) criterionHandlerFunc { + h := countCriterionHandlerBuilder{ + primaryTable: performerTable, + joinTable: performersScenesTable, + primaryFK: performerIDColumn, + } + + return h.handler(count) +} + +func (qb *performerFilterHandler) imageCountCriterionHandler(count *models.IntCriterionInput) criterionHandlerFunc { + h := countCriterionHandlerBuilder{ + primaryTable: performerTable, + joinTable: performersImagesTable, + primaryFK: performerIDColumn, + } + + return h.handler(count) +} + +func (qb *performerFilterHandler) galleryCountCriterionHandler(count *models.IntCriterionInput) criterionHandlerFunc { + h := countCriterionHandlerBuilder{ + primaryTable: performerTable, + joinTable: performersGalleriesTable, + primaryFK: performerIDColumn, + } + + return h.handler(count) +} + +// used for sorting and filtering on performer o-count +var selectPerformerOCountSQL = utils.StrFormat( + "SELECT SUM(o_counter) "+ + "FROM ("+ + "SELECT SUM(o_counter) as o_counter from {performers_images} s "+ + "LEFT JOIN {images} ON {images}.id = s.{images_id} "+ + "WHERE s.{performer_id} = {performers}.id "+ + "UNION ALL "+ + "SELECT COUNT({scenes_o_dates}.{o_date}) as o_counter from {performers_scenes} s "+ + "LEFT JOIN {scenes} ON {scenes}.id = s.{scene_id} "+ + "LEFT JOIN {scenes_o_dates} ON {scenes_o_dates}.{scene_id} = {scenes}.id "+ + "WHERE s.{performer_id} = {performers}.id "+ + ")", + map[string]interface{}{ + "performers_images": performersImagesTable, + "images": imageTable, + "performer_id": performerIDColumn, + "images_id": imageIDColumn, + "performers": performerTable, + "performers_scenes": performersScenesTable, + "scenes": sceneTable, + "scene_id": sceneIDColumn, + "scenes_o_dates": scenesODatesTable, + "o_date": sceneODateColumn, + }, +) + +// used for sorting and filtering play count on performer view count +var selectPerformerPlayCountSQL = utils.StrFormat( + "SELECT COUNT(DISTINCT {view_date}) FROM ("+ + "SELECT {view_date} FROM {performers_scenes} s "+ + "LEFT JOIN {scenes} ON {scenes}.id = s.{scene_id} "+ + "LEFT JOIN {scenes_view_dates} ON {scenes_view_dates}.{scene_id} = {scenes}.id "+ + "WHERE s.{performer_id} = {performers}.id"+ + ")", + map[string]interface{}{ + "performer_id": performerIDColumn, + "performers": performerTable, + "performers_scenes": performersScenesTable, + "scenes": sceneTable, + "scene_id": sceneIDColumn, + "scenes_view_dates": scenesViewDatesTable, + "view_date": sceneViewDateColumn, + }, +) + +func (qb *performerFilterHandler) oCounterCriterionHandler(count *models.IntCriterionInput) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if count == nil { + return + } + + lhs := "(" + selectPerformerOCountSQL + ")" + clause, args := getIntCriterionWhereClause(lhs, *count) + + f.addWhere(clause, args...) + } +} + +func (qb *performerFilterHandler) playCounterCriterionHandler(count *models.IntCriterionInput) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if count == nil { + return + } + + lhs := "(" + selectPerformerPlayCountSQL + ")" + clause, args := getIntCriterionWhereClause(lhs, *count) + + f.addWhere(clause, args...) + } +} + +func (qb *performerFilterHandler) studiosCriterionHandler(studios *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if studios != nil { + formatMaps := []utils.StrFormatMap{ + { + "primaryTable": sceneTable, + "joinTable": performersScenesTable, + "primaryFK": sceneIDColumn, + }, + { + "primaryTable": imageTable, + "joinTable": performersImagesTable, + "primaryFK": imageIDColumn, + }, + { + "primaryTable": galleryTable, + "joinTable": performersGalleriesTable, + "primaryFK": galleryIDColumn, + }, + } + + if studios.Modifier == models.CriterionModifierIsNull || studios.Modifier == models.CriterionModifierNotNull { + var notClause string + if studios.Modifier == models.CriterionModifierNotNull { + notClause = "NOT" + } + + var conditions []string + for _, c := range formatMaps { + f.addLeftJoin(c["joinTable"].(string), "", fmt.Sprintf("%s.performer_id = performers.id", c["joinTable"])) + f.addLeftJoin(c["primaryTable"].(string), "", fmt.Sprintf("%s.%s = %s.id", c["joinTable"], c["primaryFK"], c["primaryTable"])) + + conditions = append(conditions, fmt.Sprintf("%s.studio_id IS NULL", c["primaryTable"])) + } + + f.addWhere(fmt.Sprintf("%s (%s)", notClause, strings.Join(conditions, " AND "))) + return + } + + if len(studios.Value) == 0 { + return + } + + var clauseCondition string + + switch studios.Modifier { + case models.CriterionModifierIncludes: + // return performers who appear in scenes/images/galleries with any of the given studios + clauseCondition = "NOT" + case models.CriterionModifierExcludes: + // exclude performers who appear in scenes/images/galleries with any of the given studios + clauseCondition = "" + default: + return + } + + const derivedPerformerStudioTable = "performer_studio" + valuesClause, err := getHierarchicalValues(ctx, studios.Value, studioTable, "", "parent_id", "child_id", studios.Depth) + if err != nil { + f.setError(err) + return + } + f.addWith("studio(root_id, item_id) AS (" + valuesClause + ")") + + templStr := `SELECT performer_id FROM {primaryTable} + INNER JOIN {joinTable} ON {primaryTable}.id = {joinTable}.{primaryFK} + INNER JOIN studio ON {primaryTable}.studio_id = studio.item_id` + + var unions []string + for _, c := range formatMaps { + unions = append(unions, utils.StrFormat(templStr, c)) + } + + f.addWith(fmt.Sprintf("%s AS (%s)", derivedPerformerStudioTable, strings.Join(unions, " UNION "))) + + f.addLeftJoin(derivedPerformerStudioTable, "", fmt.Sprintf("performers.id = %s.performer_id", derivedPerformerStudioTable)) + f.addWhere(fmt.Sprintf("%s.performer_id IS %s NULL", derivedPerformerStudioTable, clauseCondition)) + } + } +} + +func (qb *performerFilterHandler) appearsWithCriterionHandler(performers *models.MultiCriterionInput) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if performers != nil { + formatMaps := []utils.StrFormatMap{ + { + "primaryTable": performersScenesTable, + "joinTable": performersScenesTable, + "primaryFK": sceneIDColumn, + }, + { + "primaryTable": performersImagesTable, + "joinTable": performersImagesTable, + "primaryFK": imageIDColumn, + }, + { + "primaryTable": performersGalleriesTable, + "joinTable": performersGalleriesTable, + "primaryFK": galleryIDColumn, + }, + } + + if len(performers.Value) == '0' { + return + } + + const derivedPerformerPerformersTable = "performer_performers" + + valuesClause := strings.Join(performers.Value, "),(") + + f.addWith("performer(id) AS (VALUES(" + valuesClause + "))") + + templStr := `SELECT {primaryTable}2.performer_id FROM {primaryTable} + INNER JOIN {primaryTable} AS {primaryTable}2 ON {primaryTable}.{primaryFK} = {primaryTable}2.{primaryFK} + INNER JOIN performer ON {primaryTable}.performer_id = performer.id + WHERE {primaryTable}2.performer_id != performer.id` + + if performers.Modifier == models.CriterionModifierIncludesAll && len(performers.Value) > 1 { + templStr += ` + GROUP BY {primaryTable}2.performer_id + HAVING(count(distinct {primaryTable}.performer_id) IS ` + strconv.Itoa(len(performers.Value)) + `)` + } + + var unions []string + for _, c := range formatMaps { + unions = append(unions, utils.StrFormat(templStr, c)) + } + + f.addWith(fmt.Sprintf("%s AS (%s)", derivedPerformerPerformersTable, strings.Join(unions, " UNION "))) + + f.addInnerJoin(derivedPerformerPerformersTable, "", fmt.Sprintf("performers.id = %s.performer_id", derivedPerformerPerformersTable)) + } + } +} diff --git a/pkg/sqlite/performer_test.go b/pkg/sqlite/performer_test.go index 8ba32964b82..d333913d282 100644 --- a/pkg/sqlite/performer_test.go +++ b/pkg/sqlite/performer_test.go @@ -731,10 +731,12 @@ func TestPerformerQueryEthnicityOr(t *testing.T) { Value: performer1Eth, Modifier: models.CriterionModifierEquals, }, - Or: &models.PerformerFilterType{ - Ethnicity: &models.StringCriterionInput{ - Value: performer2Eth, - Modifier: models.CriterionModifierEquals, + OperatorFilter: models.OperatorFilter[models.PerformerFilterType]{ + Or: &models.PerformerFilterType{ + Ethnicity: &models.StringCriterionInput{ + Value: performer2Eth, + Modifier: models.CriterionModifierEquals, + }, }, }, } @@ -760,10 +762,12 @@ func TestPerformerQueryEthnicityAndRating(t *testing.T) { Value: performerEth, Modifier: models.CriterionModifierEquals, }, - And: &models.PerformerFilterType{ - Rating100: &models.IntCriterionInput{ - Value: performerRating, - Modifier: models.CriterionModifierEquals, + OperatorFilter: models.OperatorFilter[models.PerformerFilterType]{ + And: &models.PerformerFilterType{ + Rating100: &models.IntCriterionInput{ + Value: performerRating, + Modifier: models.CriterionModifierEquals, + }, }, }, } @@ -801,8 +805,10 @@ func TestPerformerQueryEthnicityNotRating(t *testing.T) { performerFilter := models.PerformerFilterType{ Ethnicity: ðCriterion, - Not: &models.PerformerFilterType{ - Rating100: &ratingCriterion, + OperatorFilter: models.OperatorFilter[models.PerformerFilterType]{ + Not: &models.PerformerFilterType{ + Rating100: &ratingCriterion, + }, }, } @@ -838,24 +844,30 @@ func TestPerformerIllegalQuery(t *testing.T) { // And and Or in the same filter "AndOr", models.PerformerFilterType{ - And: &subFilter, - Or: &subFilter, + OperatorFilter: models.OperatorFilter[models.PerformerFilterType]{ + And: &subFilter, + Or: &subFilter, + }, }, }, { // And and Not in the same filter "AndNot", models.PerformerFilterType{ - And: &subFilter, - Not: &subFilter, + OperatorFilter: models.OperatorFilter[models.PerformerFilterType]{ + And: &subFilter, + Not: &subFilter, + }, }, }, { // Or and Not in the same filter "OrNot", models.PerformerFilterType{ - Or: &subFilter, - Not: &subFilter, + OperatorFilter: models.OperatorFilter[models.PerformerFilterType]{ + Or: &subFilter, + Not: &subFilter, + }, }, }, { diff --git a/pkg/sqlite/repository.go b/pkg/sqlite/repository.go index fe0961ff590..8eb87b9aff1 100644 --- a/pkg/sqlite/repository.go +++ b/pkg/sqlite/repository.go @@ -20,7 +20,6 @@ type objectList interface { } type repository struct { - tx dbWrapper tableName string idColumn string } @@ -48,7 +47,7 @@ func (r *repository) destroyExisting(ctx context.Context, ids []int) error { func (r *repository) destroy(ctx context.Context, ids []int) error { for _, id := range ids { stmt := fmt.Sprintf("DELETE FROM %s WHERE %s = ?", r.tableName, r.idColumn) - if _, err := r.tx.Exec(ctx, stmt, id); err != nil { + if _, err := dbWrapper.Exec(ctx, stmt, id); err != nil { return err } } @@ -78,7 +77,7 @@ func (r *repository) runCountQuery(ctx context.Context, query string, args []int }{0} // Perform query and fetch result - if err := r.tx.Get(ctx, &result, query, args...); err != nil && !errors.Is(err, sql.ErrNoRows) { + if err := dbWrapper.Get(ctx, &result, query, args...); err != nil && !errors.Is(err, sql.ErrNoRows) { return 0, err } @@ -90,7 +89,7 @@ func (r *repository) runIdsQuery(ctx context.Context, query string, args []inter Int int `db:"id"` } - if err := r.tx.Select(ctx, &result, query, args...); err != nil && !errors.Is(err, sql.ErrNoRows) { + if err := dbWrapper.Select(ctx, &result, query, args...); err != nil && !errors.Is(err, sql.ErrNoRows) { return []int{}, fmt.Errorf("running query: %s [%v]: %w", query, args, err) } @@ -102,7 +101,7 @@ func (r *repository) runIdsQuery(ctx context.Context, query string, args []inter } func (r *repository) queryFunc(ctx context.Context, query string, args []interface{}, single bool, f func(rows *sqlx.Rows) error) error { - rows, err := r.tx.Queryx(ctx, query, args...) + rows, err := dbWrapper.Queryx(ctx, query, args...) if err != nil && !errors.Is(err, sql.ErrNoRows) { return err @@ -150,7 +149,7 @@ func (r *repository) queryStruct(ctx context.Context, query string, args []inter } func (r *repository) querySimple(ctx context.Context, query string, args []interface{}, out interface{}) error { - rows, err := r.tx.Queryx(ctx, query, args...) + rows, err := dbWrapper.Queryx(ctx, query, args...) if err != nil && !errors.Is(err, sql.ErrNoRows) { return err @@ -230,7 +229,6 @@ func (r *repository) join(j joiner, as string, parentIDCol string) { j.addLeftJoin(r.tableName, as, fmt.Sprintf("%s.%s = %s", t, r.idColumn, parentIDCol)) } -//nolint:golint,unused func (r *repository) innerJoin(j joiner, as string, parentIDCol string) { t := r.tableName if as != "" { @@ -269,7 +267,7 @@ func (r *joinRepository) getIDs(ctx context.Context, id int) ([]int, error) { } func (r *joinRepository) insert(ctx context.Context, id int, foreignIDs ...int) error { - stmt, err := r.tx.Prepare(ctx, fmt.Sprintf("INSERT INTO %s (%s, %s) VALUES (?, ?)", r.tableName, r.idColumn, r.fkColumn)) + stmt, err := dbWrapper.Prepare(ctx, fmt.Sprintf("INSERT INTO %s (%s, %s) VALUES (?, ?)", r.tableName, r.idColumn, r.fkColumn)) if err != nil { return err } @@ -277,7 +275,7 @@ func (r *joinRepository) insert(ctx context.Context, id int, foreignIDs ...int) defer stmt.Close() for _, fk := range foreignIDs { - if _, err := r.tx.ExecStmt(ctx, stmt, id, fk); err != nil { + if _, err := dbWrapper.ExecStmt(ctx, stmt, id, fk); err != nil { return err } } @@ -286,7 +284,7 @@ func (r *joinRepository) insert(ctx context.Context, id int, foreignIDs ...int) // insertOrIgnore inserts a join into the table, silently failing in the event that a conflict occurs (ie when the join already exists) func (r *joinRepository) insertOrIgnore(ctx context.Context, id int, foreignIDs ...int) error { - stmt, err := r.tx.Prepare(ctx, fmt.Sprintf("INSERT INTO %s (%s, %s) VALUES (?, ?) ON CONFLICT (%[2]s, %s) DO NOTHING", r.tableName, r.idColumn, r.fkColumn)) + stmt, err := dbWrapper.Prepare(ctx, fmt.Sprintf("INSERT INTO %s (%s, %s) VALUES (?, ?) ON CONFLICT (%[2]s, %s) DO NOTHING", r.tableName, r.idColumn, r.fkColumn)) if err != nil { return err } @@ -294,7 +292,7 @@ func (r *joinRepository) insertOrIgnore(ctx context.Context, id int, foreignIDs defer stmt.Close() for _, fk := range foreignIDs { - if _, err := r.tx.ExecStmt(ctx, stmt, id, fk); err != nil { + if _, err := dbWrapper.ExecStmt(ctx, stmt, id, fk); err != nil { return err } } @@ -310,7 +308,7 @@ func (r *joinRepository) destroyJoins(ctx context.Context, id int, foreignIDs .. args[i+1] = v } - if _, err := r.tx.Exec(ctx, stmt, args...); err != nil { + if _, err := dbWrapper.Exec(ctx, stmt, args...); err != nil { return err } @@ -360,7 +358,7 @@ func (r *captionRepository) get(ctx context.Context, id models.FileID) ([]*model func (r *captionRepository) insert(ctx context.Context, id models.FileID, caption *models.VideoCaption) (sql.Result, error) { stmt := fmt.Sprintf("INSERT INTO %s (%s, %s, %s, %s) VALUES (?, ?, ?, ?)", r.tableName, r.idColumn, captionCodeColumn, captionFilenameColumn, captionTypeColumn) - return r.tx.Exec(ctx, stmt, id, caption.LanguageCode, caption.Filename, caption.CaptionType) + return dbWrapper.Exec(ctx, stmt, id, caption.LanguageCode, caption.Filename, caption.CaptionType) } func (r *captionRepository) replace(ctx context.Context, id models.FileID, captions []*models.VideoCaption) error { @@ -399,7 +397,7 @@ func (r *stringRepository) get(ctx context.Context, id int) ([]string, error) { func (r *stringRepository) insert(ctx context.Context, id int, s string) (sql.Result, error) { stmt := fmt.Sprintf("INSERT INTO %s (%s, %s) VALUES (?, ?)", r.tableName, r.idColumn, r.stringColumn) - return r.tx.Exec(ctx, stmt, id, s) + return dbWrapper.Exec(ctx, stmt, id, s) } func (r *stringRepository) replace(ctx context.Context, id int, newStrings []string) error { diff --git a/pkg/sqlite/scene.go b/pkg/sqlite/scene.go index 8c35d162c19..a6b73ac2eb4 100644 --- a/pkg/sqlite/scene.go +++ b/pkg/sqlite/scene.go @@ -168,23 +168,78 @@ func (r *sceneRowRecord) fromPartial(o models.ScenePartial) { r.setFloat64("play_duration", o.PlayDuration) } -type SceneStore struct { +type sceneRepositoryType struct { repository + galleries joinRepository + tags joinRepository + performers joinRepository + movies repository + + files filesRepository + + stashIDs stashIDRepository +} + +var ( + sceneRepository = sceneRepositoryType{ + repository: repository{ + tableName: sceneTable, + idColumn: idColumn, + }, + galleries: joinRepository{ + repository: repository{ + tableName: scenesGalleriesTable, + idColumn: sceneIDColumn, + }, + fkColumn: galleryIDColumn, + }, + tags: joinRepository{ + repository: repository{ + tableName: scenesTagsTable, + idColumn: sceneIDColumn, + }, + fkColumn: tagIDColumn, + foreignTable: tagTable, + orderBy: "tags.name ASC", + }, + performers: joinRepository{ + repository: repository{ + tableName: performersScenesTable, + idColumn: sceneIDColumn, + }, + fkColumn: performerIDColumn, + }, + movies: repository{ + tableName: moviesScenesTable, + idColumn: sceneIDColumn, + }, + files: filesRepository{ + repository: repository{ + tableName: scenesFilesTable, + idColumn: sceneIDColumn, + }, + }, + stashIDs: stashIDRepository{ + repository{ + tableName: "scene_stash_ids", + idColumn: sceneIDColumn, + }, + }, + } +) + +type SceneStore struct { blobJoinQueryBuilder tableMgr *table oDateManager viewDateManager - fileStore *FileStore + repo *storeRepository } -func NewSceneStore(fileStore *FileStore, blobStore *BlobStore) *SceneStore { +func NewSceneStore(r *storeRepository, blobStore *BlobStore) *SceneStore { return &SceneStore{ - repository: repository{ - tableName: sceneTable, - idColumn: idColumn, - }, blobJoinQueryBuilder: blobJoinQueryBuilder{ blobStore: blobStore, joinTable: sceneTable, @@ -193,7 +248,7 @@ func NewSceneStore(fileStore *FileStore, blobStore *BlobStore) *SceneStore { tableMgr: sceneTableMgr, viewDateManager: viewDateManager{scenesViewTableMgr}, oDateManager: oDateManager{scenesOTableMgr}, - fileStore: fileStore, + repo: r, } } @@ -531,13 +586,13 @@ func (qb *SceneStore) getMany(ctx context.Context, q *goqu.SelectDataset) ([]*mo } func (qb *SceneStore) GetFiles(ctx context.Context, id int) ([]*models.VideoFile, error) { - fileIDs, err := qb.filesRepository().get(ctx, id) + fileIDs, err := sceneRepository.files.get(ctx, id) if err != nil { return nil, err } // use fileStore to load files - files, err := qb.fileStore.Find(ctx, fileIDs...) + files, err := qb.repo.File.Find(ctx, fileIDs...) if err != nil { return nil, err } @@ -556,7 +611,7 @@ func (qb *SceneStore) GetFiles(ctx context.Context, id int) ([]*models.VideoFile func (qb *SceneStore) GetManyFileIDs(ctx context.Context, ids []int) ([][]models.FileID, error) { const primaryOnly = false - return qb.filesRepository().getMany(ctx, ids, primaryOnly) + return sceneRepository.files.getMany(ctx, ids, primaryOnly) } func (qb *SceneStore) FindByFileID(ctx context.Context, fileID models.FileID) ([]*models.Scene, error) { @@ -864,176 +919,6 @@ func (qb *SceneStore) All(ctx context.Context) ([]*models.Scene, error) { )) } -func illegalFilterCombination(type1, type2 string) error { - return fmt.Errorf("cannot have %s and %s in the same filter", type1, type2) -} - -func (qb *SceneStore) validateFilter(sceneFilter *models.SceneFilterType) error { - const and = "AND" - const or = "OR" - const not = "NOT" - - if sceneFilter.And != nil { - if sceneFilter.Or != nil { - return illegalFilterCombination(and, or) - } - if sceneFilter.Not != nil { - return illegalFilterCombination(and, not) - } - - return qb.validateFilter(sceneFilter.And) - } - - if sceneFilter.Or != nil { - if sceneFilter.Not != nil { - return illegalFilterCombination(or, not) - } - - return qb.validateFilter(sceneFilter.Or) - } - - if sceneFilter.Not != nil { - return qb.validateFilter(sceneFilter.Not) - } - - return nil -} - -func (qb *SceneStore) makeFilter(ctx context.Context, sceneFilter *models.SceneFilterType) *filterBuilder { - query := &filterBuilder{} - - if sceneFilter.And != nil { - query.and(qb.makeFilter(ctx, sceneFilter.And)) - } - if sceneFilter.Or != nil { - query.or(qb.makeFilter(ctx, sceneFilter.Or)) - } - if sceneFilter.Not != nil { - 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) - f.addLeftJoin(fingerprintTable, "fingerprints_oshash", "scenes_files.file_id = fingerprints_oshash.file_id AND fingerprints_oshash.type = 'oshash'") - } - - stringCriterionHandler(sceneFilter.Oshash, "fingerprints_oshash.fingerprint")(ctx, f) - })) - - query.handleCriterion(ctx, criterionHandlerFunc(func(ctx context.Context, f *filterBuilder) { - if sceneFilter.Checksum != nil { - qb.addSceneFilesTable(f) - f.addLeftJoin(fingerprintTable, "fingerprints_md5", "scenes_files.file_id = fingerprints_md5.file_id AND fingerprints_md5.type = 'md5'") - } - - stringCriterionHandler(sceneFilter.Checksum, "fingerprints_md5.fingerprint")(ctx, f) - })) - - query.handleCriterion(ctx, criterionHandlerFunc(func(ctx context.Context, f *filterBuilder) { - if sceneFilter.Phash != nil { - // backwards compatibility - scenePhashDistanceCriterionHandler(qb, &models.PhashDistanceCriterionInput{ - Value: sceneFilter.Phash.Value, - Modifier: sceneFilter.Phash.Modifier, - })(ctx, f) - } - })) - - query.handleCriterion(ctx, scenePhashDistanceCriterionHandler(qb, sceneFilter.PhashDistance)) - - query.handleCriterion(ctx, intCriterionHandler(sceneFilter.Rating100, "scenes.rating", nil)) - query.handleCriterion(ctx, sceneOCountCriterionHandler(sceneFilter.OCounter)) - query.handleCriterion(ctx, boolCriterionHandler(sceneFilter.Organized, "scenes.organized", nil)) - - 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, orientationCriterionHandler(sceneFilter.Orientation, "video_files.height", "video_files.width", qb.addVideoFilesTable)) - query.handleCriterion(ctx, floatIntCriterionHandler(sceneFilter.Framerate, "ROUND(video_files.frame_rate)", qb.addVideoFilesTable)) - query.handleCriterion(ctx, intCriterionHandler(sceneFilter.Bitrate, "video_files.bit_rate", qb.addVideoFilesTable)) - query.handleCriterion(ctx, codecCriterionHandler(sceneFilter.VideoCodec, "video_files.video_codec", qb.addVideoFilesTable)) - query.handleCriterion(ctx, codecCriterionHandler(sceneFilter.AudioCodec, "video_files.audio_codec", qb.addVideoFilesTable)) - - query.handleCriterion(ctx, hasMarkersCriterionHandler(sceneFilter.HasMarkers)) - query.handleCriterion(ctx, sceneIsMissingCriterionHandler(qb, sceneFilter.IsMissing)) - query.handleCriterion(ctx, sceneURLsCriterionHandler(sceneFilter.URL)) - - query.handleCriterion(ctx, criterionHandlerFunc(func(ctx context.Context, f *filterBuilder) { - if sceneFilter.StashID != nil { - qb.stashIDRepository().join(f, "scene_stash_ids", "scenes.id") - 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, scenePlayCountCriterionHandler(sceneFilter.PlayCount)) - query.handleCriterion(ctx, criterionHandlerFunc(func(ctx context.Context, f *filterBuilder) { - if sceneFilter.LastPlayedAt != nil { - f.addLeftJoin( - fmt.Sprintf("(SELECT %s, MAX(%s) as last_played_at FROM %s GROUP BY %s)", sceneIDColumn, sceneViewDateColumn, scenesViewDatesTable, sceneIDColumn), - "scene_last_view", - fmt.Sprintf("scene_last_view.%s = scenes.id", sceneIDColumn), - ) - timestampCriterionHandler(sceneFilter.LastPlayedAt, "IFNULL(last_played_at, datetime(0))")(ctx, f) - } - })) - - query.handleCriterion(ctx, sceneTagsCriterionHandler(qb, sceneFilter.Tags)) - query.handleCriterion(ctx, sceneTagCountCriterionHandler(qb, sceneFilter.TagCount)) - query.handleCriterion(ctx, scenePerformersCriterionHandler(qb, sceneFilter.Performers)) - query.handleCriterion(ctx, scenePerformerCountCriterionHandler(qb, sceneFilter.PerformerCount)) - query.handleCriterion(ctx, studioCriterionHandler(sceneTable, sceneFilter.Studios)) - query.handleCriterion(ctx, sceneMoviesCriterionHandler(qb, sceneFilter.Movies)) - query.handleCriterion(ctx, sceneGalleriesCriterionHandler(qb, sceneFilter.Galleries)) - query.handleCriterion(ctx, scenePerformerTagsCriterionHandler(qb, sceneFilter.PerformerTags)) - 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 -} - -func (qb *SceneStore) addSceneFilesTable(f *filterBuilder) { - f.addLeftJoin(scenesFilesTable, "", "scenes_files.scene_id = scenes.id") -} - -func (qb *SceneStore) addFilesTable(f *filterBuilder) { - qb.addSceneFilesTable(f) - f.addLeftJoin(fileTable, "", "scenes_files.file_id = files.id") -} - -func (qb *SceneStore) addFoldersTable(f *filterBuilder) { - qb.addFilesTable(f) - f.addLeftJoin(folderTable, "", "files.parent_folder_id = folders.id") -} - -func (qb *SceneStore) addVideoFilesTable(f *filterBuilder) { - qb.addSceneFilesTable(f) - f.addLeftJoin(videoFileTable, "", "video_files.file_id = scenes_files.file_id") -} - func (qb *SceneStore) makeQuery(ctx context.Context, sceneFilter *models.SceneFilterType, findFilter *models.FindFilterType) (*queryBuilder, error) { if sceneFilter == nil { sceneFilter = &models.SceneFilterType{} @@ -1042,7 +927,7 @@ func (qb *SceneStore) makeQuery(ctx context.Context, sceneFilter *models.SceneFi findFilter = &models.FindFilterType{} } - query := qb.newQuery() + query := sceneRepository.newQuery() distinctIDs(&query, sceneTable) if q := findFilter.Q; q != nil && *q != "" { @@ -1074,10 +959,9 @@ func (qb *SceneStore) makeQuery(ctx context.Context, sceneFilter *models.SceneFi query.parseQueryString(searchColumns, *q) } - if err := qb.validateFilter(sceneFilter); err != nil { - return nil, err - } - filter := qb.makeFilter(ctx, sceneFilter) + filter := filterBuilderFromHandler(ctx, &sceneFilterHandler{ + sceneFilter: sceneFilter, + }) if err := query.addFilter(filter); err != nil { return nil, err @@ -1117,7 +1001,7 @@ func (qb *SceneStore) queryGroupedFields(ctx context.Context, options models.Sce return models.NewSceneQueryResult(qb), nil } - aggregateQuery := qb.newQuery() + aggregateQuery := sceneRepository.newQuery() if options.Count { aggregateQuery.addColumn("COUNT(DISTINCT temp.id) as total") @@ -1161,7 +1045,7 @@ func (qb *SceneStore) queryGroupedFields(ctx context.Context, options models.Sce Duration null.Float Size null.Float }{} - if err := qb.repository.queryStruct(ctx, aggregateQuery.toSQL(includeSortPagination), query.args, &out); err != nil { + if err := sceneRepository.queryStruct(ctx, aggregateQuery.toSQL(includeSortPagination), query.args, &out); err != nil { return nil, err } @@ -1181,349 +1065,6 @@ func (qb *SceneStore) QueryCount(ctx context.Context, sceneFilter *models.SceneF return query.executeCount(ctx) } -func scenePlayCountCriterionHandler(count *models.IntCriterionInput) criterionHandlerFunc { - h := countCriterionHandlerBuilder{ - primaryTable: sceneTable, - joinTable: scenesViewDatesTable, - primaryFK: sceneIDColumn, - } - - return h.handler(count) -} - -func sceneOCountCriterionHandler(count *models.IntCriterionInput) criterionHandlerFunc { - h := countCriterionHandlerBuilder{ - primaryTable: sceneTable, - joinTable: scenesODatesTable, - primaryFK: sceneIDColumn, - } - - return h.handler(count) -} - -func sceneFileCountCriterionHandler(qb *SceneStore, fileCount *models.IntCriterionInput) criterionHandlerFunc { - h := countCriterionHandlerBuilder{ - primaryTable: sceneTable, - joinTable: scenesFilesTable, - primaryFK: sceneIDColumn, - } - - return h.handler(fileCount) -} - -func scenePhashDuplicatedCriterionHandler(duplicatedFilter *models.PHashDuplicationCriterionInput, addJoinFn func(f *filterBuilder)) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - // TODO: Wishlist item: Implement Distance matching - if duplicatedFilter != nil { - if addJoinFn != nil { - addJoinFn(f) - } - - var v string - if *duplicatedFilter.Duplicated { - v = ">" - } else { - v = "=" - } - - f.addInnerJoin("(SELECT file_id FROM files_fingerprints INNER JOIN (SELECT fingerprint FROM files_fingerprints WHERE type = 'phash' GROUP BY fingerprint HAVING COUNT (fingerprint) "+v+" 1) dupes on files_fingerprints.fingerprint = dupes.fingerprint)", "scph", "scenes_files.file_id = scph.file_id") - } - } -} - -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 { - addJoinFn(f) - } - clause, args := getIntCriterionWhereClause("cast("+column+" as int)", *durationFilter) - f.addWhere(clause, args...) - } - } -} - -func resolutionCriterionHandler(resolution *models.ResolutionCriterionInput, heightColumn string, widthColumn string, addJoinFn func(f *filterBuilder)) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if resolution != nil && resolution.Value.IsValid() { - if addJoinFn != nil { - addJoinFn(f) - } - - min := resolution.Value.GetMinResolution() - max := resolution.Value.GetMaxResolution() - - widthHeight := fmt.Sprintf("MIN(%s, %s)", widthColumn, heightColumn) - - switch resolution.Modifier { - case models.CriterionModifierEquals: - f.addWhere(fmt.Sprintf("%s BETWEEN %d AND %d", widthHeight, min, max)) - case models.CriterionModifierNotEquals: - f.addWhere(fmt.Sprintf("%s NOT BETWEEN %d AND %d", widthHeight, min, max)) - case models.CriterionModifierLessThan: - f.addWhere(fmt.Sprintf("%s < %d", widthHeight, min)) - case models.CriterionModifierGreaterThan: - f.addWhere(fmt.Sprintf("%s > %d", widthHeight, max)) - } - } - } -} - -func codecCriterionHandler(codec *models.StringCriterionInput, codecColumn string, addJoinFn func(f *filterBuilder)) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if codec != nil { - if addJoinFn != nil { - addJoinFn(f) - } - - stringCriterionHandler(codec, codecColumn)(ctx, f) - } - } -} - -func hasMarkersCriterionHandler(hasMarkers *string) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if hasMarkers != nil { - f.addLeftJoin("scene_markers", "", "scene_markers.scene_id = scenes.id") - if *hasMarkers == "true" { - f.addHaving("count(scene_markers.scene_id) > 0") - } else { - f.addWhere("scene_markers.id IS NULL") - } - } - } -} - -func sceneIsMissingCriterionHandler(qb *SceneStore, isMissing *string) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if isMissing != nil && *isMissing != "" { - switch *isMissing { - case "url": - scenesURLsTableMgr.join(f, "", "scenes.id") - f.addWhere("scene_urls.url IS NULL") - case "galleries": - qb.galleriesRepository().join(f, "galleries_join", "scenes.id") - f.addWhere("galleries_join.scene_id IS NULL") - case "studio": - f.addWhere("scenes.studio_id IS NULL") - case "movie": - qb.moviesRepository().join(f, "movies_join", "scenes.id") - f.addWhere("movies_join.scene_id IS NULL") - case "performers": - qb.performersRepository().join(f, "performers_join", "scenes.id") - f.addWhere("performers_join.scene_id IS NULL") - case "date": - f.addWhere(`scenes.date IS NULL OR scenes.date IS ""`) - case "tags": - qb.tagsRepository().join(f, "tags_join", "scenes.id") - f.addWhere("tags_join.scene_id IS NULL") - case "stash_id": - qb.stashIDRepository().join(f, "scene_stash_ids", "scenes.id") - f.addWhere("scene_stash_ids.scene_id IS NULL") - case "phash": - qb.addSceneFilesTable(f) - f.addLeftJoin(fingerprintTable, "fingerprints_phash", "scenes_files.file_id = fingerprints_phash.file_id AND fingerprints_phash.type = 'phash'") - f.addWhere("fingerprints_phash.fingerprint IS NULL") - case "cover": - f.addWhere("scenes.cover_blob IS NULL") - default: - f.addWhere("(scenes." + *isMissing + " IS NULL OR TRIM(scenes." + *isMissing + ") = '')") - } - } - } -} - -func sceneURLsCriterionHandler(url *models.StringCriterionInput) criterionHandlerFunc { - h := stringListCriterionHandlerBuilder{ - joinTable: scenesURLsTable, - stringColumn: sceneURLColumn, - addJoinTable: func(f *filterBuilder) { - scenesURLsTableMgr.join(f, "", "scenes.id") - }, - } - - return h.handler(url) -} - -func (qb *SceneStore) getMultiCriterionHandlerBuilder(foreignTable, joinTable, foreignFK string, addJoinsFunc func(f *filterBuilder)) multiCriterionHandlerBuilder { - return multiCriterionHandlerBuilder{ - primaryTable: sceneTable, - foreignTable: foreignTable, - joinTable: joinTable, - primaryFK: sceneIDColumn, - foreignFK: foreignFK, - addJoinsFunc: addJoinsFunc, - } -} - -func sceneCaptionCriterionHandler(qb *SceneStore, captions *models.StringCriterionInput) criterionHandlerFunc { - h := stringListCriterionHandlerBuilder{ - joinTable: videoCaptionsTable, - stringColumn: captionCodeColumn, - addJoinTable: func(f *filterBuilder) { - qb.addSceneFilesTable(f) - f.addLeftJoin(videoCaptionsTable, "", "video_captions.file_id = scenes_files.file_id") - }, - } - - return h.handler(captions) -} - -func sceneTagsCriterionHandler(qb *SceneStore, tags *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { - h := joinedHierarchicalMultiCriterionHandlerBuilder{ - tx: qb.tx, - - primaryTable: sceneTable, - foreignTable: tagTable, - foreignFK: "tag_id", - - relationsTable: "tags_relations", - joinAs: "scene_tag", - joinTable: scenesTagsTable, - primaryFK: sceneIDColumn, - } - - return h.handler(tags) -} - -func sceneTagCountCriterionHandler(qb *SceneStore, tagCount *models.IntCriterionInput) criterionHandlerFunc { - h := countCriterionHandlerBuilder{ - primaryTable: sceneTable, - joinTable: scenesTagsTable, - primaryFK: sceneIDColumn, - } - - return h.handler(tagCount) -} - -func scenePerformersCriterionHandler(qb *SceneStore, performers *models.MultiCriterionInput) criterionHandlerFunc { - h := joinedMultiCriterionHandlerBuilder{ - primaryTable: sceneTable, - joinTable: performersScenesTable, - joinAs: "performers_join", - primaryFK: sceneIDColumn, - foreignFK: performerIDColumn, - - addJoinTable: func(f *filterBuilder) { - qb.performersRepository().join(f, "performers_join", "scenes.id") - }, - } - - return h.handler(performers) -} - -func scenePerformerCountCriterionHandler(qb *SceneStore, performerCount *models.IntCriterionInput) criterionHandlerFunc { - h := countCriterionHandlerBuilder{ - primaryTable: sceneTable, - joinTable: performersScenesTable, - primaryFK: sceneIDColumn, - } - - return h.handler(performerCount) -} - -func scenePerformerFavoriteCriterionHandler(performerfavorite *bool) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if performerfavorite != nil { - f.addLeftJoin("performers_scenes", "", "scenes.id = performers_scenes.scene_id") - - if *performerfavorite { - // contains at least one favorite - f.addLeftJoin("performers", "", "performers.id = performers_scenes.performer_id") - f.addWhere("performers.favorite = 1") - } else { - // contains zero favorites - f.addLeftJoin(`(SELECT performers_scenes.scene_id as id FROM performers_scenes -JOIN performers ON performers.id = performers_scenes.performer_id -GROUP BY performers_scenes.scene_id HAVING SUM(performers.favorite) = 0)`, "nofaves", "scenes.id = nofaves.id") - f.addWhere("performers_scenes.scene_id IS NULL OR nofaves.id IS NOT NULL") - } - } - } -} - -func scenePerformerAgeCriterionHandler(performerAge *models.IntCriterionInput) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if performerAge != nil { - f.addInnerJoin("performers_scenes", "", "scenes.id = performers_scenes.scene_id") - f.addInnerJoin("performers", "", "performers_scenes.performer_id = performers.id") - - f.addWhere("scenes.date != '' AND performers.birthdate != ''") - f.addWhere("scenes.date IS NOT NULL AND performers.birthdate IS NOT NULL") - - ageCalc := "cast(strftime('%Y.%m%d', scenes.date) - strftime('%Y.%m%d', performers.birthdate) as int)" - whereClause, args := getIntWhereClause(ageCalc, performerAge.Modifier, performerAge.Value, performerAge.Value2) - f.addWhere(whereClause, args...) - } - } -} - -func sceneMoviesCriterionHandler(qb *SceneStore, movies *models.MultiCriterionInput) criterionHandlerFunc { - addJoinsFunc := func(f *filterBuilder) { - qb.moviesRepository().join(f, "", "scenes.id") - f.addLeftJoin("movies", "", "movies_scenes.movie_id = movies.id") - } - h := qb.getMultiCriterionHandlerBuilder(movieTable, moviesScenesTable, "movie_id", addJoinsFunc) - return h.handler(movies) -} - -func sceneGalleriesCriterionHandler(qb *SceneStore, galleries *models.MultiCriterionInput) criterionHandlerFunc { - addJoinsFunc := func(f *filterBuilder) { - qb.galleriesRepository().join(f, "", "scenes.id") - f.addLeftJoin("galleries", "", "scenes_galleries.gallery_id = galleries.id") - } - h := qb.getMultiCriterionHandlerBuilder(galleryTable, scenesGalleriesTable, "gallery_id", addJoinsFunc) - return h.handler(galleries) -} - -func scenePerformerTagsCriterionHandler(qb *SceneStore, tags *models.HierarchicalMultiCriterionInput) criterionHandler { - return &joinedPerformerTagsHandler{ - criterion: tags, - primaryTable: sceneTable, - joinTable: performersScenesTable, - joinPrimaryKey: sceneIDColumn, - } -} - -func scenePhashDistanceCriterionHandler(qb *SceneStore, phashDistance *models.PhashDistanceCriterionInput) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if phashDistance != nil { - qb.addSceneFilesTable(f) - f.addLeftJoin(fingerprintTable, "fingerprints_phash", "scenes_files.file_id = fingerprints_phash.file_id AND fingerprints_phash.type = 'phash'") - - value, _ := utils.StringToPhash(phashDistance.Value) - distance := 0 - if phashDistance.Distance != nil { - distance = *phashDistance.Distance - } - - if distance == 0 { - // use the default handler - intCriterionHandler(&models.IntCriterionInput{ - Value: int(value), - Modifier: phashDistance.Modifier, - }, "fingerprints_phash.fingerprint", nil)(ctx, f) - } - - switch { - case phashDistance.Modifier == models.CriterionModifierEquals && distance > 0: - // needed to avoid a type mismatch - f.addWhere("typeof(fingerprints_phash.fingerprint) = 'integer'") - f.addWhere("phash_distance(fingerprints_phash.fingerprint, ?) < ?", value, distance) - case phashDistance.Modifier == models.CriterionModifierNotEquals && distance > 0: - // needed to avoid a type mismatch - f.addWhere("typeof(fingerprints_phash.fingerprint) = 'integer'") - f.addWhere("phash_distance(fingerprints_phash.fingerprint, ?) > ?", value, distance) - default: - intCriterionHandler(&models.IntCriterionInput{ - Value: int(value), - Modifier: phashDistance.Modifier, - }, "fingerprints_phash.fingerprint", nil)(ctx, f) - } - } - } -} - var sceneSortOptions = sortOptions{ "bitrate", "created_at", @@ -1719,7 +1260,7 @@ func (qb *SceneStore) AssignFiles(ctx context.Context, sceneID int, fileIDs []mo } // assign primary only if destination has no files - existingFileIDs, err := qb.filesRepository().get(ctx, sceneID) + existingFileIDs, err := sceneRepository.files.get(ctx, sceneID) if err != nil { return err } @@ -1728,18 +1269,10 @@ func (qb *SceneStore) AssignFiles(ctx context.Context, sceneID int, fileIDs []mo return scenesFilesTableMgr.insertJoins(ctx, sceneID, firstPrimary, fileIDs) } -func (qb *SceneStore) moviesRepository() *repository { - return &repository{ - tx: qb.tx, - tableName: moviesScenesTable, - idColumn: sceneIDColumn, - } -} - func (qb *SceneStore) GetMovies(ctx context.Context, id int) (ret []models.MoviesScenes, err error) { ret = []models.MoviesScenes{} - if err := qb.moviesRepository().getAll(ctx, id, func(rows *sqlx.Rows) error { + if err := sceneRepository.movies.getAll(ctx, id, func(rows *sqlx.Rows) error { var ms moviesScenesRow if err := rows.StructScan(&ms); err != nil { return err @@ -1754,91 +1287,36 @@ func (qb *SceneStore) GetMovies(ctx context.Context, id int) (ret []models.Movie return ret, nil } -func (qb *SceneStore) filesRepository() *filesRepository { - return &filesRepository{ - repository: repository{ - tx: qb.tx, - tableName: scenesFilesTable, - idColumn: sceneIDColumn, - }, - } -} - func (qb *SceneStore) AddFileID(ctx context.Context, id int, fileID models.FileID) error { const firstPrimary = false return scenesFilesTableMgr.insertJoins(ctx, id, firstPrimary, []models.FileID{fileID}) } -func (qb *SceneStore) performersRepository() *joinRepository { - return &joinRepository{ - repository: repository{ - tx: qb.tx, - tableName: performersScenesTable, - idColumn: sceneIDColumn, - }, - fkColumn: performerIDColumn, - } -} - func (qb *SceneStore) GetPerformerIDs(ctx context.Context, id int) ([]int, error) { - return qb.performersRepository().getIDs(ctx, id) -} - -func (qb *SceneStore) tagsRepository() *joinRepository { - return &joinRepository{ - repository: repository{ - tx: qb.tx, - tableName: scenesTagsTable, - idColumn: sceneIDColumn, - }, - fkColumn: tagIDColumn, - foreignTable: tagTable, - orderBy: "tags.name ASC", - } + return sceneRepository.performers.getIDs(ctx, id) } func (qb *SceneStore) GetTagIDs(ctx context.Context, id int) ([]int, error) { - return qb.tagsRepository().getIDs(ctx, id) -} - -func (qb *SceneStore) galleriesRepository() *joinRepository { - return &joinRepository{ - repository: repository{ - tx: qb.tx, - tableName: scenesGalleriesTable, - idColumn: sceneIDColumn, - }, - fkColumn: galleryIDColumn, - } + return sceneRepository.tags.getIDs(ctx, id) } func (qb *SceneStore) GetGalleryIDs(ctx context.Context, id int) ([]int, error) { - return qb.galleriesRepository().getIDs(ctx, id) + return sceneRepository.galleries.getIDs(ctx, id) } func (qb *SceneStore) AddGalleryIDs(ctx context.Context, sceneID int, galleryIDs []int) error { return scenesGalleriesTableMgr.addJoins(ctx, sceneID, galleryIDs) } -func (qb *SceneStore) stashIDRepository() *stashIDRepository { - return &stashIDRepository{ - repository{ - tx: qb.tx, - tableName: "scene_stash_ids", - idColumn: sceneIDColumn, - }, - } -} - func (qb *SceneStore) GetStashIDs(ctx context.Context, sceneID int) ([]models.StashID, error) { - return qb.stashIDRepository().get(ctx, sceneID) + return sceneRepository.stashIDs.get(ctx, sceneID) } func (qb *SceneStore) FindDuplicates(ctx context.Context, distance int, durationDiff float64) ([][]*models.Scene, error) { var dupeIds [][]int if distance == 0 { var ids []string - if err := qb.tx.Select(ctx, &ids, findExactDuplicateQuery, durationDiff); err != nil { + if err := dbWrapper.Select(ctx, &ids, findExactDuplicateQuery, durationDiff); err != nil { return nil, err } @@ -1858,7 +1336,7 @@ func (qb *SceneStore) FindDuplicates(ctx context.Context, distance int, duration } else { var hashes []*utils.Phash - if err := qb.queryFunc(ctx, findAllPhashesQuery, nil, false, func(rows *sqlx.Rows) error { + if err := sceneRepository.queryFunc(ctx, findAllPhashesQuery, nil, false, func(rows *sqlx.Rows) error { phash := utils.Phash{ Bucket: -1, Duration: -1, diff --git a/pkg/sqlite/scene_filter.go b/pkg/sqlite/scene_filter.go new file mode 100644 index 00000000000..2ce329a9626 --- /dev/null +++ b/pkg/sqlite/scene_filter.go @@ -0,0 +1,533 @@ +package sqlite + +import ( + "context" + "fmt" + + "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/utils" +) + +type sceneFilterHandler struct { + sceneFilter *models.SceneFilterType +} + +func (qb *sceneFilterHandler) validate() error { + sceneFilter := qb.sceneFilter + if sceneFilter == nil { + return nil + } + + if err := validateFilterCombination(sceneFilter.OperatorFilter); err != nil { + return err + } + + if subFilter := sceneFilter.SubFilter(); subFilter != nil { + sqb := &sceneFilterHandler{sceneFilter: subFilter} + if err := sqb.validate(); err != nil { + return err + } + } + + return nil +} + +func (qb *sceneFilterHandler) handle(ctx context.Context, f *filterBuilder) { + sceneFilter := qb.sceneFilter + if sceneFilter == nil { + return + } + + if err := qb.validate(); err != nil { + f.setError(err) + return + } + + sf := sceneFilter.SubFilter() + if sf != nil { + sub := &sceneFilterHandler{sf} + handleSubFilter(ctx, sub, f, sceneFilter.OperatorFilter) + } + + f.handleCriterion(ctx, qb.criterionHandler()) +} + +func (qb *sceneFilterHandler) criterionHandler() criterionHandler { + sceneFilter := qb.sceneFilter + return compoundHandler{ + intCriterionHandler(sceneFilter.ID, "scenes.id", nil), + pathCriterionHandler(sceneFilter.Path, "folders.path", "files.basename", qb.addFoldersTable), + qb.fileCountCriterionHandler(sceneFilter.FileCount), + stringCriterionHandler(sceneFilter.Title, "scenes.title"), + stringCriterionHandler(sceneFilter.Code, "scenes.code"), + stringCriterionHandler(sceneFilter.Details, "scenes.details"), + stringCriterionHandler(sceneFilter.Director, "scenes.director"), + criterionHandlerFunc(func(ctx context.Context, f *filterBuilder) { + if sceneFilter.Oshash != nil { + qb.addSceneFilesTable(f) + f.addLeftJoin(fingerprintTable, "fingerprints_oshash", "scenes_files.file_id = fingerprints_oshash.file_id AND fingerprints_oshash.type = 'oshash'") + } + + stringCriterionHandler(sceneFilter.Oshash, "fingerprints_oshash.fingerprint")(ctx, f) + }), + + criterionHandlerFunc(func(ctx context.Context, f *filterBuilder) { + if sceneFilter.Checksum != nil { + qb.addSceneFilesTable(f) + f.addLeftJoin(fingerprintTable, "fingerprints_md5", "scenes_files.file_id = fingerprints_md5.file_id AND fingerprints_md5.type = 'md5'") + } + + stringCriterionHandler(sceneFilter.Checksum, "fingerprints_md5.fingerprint")(ctx, f) + }), + + criterionHandlerFunc(func(ctx context.Context, f *filterBuilder) { + if sceneFilter.Phash != nil { + // backwards compatibility + qb.phashDistanceCriterionHandler(&models.PhashDistanceCriterionInput{ + Value: sceneFilter.Phash.Value, + Modifier: sceneFilter.Phash.Modifier, + })(ctx, f) + } + }), + + qb.phashDistanceCriterionHandler(sceneFilter.PhashDistance), + + intCriterionHandler(sceneFilter.Rating100, "scenes.rating", nil), + qb.oCountCriterionHandler(sceneFilter.OCounter), + boolCriterionHandler(sceneFilter.Organized, "scenes.organized", nil), + + floatIntCriterionHandler(sceneFilter.Duration, "video_files.duration", qb.addVideoFilesTable), + resolutionCriterionHandler(sceneFilter.Resolution, "video_files.height", "video_files.width", qb.addVideoFilesTable), + orientationCriterionHandler(sceneFilter.Orientation, "video_files.height", "video_files.width", qb.addVideoFilesTable), + floatIntCriterionHandler(sceneFilter.Framerate, "ROUND(video_files.frame_rate)", qb.addVideoFilesTable), + intCriterionHandler(sceneFilter.Bitrate, "video_files.bit_rate", qb.addVideoFilesTable), + qb.codecCriterionHandler(sceneFilter.VideoCodec, "video_files.video_codec", qb.addVideoFilesTable), + qb.codecCriterionHandler(sceneFilter.AudioCodec, "video_files.audio_codec", qb.addVideoFilesTable), + + qb.hasMarkersCriterionHandler(sceneFilter.HasMarkers), + qb.isMissingCriterionHandler(sceneFilter.IsMissing), + qb.urlsCriterionHandler(sceneFilter.URL), + + criterionHandlerFunc(func(ctx context.Context, f *filterBuilder) { + if sceneFilter.StashID != nil { + sceneRepository.stashIDs.join(f, "scene_stash_ids", "scenes.id") + stringCriterionHandler(sceneFilter.StashID, "scene_stash_ids.stash_id")(ctx, f) + } + }), + + &stashIDCriterionHandler{ + c: sceneFilter.StashIDEndpoint, + stashIDRepository: &sceneRepository.stashIDs, + stashIDTableAs: "scene_stash_ids", + parentIDCol: "scenes.id", + }, + + boolCriterionHandler(sceneFilter.Interactive, "video_files.interactive", qb.addVideoFilesTable), + intCriterionHandler(sceneFilter.InteractiveSpeed, "video_files.interactive_speed", qb.addVideoFilesTable), + + qb.captionCriterionHandler(sceneFilter.Captions), + + floatIntCriterionHandler(sceneFilter.ResumeTime, "scenes.resume_time", nil), + floatIntCriterionHandler(sceneFilter.PlayDuration, "scenes.play_duration", nil), + qb.playCountCriterionHandler(sceneFilter.PlayCount), + criterionHandlerFunc(func(ctx context.Context, f *filterBuilder) { + if sceneFilter.LastPlayedAt != nil { + f.addLeftJoin( + fmt.Sprintf("(SELECT %s, MAX(%s) as last_played_at FROM %s GROUP BY %s)", sceneIDColumn, sceneViewDateColumn, scenesViewDatesTable, sceneIDColumn), + "scene_last_view", + fmt.Sprintf("scene_last_view.%s = scenes.id", sceneIDColumn), + ) + h := timestampCriterionHandler{sceneFilter.LastPlayedAt, "IFNULL(last_played_at, datetime(0))", nil} + h.handle(ctx, f) + } + }), + + qb.tagsCriterionHandler(sceneFilter.Tags), + qb.tagCountCriterionHandler(sceneFilter.TagCount), + qb.performersCriterionHandler(sceneFilter.Performers), + qb.performerCountCriterionHandler(sceneFilter.PerformerCount), + studioCriterionHandler(sceneTable, sceneFilter.Studios), + qb.moviesCriterionHandler(sceneFilter.Movies), + qb.galleriesCriterionHandler(sceneFilter.Galleries), + qb.performerTagsCriterionHandler(sceneFilter.PerformerTags), + qb.performerFavoriteCriterionHandler(sceneFilter.PerformerFavorite), + qb.performerAgeCriterionHandler(sceneFilter.PerformerAge), + qb.phashDuplicatedCriterionHandler(sceneFilter.Duplicated, qb.addSceneFilesTable), + &dateCriterionHandler{sceneFilter.Date, "scenes.date", nil}, + ×tampCriterionHandler{sceneFilter.CreatedAt, "scenes.created_at", nil}, + ×tampCriterionHandler{sceneFilter.UpdatedAt, "scenes.updated_at", nil}, + + &relatedFilterHandler{ + relatedIDCol: "scenes_galleries.gallery_id", + relatedRepo: galleryRepository.repository, + relatedHandler: &galleryFilterHandler{sceneFilter.GalleriesFilter}, + joinFn: func(f *filterBuilder) { + sceneRepository.galleries.innerJoin(f, "", "scenes.id") + }, + }, + + &relatedFilterHandler{ + relatedIDCol: "performers_join.performer_id", + relatedRepo: performerRepository.repository, + relatedHandler: &performerFilterHandler{sceneFilter.PerformersFilter}, + joinFn: func(f *filterBuilder) { + sceneRepository.performers.innerJoin(f, "performers_join", "scenes.id") + }, + }, + + &relatedFilterHandler{ + relatedIDCol: "scenes.studio_id", + relatedRepo: studioRepository.repository, + relatedHandler: &studioFilterHandler{sceneFilter.StudiosFilter}, + }, + + &relatedFilterHandler{ + relatedIDCol: "scene_tag.tag_id", + relatedRepo: tagRepository.repository, + relatedHandler: &tagFilterHandler{sceneFilter.TagsFilter}, + joinFn: func(f *filterBuilder) { + sceneRepository.tags.innerJoin(f, "scene_tag", "scenes.id") + }, + }, + + &relatedFilterHandler{ + relatedIDCol: "movies_scenes.movie_id", + relatedRepo: movieRepository.repository, + relatedHandler: &movieFilterHandler{sceneFilter.MoviesFilter}, + joinFn: func(f *filterBuilder) { + sceneRepository.movies.innerJoin(f, "", "scenes.id") + }, + }, + + &relatedFilterHandler{ + relatedIDCol: "scene_markers.id", + relatedRepo: sceneMarkerRepository.repository, + relatedHandler: &sceneMarkerFilterHandler{sceneFilter.MarkersFilter}, + joinFn: func(f *filterBuilder) { + f.addInnerJoin("scene_markers", "", "scenes.id") + }, + }, + } +} + +func (qb *sceneFilterHandler) addSceneFilesTable(f *filterBuilder) { + f.addLeftJoin(scenesFilesTable, "", "scenes_files.scene_id = scenes.id") +} + +func (qb *sceneFilterHandler) addFilesTable(f *filterBuilder) { + qb.addSceneFilesTable(f) + f.addLeftJoin(fileTable, "", "scenes_files.file_id = files.id") +} + +func (qb *sceneFilterHandler) addFoldersTable(f *filterBuilder) { + qb.addFilesTable(f) + f.addLeftJoin(folderTable, "", "files.parent_folder_id = folders.id") +} + +func (qb *sceneFilterHandler) addVideoFilesTable(f *filterBuilder) { + qb.addSceneFilesTable(f) + f.addLeftJoin(videoFileTable, "", "video_files.file_id = scenes_files.file_id") +} + +func (qb *sceneFilterHandler) playCountCriterionHandler(count *models.IntCriterionInput) criterionHandlerFunc { + h := countCriterionHandlerBuilder{ + primaryTable: sceneTable, + joinTable: scenesViewDatesTable, + primaryFK: sceneIDColumn, + } + + return h.handler(count) +} + +func (qb *sceneFilterHandler) oCountCriterionHandler(count *models.IntCriterionInput) criterionHandlerFunc { + h := countCriterionHandlerBuilder{ + primaryTable: sceneTable, + joinTable: scenesODatesTable, + primaryFK: sceneIDColumn, + } + + return h.handler(count) +} + +func (qb *sceneFilterHandler) fileCountCriterionHandler(fileCount *models.IntCriterionInput) criterionHandlerFunc { + h := countCriterionHandlerBuilder{ + primaryTable: sceneTable, + joinTable: scenesFilesTable, + primaryFK: sceneIDColumn, + } + + return h.handler(fileCount) +} + +func (qb *sceneFilterHandler) phashDuplicatedCriterionHandler(duplicatedFilter *models.PHashDuplicationCriterionInput, addJoinFn func(f *filterBuilder)) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + // TODO: Wishlist item: Implement Distance matching + if duplicatedFilter != nil { + if addJoinFn != nil { + addJoinFn(f) + } + + var v string + if *duplicatedFilter.Duplicated { + v = ">" + } else { + v = "=" + } + + f.addInnerJoin("(SELECT file_id FROM files_fingerprints INNER JOIN (SELECT fingerprint FROM files_fingerprints WHERE type = 'phash' GROUP BY fingerprint HAVING COUNT (fingerprint) "+v+" 1) dupes on files_fingerprints.fingerprint = dupes.fingerprint)", "scph", "scenes_files.file_id = scph.file_id") + } + } +} + +func (qb *sceneFilterHandler) codecCriterionHandler(codec *models.StringCriterionInput, codecColumn string, addJoinFn func(f *filterBuilder)) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if codec != nil { + if addJoinFn != nil { + addJoinFn(f) + } + + stringCriterionHandler(codec, codecColumn)(ctx, f) + } + } +} + +func (qb *sceneFilterHandler) hasMarkersCriterionHandler(hasMarkers *string) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if hasMarkers != nil { + f.addLeftJoin("scene_markers", "", "scene_markers.scene_id = scenes.id") + if *hasMarkers == "true" { + f.addHaving("count(scene_markers.scene_id) > 0") + } else { + f.addWhere("scene_markers.id IS NULL") + } + } + } +} + +func (qb *sceneFilterHandler) isMissingCriterionHandler(isMissing *string) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if isMissing != nil && *isMissing != "" { + switch *isMissing { + case "url": + scenesURLsTableMgr.join(f, "", "scenes.id") + f.addWhere("scene_urls.url IS NULL") + case "galleries": + sceneRepository.galleries.join(f, "galleries_join", "scenes.id") + f.addWhere("galleries_join.scene_id IS NULL") + case "studio": + f.addWhere("scenes.studio_id IS NULL") + case "movie": + sceneRepository.movies.join(f, "movies_join", "scenes.id") + f.addWhere("movies_join.scene_id IS NULL") + case "performers": + sceneRepository.performers.join(f, "performers_join", "scenes.id") + f.addWhere("performers_join.scene_id IS NULL") + case "date": + f.addWhere(`scenes.date IS NULL OR scenes.date IS ""`) + case "tags": + sceneRepository.tags.join(f, "tags_join", "scenes.id") + f.addWhere("tags_join.scene_id IS NULL") + case "stash_id": + sceneRepository.stashIDs.join(f, "scene_stash_ids", "scenes.id") + f.addWhere("scene_stash_ids.scene_id IS NULL") + case "phash": + qb.addSceneFilesTable(f) + f.addLeftJoin(fingerprintTable, "fingerprints_phash", "scenes_files.file_id = fingerprints_phash.file_id AND fingerprints_phash.type = 'phash'") + f.addWhere("fingerprints_phash.fingerprint IS NULL") + case "cover": + f.addWhere("scenes.cover_blob IS NULL") + default: + f.addWhere("(scenes." + *isMissing + " IS NULL OR TRIM(scenes." + *isMissing + ") = '')") + } + } + } +} + +func (qb *sceneFilterHandler) urlsCriterionHandler(url *models.StringCriterionInput) criterionHandlerFunc { + h := stringListCriterionHandlerBuilder{ + joinTable: scenesURLsTable, + stringColumn: sceneURLColumn, + addJoinTable: func(f *filterBuilder) { + scenesURLsTableMgr.join(f, "", "scenes.id") + }, + } + + return h.handler(url) +} + +func (qb *sceneFilterHandler) getMultiCriterionHandlerBuilder(foreignTable, joinTable, foreignFK string, addJoinsFunc func(f *filterBuilder)) multiCriterionHandlerBuilder { + return multiCriterionHandlerBuilder{ + primaryTable: sceneTable, + foreignTable: foreignTable, + joinTable: joinTable, + primaryFK: sceneIDColumn, + foreignFK: foreignFK, + addJoinsFunc: addJoinsFunc, + } +} + +func (qb *sceneFilterHandler) captionCriterionHandler(captions *models.StringCriterionInput) criterionHandlerFunc { + h := stringListCriterionHandlerBuilder{ + joinTable: videoCaptionsTable, + stringColumn: captionCodeColumn, + addJoinTable: func(f *filterBuilder) { + qb.addSceneFilesTable(f) + f.addLeftJoin(videoCaptionsTable, "", "video_captions.file_id = scenes_files.file_id") + }, + } + + return h.handler(captions) +} + +func (qb *sceneFilterHandler) tagsCriterionHandler(tags *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { + h := joinedHierarchicalMultiCriterionHandlerBuilder{ + primaryTable: sceneTable, + foreignTable: tagTable, + foreignFK: "tag_id", + + relationsTable: "tags_relations", + joinAs: "scene_tag", + joinTable: scenesTagsTable, + primaryFK: sceneIDColumn, + } + + return h.handler(tags) +} + +func (qb *sceneFilterHandler) tagCountCriterionHandler(tagCount *models.IntCriterionInput) criterionHandlerFunc { + h := countCriterionHandlerBuilder{ + primaryTable: sceneTable, + joinTable: scenesTagsTable, + primaryFK: sceneIDColumn, + } + + return h.handler(tagCount) +} + +func (qb *sceneFilterHandler) performersCriterionHandler(performers *models.MultiCriterionInput) criterionHandlerFunc { + h := joinedMultiCriterionHandlerBuilder{ + primaryTable: sceneTable, + joinTable: performersScenesTable, + joinAs: "performers_join", + primaryFK: sceneIDColumn, + foreignFK: performerIDColumn, + + addJoinTable: func(f *filterBuilder) { + sceneRepository.performers.join(f, "performers_join", "scenes.id") + }, + } + + return h.handler(performers) +} + +func (qb *sceneFilterHandler) performerCountCriterionHandler(performerCount *models.IntCriterionInput) criterionHandlerFunc { + h := countCriterionHandlerBuilder{ + primaryTable: sceneTable, + joinTable: performersScenesTable, + primaryFK: sceneIDColumn, + } + + return h.handler(performerCount) +} + +func (qb *sceneFilterHandler) performerFavoriteCriterionHandler(performerfavorite *bool) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if performerfavorite != nil { + f.addLeftJoin("performers_scenes", "", "scenes.id = performers_scenes.scene_id") + + if *performerfavorite { + // contains at least one favorite + f.addLeftJoin("performers", "", "performers.id = performers_scenes.performer_id") + f.addWhere("performers.favorite = 1") + } else { + // contains zero favorites + f.addLeftJoin(`(SELECT performers_scenes.scene_id as id FROM performers_scenes +JOIN performers ON performers.id = performers_scenes.performer_id +GROUP BY performers_scenes.scene_id HAVING SUM(performers.favorite) = 0)`, "nofaves", "scenes.id = nofaves.id") + f.addWhere("performers_scenes.scene_id IS NULL OR nofaves.id IS NOT NULL") + } + } + } +} + +func (qb *sceneFilterHandler) performerAgeCriterionHandler(performerAge *models.IntCriterionInput) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if performerAge != nil { + f.addInnerJoin("performers_scenes", "", "scenes.id = performers_scenes.scene_id") + f.addInnerJoin("performers", "", "performers_scenes.performer_id = performers.id") + + f.addWhere("scenes.date != '' AND performers.birthdate != ''") + f.addWhere("scenes.date IS NOT NULL AND performers.birthdate IS NOT NULL") + + ageCalc := "cast(strftime('%Y.%m%d', scenes.date) - strftime('%Y.%m%d', performers.birthdate) as int)" + whereClause, args := getIntWhereClause(ageCalc, performerAge.Modifier, performerAge.Value, performerAge.Value2) + f.addWhere(whereClause, args...) + } + } +} + +func (qb *sceneFilterHandler) moviesCriterionHandler(movies *models.MultiCriterionInput) criterionHandlerFunc { + addJoinsFunc := func(f *filterBuilder) { + sceneRepository.movies.join(f, "", "scenes.id") + f.addLeftJoin("movies", "", "movies_scenes.movie_id = movies.id") + } + h := qb.getMultiCriterionHandlerBuilder(movieTable, moviesScenesTable, "movie_id", addJoinsFunc) + return h.handler(movies) +} + +func (qb *sceneFilterHandler) galleriesCriterionHandler(galleries *models.MultiCriterionInput) criterionHandlerFunc { + addJoinsFunc := func(f *filterBuilder) { + sceneRepository.galleries.join(f, "", "scenes.id") + f.addLeftJoin("galleries", "", "scenes_galleries.gallery_id = galleries.id") + } + h := qb.getMultiCriterionHandlerBuilder(galleryTable, scenesGalleriesTable, "gallery_id", addJoinsFunc) + return h.handler(galleries) +} + +func (qb *sceneFilterHandler) performerTagsCriterionHandler(tags *models.HierarchicalMultiCriterionInput) criterionHandler { + return &joinedPerformerTagsHandler{ + criterion: tags, + primaryTable: sceneTable, + joinTable: performersScenesTable, + joinPrimaryKey: sceneIDColumn, + } +} + +func (qb *sceneFilterHandler) phashDistanceCriterionHandler(phashDistance *models.PhashDistanceCriterionInput) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if phashDistance != nil { + qb.addSceneFilesTable(f) + f.addLeftJoin(fingerprintTable, "fingerprints_phash", "scenes_files.file_id = fingerprints_phash.file_id AND fingerprints_phash.type = 'phash'") + + value, _ := utils.StringToPhash(phashDistance.Value) + distance := 0 + if phashDistance.Distance != nil { + distance = *phashDistance.Distance + } + + if distance == 0 { + // use the default handler + intCriterionHandler(&models.IntCriterionInput{ + Value: int(value), + Modifier: phashDistance.Modifier, + }, "fingerprints_phash.fingerprint", nil)(ctx, f) + } + + switch { + case phashDistance.Modifier == models.CriterionModifierEquals && distance > 0: + // needed to avoid a type mismatch + f.addWhere("typeof(fingerprints_phash.fingerprint) = 'integer'") + f.addWhere("phash_distance(fingerprints_phash.fingerprint, ?) < ?", value, distance) + case phashDistance.Modifier == models.CriterionModifierNotEquals && distance > 0: + // needed to avoid a type mismatch + f.addWhere("typeof(fingerprints_phash.fingerprint) = 'integer'") + f.addWhere("phash_distance(fingerprints_phash.fingerprint, ?) > ?", value, distance) + default: + intCriterionHandler(&models.IntCriterionInput{ + Value: int(value), + Modifier: phashDistance.Modifier, + }, "fingerprints_phash.fingerprint", nil)(ctx, f) + } + } + } +} diff --git a/pkg/sqlite/scene_marker.go b/pkg/sqlite/scene_marker.go index f1221cd0e13..158916a828a 100644 --- a/pkg/sqlite/scene_marker.go +++ b/pkg/sqlite/scene_marker.go @@ -75,24 +75,41 @@ func (r *sceneMarkerRowRecord) fromPartial(o models.SceneMarkerPartial) { r.setTimestamp("updated_at", o.UpdatedAt) } -type SceneMarkerStore struct { +type sceneMarkerRepositoryType struct { repository - tableMgr *table + scenes repository + tags joinRepository } -func NewSceneMarkerStore() *SceneMarkerStore { - return &SceneMarkerStore{ +var ( + sceneMarkerRepository = sceneMarkerRepositoryType{ repository: repository{ tableName: sceneMarkerTable, idColumn: idColumn, }, - tableMgr: sceneMarkerTableMgr, + scenes: repository{ + tableName: sceneTable, + idColumn: idColumn, + }, + tags: joinRepository{ + repository: repository{ + tableName: "scene_markers_tags", + idColumn: "scene_marker_id", + }, + fkColumn: tagIDColumn, + }, } +) + +type SceneMarkerStore struct{} + +func NewSceneMarkerStore() *SceneMarkerStore { + return &SceneMarkerStore{} } func (qb *SceneMarkerStore) table() exp.IdentifierExpression { - return qb.tableMgr.table + return sceneMarkerTableMgr.table } func (qb *SceneMarkerStore) selectDataset() *goqu.SelectDataset { @@ -103,7 +120,7 @@ func (qb *SceneMarkerStore) Create(ctx context.Context, newObject *models.SceneM var r sceneMarkerRow r.fromSceneMarker(*newObject) - id, err := qb.tableMgr.insertID(ctx, r) + id, err := sceneMarkerTableMgr.insertID(ctx, r) if err != nil { return err } @@ -128,7 +145,7 @@ func (qb *SceneMarkerStore) UpdatePartial(ctx context.Context, id int, partial m r.fromPartial(partial) if len(r.Record) > 0 { - if err := qb.tableMgr.updateByID(ctx, id, r.Record); err != nil { + if err := sceneMarkerTableMgr.updateByID(ctx, id, r.Record); err != nil { return nil, err } } @@ -140,7 +157,7 @@ func (qb *SceneMarkerStore) Update(ctx context.Context, updatedObject *models.Sc var r sceneMarkerRow r.fromSceneMarker(*updatedObject) - if err := qb.tableMgr.updateByID(ctx, updatedObject.ID, r); err != nil { + if err := sceneMarkerTableMgr.updateByID(ctx, updatedObject.ID, r); err != nil { return err } @@ -148,7 +165,7 @@ func (qb *SceneMarkerStore) Update(ctx context.Context, updatedObject *models.Sc } func (qb *SceneMarkerStore) Destroy(ctx context.Context, id int) error { - return qb.destroyExisting(ctx, []int{id}) + return sceneMarkerRepository.destroyExisting(ctx, []int{id}) } // returns nil, nil if not found @@ -186,7 +203,7 @@ func (qb *SceneMarkerStore) FindMany(ctx context.Context, ids []int) ([]*models. // returns nil, sql.ErrNoRows if not found func (qb *SceneMarkerStore) find(ctx context.Context, id int) (*models.SceneMarker, error) { - q := qb.selectDataset().Where(qb.tableMgr.byID(id)) + q := qb.selectDataset().Where(sceneMarkerTableMgr.byID(id)) ret, err := qb.get(ctx, q) if err != nil { @@ -243,7 +260,7 @@ func (qb *SceneMarkerStore) FindBySceneID(ctx context.Context, sceneID int) ([]* func (qb *SceneMarkerStore) CountByTagID(ctx context.Context, tagID int) (int, error) { args := []interface{}{tagID, tagID} - return qb.runCountQuery(ctx, qb.buildCountQuery(countSceneMarkersForTagQuery), args) + return sceneMarkerRepository.runCountQuery(ctx, sceneMarkerRepository.buildCountQuery(countSceneMarkersForTagQuery), args) } func (qb *SceneMarkerStore) GetMarkerStrings(ctx context.Context, q *string, sort *string) ([]*models.MarkerStringsResultType, error) { @@ -272,21 +289,6 @@ func (qb *SceneMarkerStore) Wall(ctx context.Context, q *string) ([]*models.Scen return qb.getMany(ctx, qq) } -func (qb *SceneMarkerStore) makeFilter(ctx context.Context, sceneMarkerFilter *models.SceneMarkerFilterType) *filterBuilder { - query := &filterBuilder{} - - query.handleCriterion(ctx, sceneMarkerTagIDCriterionHandler(qb, sceneMarkerFilter.TagID)) - 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 -} func (qb *SceneMarkerStore) makeQuery(ctx context.Context, sceneMarkerFilter *models.SceneMarkerFilterType, findFilter *models.FindFilterType) (*queryBuilder, error) { if sceneMarkerFilter == nil { sceneMarkerFilter = &models.SceneMarkerFilterType{} @@ -295,7 +297,7 @@ func (qb *SceneMarkerStore) makeQuery(ctx context.Context, sceneMarkerFilter *mo findFilter = &models.FindFilterType{} } - query := qb.newQuery() + query := sceneMarkerRepository.newQuery() distinctIDs(&query, sceneMarkerTable) if q := findFilter.Q; q != nil && *q != "" { @@ -304,7 +306,9 @@ func (qb *SceneMarkerStore) makeQuery(ctx context.Context, sceneMarkerFilter *mo query.parseQueryString(searchColumns, *q) } - filter := qb.makeFilter(ctx, sceneMarkerFilter) + filter := filterBuilderFromHandler(ctx, &sceneMarkerFilterHandler{ + sceneMarkerFilter: sceneMarkerFilter, + }) if err := query.addFilter(filter); err != nil { return nil, err @@ -346,135 +350,6 @@ func (qb *SceneMarkerStore) QueryCount(ctx context.Context, sceneMarkerFilter *m return query.executeCount(ctx) } -func sceneMarkerTagIDCriterionHandler(qb *SceneMarkerStore, tagID *string) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if tagID != nil { - f.addLeftJoin("scene_markers_tags", "", "scene_markers_tags.scene_marker_id = scene_markers.id") - - f.addWhere("(scene_markers.primary_tag_id = ? OR scene_markers_tags.tag_id = ?)", *tagID, *tagID) - } - } -} - -func sceneMarkerTagsCriterionHandler(qb *SceneMarkerStore, criterion *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if criterion != nil { - tags := criterion.CombineExcludes() - - if tags.Modifier == models.CriterionModifierIsNull || tags.Modifier == models.CriterionModifierNotNull { - var notClause string - if tags.Modifier == models.CriterionModifierNotNull { - notClause = "NOT" - } - - f.addLeftJoin("scene_markers_tags", "", "scene_markers.id = scene_markers_tags.scene_marker_id") - - f.addWhere(fmt.Sprintf("%s scene_markers_tags.tag_id IS NULL", notClause)) - return - } - - if tags.Modifier == models.CriterionModifierEquals && tags.Depth != nil && *tags.Depth != 0 { - f.setError(fmt.Errorf("depth is not supported for equals modifier for marker tag filtering")) - return - } - - if len(tags.Value) == 0 && len(tags.Excludes) == 0 { - return - } - - if len(tags.Value) > 0 { - valuesClause, err := getHierarchicalValues(ctx, qb.tx, tags.Value, tagTable, "tags_relations", "parent_id", "child_id", tags.Depth) - if err != nil { - f.setError(err) - return - } - - f.addWith(`marker_tags AS ( - SELECT mt.scene_marker_id, t.column1 AS root_tag_id FROM scene_markers_tags mt - INNER JOIN (` + valuesClause + `) t ON t.column2 = mt.tag_id - UNION - SELECT m.id, t.column1 FROM scene_markers m - INNER JOIN (` + valuesClause + `) t ON t.column2 = m.primary_tag_id - )`) - - f.addLeftJoin("marker_tags", "", "marker_tags.scene_marker_id = scene_markers.id") - - switch tags.Modifier { - case models.CriterionModifierEquals: - // includes only the provided ids - f.addWhere("marker_tags.root_tag_id IS NOT NULL") - tagsLen := len(tags.Value) - f.addHaving(fmt.Sprintf("count(distinct marker_tags.root_tag_id) IS %d", tagsLen)) - // decrement by one to account for primary tag id - f.addWhere("(SELECT COUNT(*) FROM scene_markers_tags s WHERE s.scene_marker_id = scene_markers.id) = ?", tagsLen-1) - case models.CriterionModifierNotEquals: - f.setError(fmt.Errorf("not equals modifier is not supported for scene marker tags")) - default: - addHierarchicalConditionClauses(f, tags, "marker_tags", "root_tag_id") - } - } - - if len(criterion.Excludes) > 0 { - valuesClause, err := getHierarchicalValues(ctx, dbWrapper{}, tags.Excludes, tagTable, "tags_relations", "parent_id", "child_id", tags.Depth) - if err != nil { - f.setError(err) - return - } - - clause := "scene_markers.id NOT IN (SELECT scene_markers_tags.scene_marker_id FROM scene_markers_tags WHERE scene_markers_tags.tag_id IN (SELECT column2 FROM (%s)))" - f.addWhere(fmt.Sprintf(clause, valuesClause)) - - f.addWhere(fmt.Sprintf("scene_markers.primary_tag_id NOT IN (SELECT column2 FROM (%s))", valuesClause)) - } - } - } -} - -func sceneMarkerSceneTagsCriterionHandler(qb *SceneMarkerStore, tags *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if tags != nil { - f.addLeftJoin("scenes_tags", "", "scene_markers.scene_id = scenes_tags.scene_id") - - h := joinedHierarchicalMultiCriterionHandlerBuilder{ - tx: qb.tx, - - primaryTable: "scene_markers", - primaryKey: sceneIDColumn, - foreignTable: tagTable, - foreignFK: tagIDColumn, - - relationsTable: "tags_relations", - joinTable: "scenes_tags", - joinAs: "marker_scenes_tags", - primaryFK: sceneIDColumn, - } - - h.handler(tags).handle(ctx, f) - } - } -} - -func sceneMarkerPerformersCriterionHandler(qb *SceneMarkerStore, performers *models.MultiCriterionInput) criterionHandlerFunc { - h := joinedMultiCriterionHandlerBuilder{ - primaryTable: sceneTable, - joinTable: performersScenesTable, - joinAs: "performers_join", - primaryFK: sceneIDColumn, - foreignFK: performerIDColumn, - - addJoinTable: func(f *filterBuilder) { - f.addLeftJoin(performersScenesTable, "performers_join", "performers_join.scene_id = scene_markers.scene_id") - }, - } - - handler := h.handler(performers) - return func(ctx context.Context, f *filterBuilder) { - // Make sure scenes is included, otherwise excludes filter fails - f.addLeftJoin(sceneTable, "", "scenes.id = scene_markers.scene_id") - handler(ctx, f) - } -} - var sceneMarkerSortOptions = sortOptions{ "created_at", "id", @@ -514,7 +389,7 @@ func (qb *SceneMarkerStore) setSceneMarkerSort(query *queryBuilder, findFilter * func (qb *SceneMarkerStore) querySceneMarkers(ctx context.Context, query string, args []interface{}) ([]*models.SceneMarker, error) { const single = false var ret []*models.SceneMarker - if err := qb.queryFunc(ctx, query, args, single, func(r *sqlx.Rows) error { + if err := sceneMarkerRepository.queryFunc(ctx, query, args, single, func(r *sqlx.Rows) error { var f sceneMarkerRow if err := r.StructScan(&f); err != nil { return err @@ -532,7 +407,7 @@ func (qb *SceneMarkerStore) querySceneMarkers(ctx context.Context, query string, } func (qb *SceneMarkerStore) queryMarkerStringsResultType(ctx context.Context, query string, args []interface{}) ([]*models.MarkerStringsResultType, error) { - rows, err := qb.tx.Queryx(ctx, query, args...) + rows, err := dbWrapper.Queryx(ctx, query, args...) if err != nil && !errors.Is(err, sql.ErrNoRows) { return nil, err } @@ -554,24 +429,13 @@ func (qb *SceneMarkerStore) queryMarkerStringsResultType(ctx context.Context, qu return markerStrings, nil } -func (qb *SceneMarkerStore) tagsRepository() *joinRepository { - return &joinRepository{ - repository: repository{ - tx: qb.tx, - tableName: "scene_markers_tags", - idColumn: "scene_marker_id", - }, - fkColumn: tagIDColumn, - } -} - func (qb *SceneMarkerStore) GetTagIDs(ctx context.Context, id int) ([]int, error) { - return qb.tagsRepository().getIDs(ctx, id) + return sceneMarkerRepository.tags.getIDs(ctx, id) } func (qb *SceneMarkerStore) 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) + return sceneMarkerRepository.tags.replace(ctx, id, tagIDs) } func (qb *SceneMarkerStore) Count(ctx context.Context) (int, error) { diff --git a/pkg/sqlite/scene_marker_filter.go b/pkg/sqlite/scene_marker_filter.go new file mode 100644 index 00000000000..94147ed80af --- /dev/null +++ b/pkg/sqlite/scene_marker_filter.go @@ -0,0 +1,189 @@ +package sqlite + +import ( + "context" + "fmt" + + "github.com/stashapp/stash/pkg/models" +) + +type sceneMarkerFilterHandler struct { + sceneMarkerFilter *models.SceneMarkerFilterType +} + +func (qb *sceneMarkerFilterHandler) validate() error { + return nil +} + +func (qb *sceneMarkerFilterHandler) handle(ctx context.Context, f *filterBuilder) { + sceneMarkerFilter := qb.sceneMarkerFilter + if sceneMarkerFilter == nil { + return + } + + if err := qb.validate(); err != nil { + f.setError(err) + return + } + + f.handleCriterion(ctx, qb.criterionHandler()) +} + +func (qb *sceneMarkerFilterHandler) joinScenes(f *filterBuilder) { + sceneMarkerRepository.scenes.innerJoin(f, "", "scene_markers.scene_id") +} + +func (qb *sceneMarkerFilterHandler) criterionHandler() criterionHandler { + sceneMarkerFilter := qb.sceneMarkerFilter + return compoundHandler{ + qb.tagIDCriterionHandler(sceneMarkerFilter.TagID), + qb.tagsCriterionHandler(sceneMarkerFilter.Tags), + qb.sceneTagsCriterionHandler(sceneMarkerFilter.SceneTags), + qb.performersCriterionHandler(sceneMarkerFilter.Performers), + ×tampCriterionHandler{sceneMarkerFilter.CreatedAt, "scene_markers.created_at", nil}, + ×tampCriterionHandler{sceneMarkerFilter.UpdatedAt, "scene_markers.updated_at", nil}, + &dateCriterionHandler{sceneMarkerFilter.SceneDate, "scenes.date", qb.joinScenes}, + ×tampCriterionHandler{sceneMarkerFilter.SceneCreatedAt, "scenes.created_at", qb.joinScenes}, + ×tampCriterionHandler{sceneMarkerFilter.SceneUpdatedAt, "scenes.updated_at", qb.joinScenes}, + + &relatedFilterHandler{ + relatedIDCol: "scenes.id", + relatedRepo: sceneRepository.repository, + relatedHandler: &sceneFilterHandler{sceneMarkerFilter.SceneFilter}, + joinFn: func(f *filterBuilder) { + qb.joinScenes(f) + }, + }, + } +} + +func (qb *sceneMarkerFilterHandler) tagIDCriterionHandler(tagID *string) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if tagID != nil { + f.addLeftJoin("scene_markers_tags", "", "scene_markers_tags.scene_marker_id = scene_markers.id") + + f.addWhere("(scene_markers.primary_tag_id = ? OR scene_markers_tags.tag_id = ?)", *tagID, *tagID) + } + } +} + +func (qb *sceneMarkerFilterHandler) tagsCriterionHandler(criterion *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if criterion != nil { + tags := criterion.CombineExcludes() + + if tags.Modifier == models.CriterionModifierIsNull || tags.Modifier == models.CriterionModifierNotNull { + var notClause string + if tags.Modifier == models.CriterionModifierNotNull { + notClause = "NOT" + } + + f.addLeftJoin("scene_markers_tags", "", "scene_markers.id = scene_markers_tags.scene_marker_id") + + f.addWhere(fmt.Sprintf("%s scene_markers_tags.tag_id IS NULL", notClause)) + return + } + + if tags.Modifier == models.CriterionModifierEquals && tags.Depth != nil && *tags.Depth != 0 { + f.setError(fmt.Errorf("depth is not supported for equals modifier for marker tag filtering")) + return + } + + if len(tags.Value) == 0 && len(tags.Excludes) == 0 { + return + } + + if len(tags.Value) > 0 { + valuesClause, err := getHierarchicalValues(ctx, tags.Value, tagTable, "tags_relations", "parent_id", "child_id", tags.Depth) + if err != nil { + f.setError(err) + return + } + + f.addWith(`marker_tags AS ( + SELECT mt.scene_marker_id, t.column1 AS root_tag_id FROM scene_markers_tags mt + INNER JOIN (` + valuesClause + `) t ON t.column2 = mt.tag_id + UNION + SELECT m.id, t.column1 FROM scene_markers m + INNER JOIN (` + valuesClause + `) t ON t.column2 = m.primary_tag_id + )`) + + f.addLeftJoin("marker_tags", "", "marker_tags.scene_marker_id = scene_markers.id") + + switch tags.Modifier { + case models.CriterionModifierEquals: + // includes only the provided ids + f.addWhere("marker_tags.root_tag_id IS NOT NULL") + tagsLen := len(tags.Value) + f.addHaving(fmt.Sprintf("count(distinct marker_tags.root_tag_id) IS %d", tagsLen)) + // decrement by one to account for primary tag id + f.addWhere("(SELECT COUNT(*) FROM scene_markers_tags s WHERE s.scene_marker_id = scene_markers.id) = ?", tagsLen-1) + case models.CriterionModifierNotEquals: + f.setError(fmt.Errorf("not equals modifier is not supported for scene marker tags")) + default: + addHierarchicalConditionClauses(f, tags, "marker_tags", "root_tag_id") + } + } + + if len(criterion.Excludes) > 0 { + valuesClause, err := getHierarchicalValues(ctx, tags.Excludes, tagTable, "tags_relations", "parent_id", "child_id", tags.Depth) + if err != nil { + f.setError(err) + return + } + + clause := "scene_markers.id NOT IN (SELECT scene_markers_tags.scene_marker_id FROM scene_markers_tags WHERE scene_markers_tags.tag_id IN (SELECT column2 FROM (%s)))" + f.addWhere(fmt.Sprintf(clause, valuesClause)) + + f.addWhere(fmt.Sprintf("scene_markers.primary_tag_id NOT IN (SELECT column2 FROM (%s))", valuesClause)) + } + } + } +} + +func (qb *sceneMarkerFilterHandler) sceneTagsCriterionHandler(tags *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if tags != nil { + f.addLeftJoin("scenes_tags", "", "scene_markers.scene_id = scenes_tags.scene_id") + + h := joinedHierarchicalMultiCriterionHandlerBuilder{ + primaryTable: "scene_markers", + primaryKey: sceneIDColumn, + foreignTable: tagTable, + foreignFK: tagIDColumn, + + relationsTable: "tags_relations", + joinTable: "scenes_tags", + joinAs: "marker_scenes_tags", + primaryFK: sceneIDColumn, + } + + h.handler(tags).handle(ctx, f) + } + } +} + +func (qb *sceneMarkerFilterHandler) performersCriterionHandler(performers *models.MultiCriterionInput) criterionHandlerFunc { + h := joinedMultiCriterionHandlerBuilder{ + primaryTable: sceneTable, + joinTable: performersScenesTable, + joinAs: "performers_join", + primaryFK: sceneIDColumn, + foreignFK: performerIDColumn, + + addJoinTable: func(f *filterBuilder) { + f.addLeftJoin(performersScenesTable, "performers_join", "performers_join.scene_id = scene_markers.scene_id") + }, + } + + handler := h.handler(performers) + return func(ctx context.Context, f *filterBuilder) { + if performers == nil { + return + } + + // Make sure scenes is included, otherwise excludes filter fails + qb.joinScenes(f) + handler(ctx, f) + } +} diff --git a/pkg/sqlite/scene_test.go b/pkg/sqlite/scene_test.go index 942a12591f4..f5528b1248e 100644 --- a/pkg/sqlite/scene_test.go +++ b/pkg/sqlite/scene_test.go @@ -2411,10 +2411,12 @@ func TestSceneQueryPathOr(t *testing.T) { Value: scene1Path, Modifier: models.CriterionModifierEquals, }, - Or: &models.SceneFilterType{ - Path: &models.StringCriterionInput{ - Value: scene2Path, - Modifier: models.CriterionModifierEquals, + OperatorFilter: models.OperatorFilter[models.SceneFilterType]{ + Or: &models.SceneFilterType{ + Path: &models.StringCriterionInput{ + Value: scene2Path, + Modifier: models.CriterionModifierEquals, + }, }, }, } @@ -2444,10 +2446,12 @@ func TestSceneQueryPathAndRating(t *testing.T) { Value: scenePath, Modifier: models.CriterionModifierEquals, }, - And: &models.SceneFilterType{ - Rating100: &models.IntCriterionInput{ - Value: sceneRating, - Modifier: models.CriterionModifierEquals, + OperatorFilter: models.OperatorFilter[models.SceneFilterType]{ + And: &models.SceneFilterType{ + Rating100: &models.IntCriterionInput{ + Value: sceneRating, + Modifier: models.CriterionModifierEquals, + }, }, }, } @@ -2484,8 +2488,10 @@ func TestSceneQueryPathNotRating(t *testing.T) { sceneFilter := models.SceneFilterType{ Path: &pathCriterion, - Not: &models.SceneFilterType{ - Rating100: &ratingCriterion, + OperatorFilter: models.OperatorFilter[models.SceneFilterType]{ + Not: &models.SceneFilterType{ + Rating100: &ratingCriterion, + }, }, } @@ -2516,8 +2522,10 @@ func TestSceneIllegalQuery(t *testing.T) { } sceneFilter := &models.SceneFilterType{ - And: &subFilter, - Or: &subFilter, + OperatorFilter: models.OperatorFilter[models.SceneFilterType]{ + And: &subFilter, + Or: &subFilter, + }, } withTxn(func(ctx context.Context) error { diff --git a/pkg/sqlite/sql.go b/pkg/sqlite/sql.go index 2c5e7d39686..780d2e9881b 100644 --- a/pkg/sqlite/sql.go +++ b/pkg/sqlite/sql.go @@ -21,6 +21,11 @@ func distinctIDs(qb *queryBuilder, tableName string) { qb.from = tableName } +func selectIDs(qb *queryBuilder, tableName string) { + qb.addColumn(getColumn(tableName, "id")) + qb.from = tableName +} + func getColumn(tableName string, columnName string) string { return tableName + "." + columnName } diff --git a/pkg/sqlite/studio.go b/pkg/sqlite/studio.go index e6ab0315786..ac6a4a4d938 100644 --- a/pkg/sqlite/studio.go +++ b/pkg/sqlite/studio.go @@ -90,19 +90,51 @@ func (r *studioRowRecord) fromPartial(o models.StudioPartial) { r.setBool("ignore_auto_tag", o.IgnoreAutoTag) } -type StudioStore struct { +type studioRepositoryType struct { repository - blobJoinQueryBuilder - tableMgr *table + stashIDs stashIDRepository + + scenes repository + images repository + galleries repository } -func NewStudioStore(blobStore *BlobStore) *StudioStore { - return &StudioStore{ +var ( + studioRepository = studioRepositoryType{ repository: repository{ tableName: studioTable, idColumn: idColumn, }, + stashIDs: stashIDRepository{ + repository{ + tableName: "studio_stash_ids", + idColumn: studioIDColumn, + }, + }, + scenes: repository{ + tableName: sceneTable, + idColumn: studioIDColumn, + }, + images: repository{ + tableName: imageTable, + idColumn: studioIDColumn, + }, + galleries: repository{ + tableName: galleryTable, + idColumn: studioIDColumn, + }, + } +) + +type StudioStore struct { + blobJoinQueryBuilder + + tableMgr *table +} + +func NewStudioStore(blobStore *BlobStore) *StudioStore { + return &StudioStore{ blobJoinQueryBuilder: blobJoinQueryBuilder{ blobStore: blobStore, joinTable: studioTable, @@ -147,7 +179,7 @@ func (qb *StudioStore) Create(ctx context.Context, newObject *models.Studio) err } } - updated, _ := qb.find(ctx, id) + updated, err := qb.find(ctx, id) if err != nil { return fmt.Errorf("finding after create: %w", err) } @@ -220,7 +252,7 @@ func (qb *StudioStore) Destroy(ctx context.Context, id int) error { return err } - return qb.destroyExisting(ctx, []int{id}) + return studioRepository.destroyExisting(ctx, []int{id}) } // returns nil, nil if not found @@ -452,83 +484,6 @@ func (qb *StudioStore) QueryForAutoTag(ctx context.Context, words []string) ([]* return ret, nil } -func (qb *StudioStore) validateFilter(filter *models.StudioFilterType) error { - const and = "AND" - const or = "OR" - const not = "NOT" - - if filter.And != nil { - if filter.Or != nil { - return illegalFilterCombination(and, or) - } - if filter.Not != nil { - return illegalFilterCombination(and, not) - } - - return qb.validateFilter(filter.And) - } - - if filter.Or != nil { - if filter.Not != nil { - return illegalFilterCombination(or, not) - } - - return qb.validateFilter(filter.Or) - } - - if filter.Not != nil { - return qb.validateFilter(filter.Not) - } - - return nil -} - -func (qb *StudioStore) makeFilter(ctx context.Context, studioFilter *models.StudioFilterType) *filterBuilder { - query := &filterBuilder{} - - if studioFilter.And != nil { - query.and(qb.makeFilter(ctx, studioFilter.And)) - } - if studioFilter.Or != nil { - query.or(qb.makeFilter(ctx, studioFilter.Or)) - } - if studioFilter.Not != nil { - query.not(qb.makeFilter(ctx, studioFilter.Not)) - } - - 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.Rating100, studioTable+".rating", nil)) - query.handleCriterion(ctx, boolCriterionHandler(studioFilter.Favorite, studioTable+".favorite", nil)) - query.handleCriterion(ctx, boolCriterionHandler(studioFilter.IgnoreAutoTag, studioTable+".ignore_auto_tag", nil)) - - query.handleCriterion(ctx, criterionHandlerFunc(func(ctx context.Context, f *filterBuilder) { - if studioFilter.StashID != nil { - qb.stashIDRepository().join(f, "studio_stash_ids", "studios.id") - 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)) - query.handleCriterion(ctx, studioImageCountCriterionHandler(qb, studioFilter.ImageCount)) - query.handleCriterion(ctx, studioGalleryCountCriterionHandler(qb, studioFilter.GalleryCount)) - query.handleCriterion(ctx, studioParentCriterionHandler(qb, studioFilter.Parents)) - query.handleCriterion(ctx, studioAliasCriterionHandler(qb, studioFilter.Aliases)) - query.handleCriterion(ctx, studioChildCountCriterionHandler(qb, studioFilter.ChildCount)) - query.handleCriterion(ctx, timestampCriterionHandler(studioFilter.CreatedAt, studioTable+".created_at")) - query.handleCriterion(ctx, timestampCriterionHandler(studioFilter.UpdatedAt, studioTable+".updated_at")) - - return query -} - func (qb *StudioStore) makeQuery(ctx context.Context, studioFilter *models.StudioFilterType, findFilter *models.FindFilterType) (*queryBuilder, error) { if studioFilter == nil { studioFilter = &models.StudioFilterType{} @@ -537,7 +492,7 @@ func (qb *StudioStore) makeQuery(ctx context.Context, studioFilter *models.Studi findFilter = &models.FindFilterType{} } - query := qb.newQuery() + query := studioRepository.newQuery() distinctIDs(&query, studioTable) if q := findFilter.Q; q != nil && *q != "" { @@ -546,10 +501,9 @@ func (qb *StudioStore) makeQuery(ctx context.Context, studioFilter *models.Studi query.parseQueryString(searchColumns, *q) } - if err := qb.validateFilter(studioFilter); err != nil { - return nil, err - } - filter := qb.makeFilter(ctx, studioFilter) + filter := filterBuilderFromHandler(ctx, &studioFilterHandler{ + studioFilter: studioFilter, + }) if err := query.addFilter(filter); err != nil { return nil, err @@ -584,93 +538,6 @@ func (qb *StudioStore) Query(ctx context.Context, studioFilter *models.StudioFil return studios, countResult, nil } -func studioIsMissingCriterionHandler(qb *StudioStore, isMissing *string) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if isMissing != nil && *isMissing != "" { - switch *isMissing { - case "image": - f.addWhere("studios.image_blob IS NULL") - case "stash_id": - qb.stashIDRepository().join(f, "studio_stash_ids", "studios.id") - f.addWhere("studio_stash_ids.studio_id IS NULL") - default: - f.addWhere("(studios." + *isMissing + " IS NULL OR TRIM(studios." + *isMissing + ") = '')") - } - } - } -} - -func studioSceneCountCriterionHandler(qb *StudioStore, sceneCount *models.IntCriterionInput) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if sceneCount != nil { - f.addLeftJoin("scenes", "", "scenes.studio_id = studios.id") - clause, args := getIntCriterionWhereClause("count(distinct scenes.id)", *sceneCount) - - f.addHaving(clause, args...) - } - } -} - -func studioImageCountCriterionHandler(qb *StudioStore, imageCount *models.IntCriterionInput) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if imageCount != nil { - f.addLeftJoin("images", "", "images.studio_id = studios.id") - clause, args := getIntCriterionWhereClause("count(distinct images.id)", *imageCount) - - f.addHaving(clause, args...) - } - } -} - -func studioGalleryCountCriterionHandler(qb *StudioStore, galleryCount *models.IntCriterionInput) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if galleryCount != nil { - f.addLeftJoin("galleries", "", "galleries.studio_id = studios.id") - clause, args := getIntCriterionWhereClause("count(distinct galleries.id)", *galleryCount) - - f.addHaving(clause, args...) - } - } -} - -func studioParentCriterionHandler(qb *StudioStore, parents *models.MultiCriterionInput) criterionHandlerFunc { - addJoinsFunc := func(f *filterBuilder) { - f.addLeftJoin("studios", "parent_studio", "parent_studio.id = studios.parent_id") - } - h := multiCriterionHandlerBuilder{ - primaryTable: studioTable, - foreignTable: "parent_studio", - joinTable: "", - primaryFK: studioIDColumn, - foreignFK: "parent_id", - addJoinsFunc: addJoinsFunc, - } - return h.handler(parents) -} - -func studioAliasCriterionHandler(qb *StudioStore, alias *models.StringCriterionInput) criterionHandlerFunc { - h := stringListCriterionHandlerBuilder{ - joinTable: studioAliasesTable, - stringColumn: studioAliasColumn, - addJoinTable: func(f *filterBuilder) { - studiosAliasesTableMgr.join(f, "", "studios.id") - }, - } - - return h.handler(alias) -} - -func studioChildCountCriterionHandler(qb *StudioStore, childCount *models.IntCriterionInput) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if childCount != nil { - f.addLeftJoin("studios", "children_count", "children_count.parent_id = studios.id") - clause, args := getIntCriterionWhereClause("count(distinct children_count.id)", *childCount) - - f.addHaving(clause, args...) - } - } -} - var studioSortOptions = sortOptions{ "child_count", "created_at", @@ -735,16 +602,6 @@ func (qb *StudioStore) destroyImage(ctx context.Context, studioID int) error { return qb.blobJoinQueryBuilder.DestroyImage(ctx, studioID, studioImageBlobColumn) } -func (qb *StudioStore) stashIDRepository() *stashIDRepository { - return &stashIDRepository{ - repository{ - tx: qb.tx, - tableName: "studio_stash_ids", - idColumn: studioIDColumn, - }, - } -} - func (qb *StudioStore) GetStashIDs(ctx context.Context, studioID int) ([]models.StashID, error) { return studiosStashIDsTableMgr.get(ctx, studioID) } diff --git a/pkg/sqlite/studio_filter.go b/pkg/sqlite/studio_filter.go new file mode 100644 index 00000000000..1a3aa2131f0 --- /dev/null +++ b/pkg/sqlite/studio_filter.go @@ -0,0 +1,200 @@ +package sqlite + +import ( + "context" + + "github.com/stashapp/stash/pkg/models" +) + +type studioFilterHandler struct { + studioFilter *models.StudioFilterType +} + +func (qb *studioFilterHandler) validate() error { + studioFilter := qb.studioFilter + if studioFilter == nil { + return nil + } + + if err := validateFilterCombination(studioFilter.OperatorFilter); err != nil { + return err + } + + if subFilter := studioFilter.SubFilter(); subFilter != nil { + sqb := &studioFilterHandler{studioFilter: subFilter} + if err := sqb.validate(); err != nil { + return err + } + } + + return nil +} + +func (qb *studioFilterHandler) handle(ctx context.Context, f *filterBuilder) { + studioFilter := qb.studioFilter + if studioFilter == nil { + return + } + + if err := qb.validate(); err != nil { + f.setError(err) + return + } + + sf := studioFilter.SubFilter() + if sf != nil { + sub := &studioFilterHandler{sf} + handleSubFilter(ctx, sub, f, studioFilter.OperatorFilter) + } + + f.handleCriterion(ctx, qb.criterionHandler()) +} + +func (qb *studioFilterHandler) criterionHandler() criterionHandler { + studioFilter := qb.studioFilter + return compoundHandler{ + stringCriterionHandler(studioFilter.Name, studioTable+".name"), + stringCriterionHandler(studioFilter.Details, studioTable+".details"), + stringCriterionHandler(studioFilter.URL, studioTable+".url"), + intCriterionHandler(studioFilter.Rating100, studioTable+".rating", nil), + boolCriterionHandler(studioFilter.Favorite, studioTable+".favorite", nil), + boolCriterionHandler(studioFilter.IgnoreAutoTag, studioTable+".ignore_auto_tag", nil), + + criterionHandlerFunc(func(ctx context.Context, f *filterBuilder) { + if studioFilter.StashID != nil { + studioRepository.stashIDs.join(f, "studio_stash_ids", "studios.id") + stringCriterionHandler(studioFilter.StashID, "studio_stash_ids.stash_id")(ctx, f) + } + }), + &stashIDCriterionHandler{ + c: studioFilter.StashIDEndpoint, + stashIDRepository: &studioRepository.stashIDs, + stashIDTableAs: "studio_stash_ids", + parentIDCol: "studios.id", + }, + + qb.isMissingCriterionHandler(studioFilter.IsMissing), + qb.sceneCountCriterionHandler(studioFilter.SceneCount), + qb.imageCountCriterionHandler(studioFilter.ImageCount), + qb.galleryCountCriterionHandler(studioFilter.GalleryCount), + qb.parentCriterionHandler(studioFilter.Parents), + qb.aliasCriterionHandler(studioFilter.Aliases), + qb.childCountCriterionHandler(studioFilter.ChildCount), + ×tampCriterionHandler{studioFilter.CreatedAt, studioTable + ".created_at", nil}, + ×tampCriterionHandler{studioFilter.UpdatedAt, studioTable + ".updated_at", nil}, + + &relatedFilterHandler{ + relatedIDCol: "scenes.id", + relatedRepo: sceneRepository.repository, + relatedHandler: &sceneFilterHandler{studioFilter.ScenesFilter}, + joinFn: func(f *filterBuilder) { + sceneRepository.innerJoin(f, "", "studios.id") + }, + }, + + &relatedFilterHandler{ + relatedIDCol: "images.id", + relatedRepo: imageRepository.repository, + relatedHandler: &imageFilterHandler{studioFilter.ImagesFilter}, + joinFn: func(f *filterBuilder) { + studioRepository.images.innerJoin(f, "", "studios.id") + }, + }, + + &relatedFilterHandler{ + relatedIDCol: "galleries.id", + relatedRepo: galleryRepository.repository, + relatedHandler: &galleryFilterHandler{studioFilter.GalleriesFilter}, + joinFn: func(f *filterBuilder) { + studioRepository.galleries.innerJoin(f, "", "studios.id") + }, + }, + } +} + +func (qb *studioFilterHandler) isMissingCriterionHandler(isMissing *string) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if isMissing != nil && *isMissing != "" { + switch *isMissing { + case "image": + f.addWhere("studios.image_blob IS NULL") + case "stash_id": + studioRepository.stashIDs.join(f, "studio_stash_ids", "studios.id") + f.addWhere("studio_stash_ids.studio_id IS NULL") + default: + f.addWhere("(studios." + *isMissing + " IS NULL OR TRIM(studios." + *isMissing + ") = '')") + } + } + } +} + +func (qb *studioFilterHandler) sceneCountCriterionHandler(sceneCount *models.IntCriterionInput) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if sceneCount != nil { + f.addLeftJoin("scenes", "", "scenes.studio_id = studios.id") + clause, args := getIntCriterionWhereClause("count(distinct scenes.id)", *sceneCount) + + f.addHaving(clause, args...) + } + } +} + +func (qb *studioFilterHandler) imageCountCriterionHandler(imageCount *models.IntCriterionInput) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if imageCount != nil { + f.addLeftJoin("images", "", "images.studio_id = studios.id") + clause, args := getIntCriterionWhereClause("count(distinct images.id)", *imageCount) + + f.addHaving(clause, args...) + } + } +} + +func (qb *studioFilterHandler) galleryCountCriterionHandler(galleryCount *models.IntCriterionInput) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if galleryCount != nil { + f.addLeftJoin("galleries", "", "galleries.studio_id = studios.id") + clause, args := getIntCriterionWhereClause("count(distinct galleries.id)", *galleryCount) + + f.addHaving(clause, args...) + } + } +} + +func (qb *studioFilterHandler) parentCriterionHandler(parents *models.MultiCriterionInput) criterionHandlerFunc { + addJoinsFunc := func(f *filterBuilder) { + f.addLeftJoin("studios", "parent_studio", "parent_studio.id = studios.parent_id") + } + h := multiCriterionHandlerBuilder{ + primaryTable: studioTable, + foreignTable: "parent_studio", + joinTable: "", + primaryFK: studioIDColumn, + foreignFK: "parent_id", + addJoinsFunc: addJoinsFunc, + } + return h.handler(parents) +} + +func (qb *studioFilterHandler) aliasCriterionHandler(alias *models.StringCriterionInput) criterionHandlerFunc { + h := stringListCriterionHandlerBuilder{ + joinTable: studioAliasesTable, + stringColumn: studioAliasColumn, + addJoinTable: func(f *filterBuilder) { + studiosAliasesTableMgr.join(f, "", "studios.id") + }, + } + + return h.handler(alias) +} + +func (qb *studioFilterHandler) childCountCriterionHandler(childCount *models.IntCriterionInput) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if childCount != nil { + f.addLeftJoin("studios", "children_count", "children_count.parent_id = studios.id") + clause, args := getIntCriterionWhereClause("count(distinct children_count.id)", *childCount) + + f.addHaving(clause, args...) + } + } +} diff --git a/pkg/sqlite/studio_test.go b/pkg/sqlite/studio_test.go index 25f8ea195d3..c75c2a61f43 100644 --- a/pkg/sqlite/studio_test.go +++ b/pkg/sqlite/studio_test.go @@ -59,10 +59,12 @@ func TestStudioQueryNameOr(t *testing.T) { Value: studio1Name, Modifier: models.CriterionModifierEquals, }, - Or: &models.StudioFilterType{ - Name: &models.StringCriterionInput{ - Value: studio2Name, - Modifier: models.CriterionModifierEquals, + OperatorFilter: models.OperatorFilter[models.StudioFilterType]{ + Or: &models.StudioFilterType{ + Name: &models.StringCriterionInput{ + Value: studio2Name, + Modifier: models.CriterionModifierEquals, + }, }, }, } @@ -90,10 +92,12 @@ func TestStudioQueryNameAndUrl(t *testing.T) { Value: studioName, Modifier: models.CriterionModifierEquals, }, - And: &models.StudioFilterType{ - URL: &models.StringCriterionInput{ - Value: studioUrl, - Modifier: models.CriterionModifierEquals, + OperatorFilter: models.OperatorFilter[models.StudioFilterType]{ + And: &models.StudioFilterType{ + URL: &models.StringCriterionInput{ + Value: studioUrl, + Modifier: models.CriterionModifierEquals, + }, }, }, } @@ -128,8 +132,10 @@ func TestStudioQueryNameNotUrl(t *testing.T) { studioFilter := models.StudioFilterType{ Name: &nameCriterion, - Not: &models.StudioFilterType{ - URL: &urlCriterion, + OperatorFilter: models.OperatorFilter[models.StudioFilterType]{ + Not: &models.StudioFilterType{ + URL: &urlCriterion, + }, }, } @@ -160,8 +166,10 @@ func TestStudioIllegalQuery(t *testing.T) { } studioFilter := &models.StudioFilterType{ - And: &subFilter, - Or: &subFilter, + OperatorFilter: models.OperatorFilter[models.StudioFilterType]{ + And: &subFilter, + Or: &subFilter, + }, } withTxn(func(ctx context.Context) error { diff --git a/pkg/sqlite/table.go b/pkg/sqlite/table.go index a04504281b0..2aa5b77b6c3 100644 --- a/pkg/sqlite/table.go +++ b/pkg/sqlite/table.go @@ -193,8 +193,7 @@ func (t *joinTable) insertJoins(ctx context.Context, id int, foreignIDs []int) e // ignore duplicates q := fmt.Sprintf("INSERT INTO %s (%s, %s) VALUES (?, ?) ON CONFLICT (%[2]s, %s) DO NOTHING", t.table.table.GetTable(), t.idColumn.GetCol(), t.fkColumn.GetCol()) - tx := dbWrapper{} - stmt, err := tx.Prepare(ctx, q) + stmt, err := dbWrapper.Prepare(ctx, q) if err != nil { return err } @@ -204,7 +203,7 @@ func (t *joinTable) insertJoins(ctx context.Context, id int, foreignIDs []int) e foreignIDs = sliceutil.AppendUniques(nil, foreignIDs) for _, fk := range foreignIDs { - if _, err := tx.ExecStmt(ctx, stmt, id, fk); err != nil { + if _, err := dbWrapper.ExecStmt(ctx, stmt, id, fk); err != nil { return err } } @@ -1077,8 +1076,7 @@ func queryFunc(ctx context.Context, query *goqu.SelectDataset, single bool, f fu return err } - wrapper := dbWrapper{} - rows, err := wrapper.QueryxContext(ctx, q, args...) + rows, err := dbWrapper.QueryxContext(ctx, q, args...) if err != nil && !errors.Is(err, sql.ErrNoRows) { return fmt.Errorf("querying `%s` [%v]: %w", q, args, err) @@ -1107,8 +1105,7 @@ func querySimple(ctx context.Context, query *goqu.SelectDataset, out interface{} return err } - wrapper := dbWrapper{} - rows, err := wrapper.QueryxContext(ctx, q, args...) + rows, err := dbWrapper.QueryxContext(ctx, q, args...) if err != nil { return fmt.Errorf("querying `%s` [%v]: %w", q, args, err) } diff --git a/pkg/sqlite/tag.go b/pkg/sqlite/tag.go index cfed64bfce7..99cc42edcdf 100644 --- a/pkg/sqlite/tag.go +++ b/pkg/sqlite/tag.go @@ -90,19 +90,64 @@ func (r *tagRowRecord) fromPartial(o models.TagPartial) { r.setTimestamp("updated_at", o.UpdatedAt) } -type TagStore struct { +type tagRepositoryType struct { repository - blobJoinQueryBuilder - tableMgr *table + aliases stringRepository + + scenes joinRepository + images joinRepository + galleries joinRepository } -func NewTagStore(blobStore *BlobStore) *TagStore { - return &TagStore{ +var ( + tagRepository = tagRepositoryType{ repository: repository{ tableName: tagTable, idColumn: idColumn, }, + aliases: stringRepository{ + repository: repository{ + tableName: tagAliasesTable, + idColumn: tagIDColumn, + }, + stringColumn: tagAliasColumn, + }, + scenes: joinRepository{ + repository: repository{ + tableName: scenesTagsTable, + idColumn: tagIDColumn, + }, + fkColumn: sceneIDColumn, + foreignTable: sceneTable, + }, + images: joinRepository{ + repository: repository{ + tableName: imagesTagsTable, + idColumn: tagIDColumn, + }, + fkColumn: imageIDColumn, + foreignTable: imageTable, + }, + galleries: joinRepository{ + repository: repository{ + tableName: galleriesTagsTable, + idColumn: tagIDColumn, + }, + fkColumn: galleryIDColumn, + foreignTable: galleryTable, + }, + } +) + +type TagStore struct { + blobJoinQueryBuilder + + tableMgr *table +} + +func NewTagStore(blobStore *BlobStore) *TagStore { + return &TagStore{ blobJoinQueryBuilder: blobJoinQueryBuilder{ blobStore: blobStore, joinTable: tagTable, @@ -176,7 +221,7 @@ func (qb *TagStore) Destroy(ctx context.Context, id int) error { // cannot unset primary_tag_id in scene_markers because it is not nullable countQuery := "SELECT COUNT(*) as count FROM scene_markers where primary_tag_id = ?" args := []interface{}{id} - primaryMarkers, err := qb.runCountQuery(ctx, countQuery, args) + primaryMarkers, err := tagRepository.runCountQuery(ctx, countQuery, args) if err != nil { return err } @@ -185,7 +230,7 @@ func (qb *TagStore) Destroy(ctx context.Context, id int) error { return errors.New("cannot delete tag used as a primary tag in scene markers") } - return qb.destroyExisting(ctx, []int{id}) + return tagRepository.destroyExisting(ctx, []int{id}) } // returns nil, nil if not found @@ -455,73 +500,6 @@ func (qb *TagStore) QueryForAutoTag(ctx context.Context, words []string) ([]*mod return qb.queryTags(ctx, query+" WHERE "+where, args) } -func (qb *TagStore) validateFilter(tagFilter *models.TagFilterType) error { - const and = "AND" - const or = "OR" - const not = "NOT" - - if tagFilter.And != nil { - if tagFilter.Or != nil { - return illegalFilterCombination(and, or) - } - if tagFilter.Not != nil { - return illegalFilterCombination(and, not) - } - - return qb.validateFilter(tagFilter.And) - } - - if tagFilter.Or != nil { - if tagFilter.Not != nil { - return illegalFilterCombination(or, not) - } - - return qb.validateFilter(tagFilter.Or) - } - - if tagFilter.Not != nil { - return qb.validateFilter(tagFilter.Not) - } - - return nil -} - -func (qb *TagStore) makeFilter(ctx context.Context, tagFilter *models.TagFilterType) *filterBuilder { - query := &filterBuilder{} - - if tagFilter.And != nil { - query.and(qb.makeFilter(ctx, tagFilter.And)) - } - if tagFilter.Or != nil { - query.or(qb.makeFilter(ctx, tagFilter.Or)) - } - if tagFilter.Not != nil { - query.not(qb.makeFilter(ctx, tagFilter.Not)) - } - - query.handleCriterion(ctx, stringCriterionHandler(tagFilter.Name, tagTable+".name")) - query.handleCriterion(ctx, tagAliasCriterionHandler(qb, tagFilter.Aliases)) - - query.handleCriterion(ctx, boolCriterionHandler(tagFilter.Favorite, tagTable+".favorite", nil)) - 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)) - query.handleCriterion(ctx, tagSceneCountCriterionHandler(qb, tagFilter.SceneCount)) - query.handleCriterion(ctx, tagImageCountCriterionHandler(qb, tagFilter.ImageCount)) - query.handleCriterion(ctx, tagGalleryCountCriterionHandler(qb, tagFilter.GalleryCount)) - query.handleCriterion(ctx, tagPerformerCountCriterionHandler(qb, tagFilter.PerformerCount)) - query.handleCriterion(ctx, tagMarkerCountCriterionHandler(qb, tagFilter.MarkerCount)) - query.handleCriterion(ctx, tagParentsCriterionHandler(qb, tagFilter.Parents)) - 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 -} - func (qb *TagStore) Query(ctx context.Context, tagFilter *models.TagFilterType, findFilter *models.FindFilterType) ([]*models.Tag, int, error) { if tagFilter == nil { tagFilter = &models.TagFilterType{} @@ -530,7 +508,7 @@ func (qb *TagStore) Query(ctx context.Context, tagFilter *models.TagFilterType, findFilter = &models.FindFilterType{} } - query := qb.newQuery() + query := tagRepository.newQuery() distinctIDs(&query, tagTable) if q := findFilter.Q; q != nil && *q != "" { @@ -539,10 +517,9 @@ func (qb *TagStore) Query(ctx context.Context, tagFilter *models.TagFilterType, query.parseQueryString(searchColumns, *q) } - if err := qb.validateFilter(tagFilter); err != nil { - return nil, 0, err - } - filter := qb.makeFilter(ctx, tagFilter) + filter := filterBuilderFromHandler(ctx, &tagFilterHandler{ + tagFilter: tagFilter, + }) if err := query.addFilter(filter); err != nil { return nil, 0, err @@ -567,297 +544,6 @@ func (qb *TagStore) Query(ctx context.Context, tagFilter *models.TagFilterType, return tags, countResult, nil } -func tagAliasCriterionHandler(qb *TagStore, alias *models.StringCriterionInput) criterionHandlerFunc { - h := stringListCriterionHandlerBuilder{ - joinTable: tagAliasesTable, - stringColumn: tagAliasColumn, - addJoinTable: func(f *filterBuilder) { - qb.aliasRepository().join(f, "", "tags.id") - }, - } - - return h.handler(alias) -} - -func tagIsMissingCriterionHandler(qb *TagStore, isMissing *string) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if isMissing != nil && *isMissing != "" { - switch *isMissing { - case "image": - f.addWhere("tags.image_blob IS NULL") - default: - f.addWhere("(tags." + *isMissing + " IS NULL OR TRIM(tags." + *isMissing + ") = '')") - } - } - } -} - -func tagSceneCountCriterionHandler(qb *TagStore, sceneCount *models.IntCriterionInput) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if sceneCount != nil { - f.addLeftJoin("scenes_tags", "", "scenes_tags.tag_id = tags.id") - clause, args := getIntCriterionWhereClause("count(distinct scenes_tags.scene_id)", *sceneCount) - - f.addHaving(clause, args...) - } - } -} - -func tagImageCountCriterionHandler(qb *TagStore, imageCount *models.IntCriterionInput) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if imageCount != nil { - f.addLeftJoin("images_tags", "", "images_tags.tag_id = tags.id") - clause, args := getIntCriterionWhereClause("count(distinct images_tags.image_id)", *imageCount) - - f.addHaving(clause, args...) - } - } -} - -func tagGalleryCountCriterionHandler(qb *TagStore, galleryCount *models.IntCriterionInput) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if galleryCount != nil { - f.addLeftJoin("galleries_tags", "", "galleries_tags.tag_id = tags.id") - clause, args := getIntCriterionWhereClause("count(distinct galleries_tags.gallery_id)", *galleryCount) - - f.addHaving(clause, args...) - } - } -} - -func tagPerformerCountCriterionHandler(qb *TagStore, performerCount *models.IntCriterionInput) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if performerCount != nil { - f.addLeftJoin("performers_tags", "", "performers_tags.tag_id = tags.id") - clause, args := getIntCriterionWhereClause("count(distinct performers_tags.performer_id)", *performerCount) - - f.addHaving(clause, args...) - } - } -} - -func tagMarkerCountCriterionHandler(qb *TagStore, markerCount *models.IntCriterionInput) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if markerCount != nil { - f.addLeftJoin("scene_markers_tags", "", "scene_markers_tags.tag_id = tags.id") - f.addLeftJoin("scene_markers", "", "scene_markers_tags.scene_marker_id = scene_markers.id OR scene_markers.primary_tag_id = tags.id") - clause, args := getIntCriterionWhereClause("count(distinct scene_markers.id)", *markerCount) - - f.addHaving(clause, args...) - } - } -} - -func tagParentsCriterionHandler(qb *TagStore, criterion *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if criterion != nil { - tags := criterion.CombineExcludes() - - // validate the modifier - switch tags.Modifier { - case models.CriterionModifierIncludesAll, models.CriterionModifierIncludes, models.CriterionModifierExcludes, models.CriterionModifierIsNull, models.CriterionModifierNotNull: - // valid - default: - f.setError(fmt.Errorf("invalid modifier %s for tag parent/children", criterion.Modifier)) - } - - if tags.Modifier == models.CriterionModifierIsNull || tags.Modifier == models.CriterionModifierNotNull { - var notClause string - if tags.Modifier == models.CriterionModifierNotNull { - notClause = "NOT" - } - - f.addLeftJoin("tags_relations", "parent_relations", "tags.id = parent_relations.child_id") - - f.addWhere(fmt.Sprintf("parent_relations.parent_id IS %s NULL", notClause)) - return - } - - if len(tags.Value) == 0 && len(tags.Excludes) == 0 { - return - } - - if len(tags.Value) > 0 { - var args []interface{} - for _, val := range tags.Value { - args = append(args, val) - } - - depthVal := 0 - if tags.Depth != nil { - depthVal = *tags.Depth - } - - var depthCondition string - if depthVal != -1 { - depthCondition = fmt.Sprintf("WHERE depth < %d", depthVal) - } - - query := `parents AS ( - SELECT parent_id AS root_id, child_id AS item_id, 0 AS depth FROM tags_relations WHERE parent_id IN` + getInBinding(len(tags.Value)) + ` - UNION - SELECT root_id, child_id, depth + 1 FROM tags_relations INNER JOIN parents ON item_id = parent_id ` + depthCondition + ` - )` - - f.addRecursiveWith(query, args...) - - f.addLeftJoin("parents", "", "parents.item_id = tags.id") - - addHierarchicalConditionClauses(f, tags, "parents", "root_id") - } - - if len(tags.Excludes) > 0 { - var args []interface{} - for _, val := range tags.Excludes { - args = append(args, val) - } - - depthVal := 0 - if tags.Depth != nil { - depthVal = *tags.Depth - } - - var depthCondition string - if depthVal != -1 { - depthCondition = fmt.Sprintf("WHERE depth < %d", depthVal) - } - - query := `parents2 AS ( - SELECT parent_id AS root_id, child_id AS item_id, 0 AS depth FROM tags_relations WHERE parent_id IN` + getInBinding(len(tags.Excludes)) + ` - UNION - SELECT root_id, child_id, depth + 1 FROM tags_relations INNER JOIN parents2 ON item_id = parent_id ` + depthCondition + ` - )` - - f.addRecursiveWith(query, args...) - - f.addLeftJoin("parents2", "", "parents2.item_id = tags.id") - - addHierarchicalConditionClauses(f, models.HierarchicalMultiCriterionInput{ - Value: tags.Excludes, - Depth: tags.Depth, - Modifier: models.CriterionModifierExcludes, - }, "parents2", "root_id") - } - } - } -} - -func tagChildrenCriterionHandler(qb *TagStore, criterion *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if criterion != nil { - tags := criterion.CombineExcludes() - - // validate the modifier - switch tags.Modifier { - case models.CriterionModifierIncludesAll, models.CriterionModifierIncludes, models.CriterionModifierExcludes, models.CriterionModifierIsNull, models.CriterionModifierNotNull: - // valid - default: - f.setError(fmt.Errorf("invalid modifier %s for tag parent/children", criterion.Modifier)) - } - - if tags.Modifier == models.CriterionModifierIsNull || tags.Modifier == models.CriterionModifierNotNull { - var notClause string - if tags.Modifier == models.CriterionModifierNotNull { - notClause = "NOT" - } - - f.addLeftJoin("tags_relations", "child_relations", "tags.id = child_relations.parent_id") - - f.addWhere(fmt.Sprintf("child_relations.child_id IS %s NULL", notClause)) - return - } - - if len(tags.Value) == 0 && len(tags.Excludes) == 0 { - return - } - - if len(tags.Value) > 0 { - var args []interface{} - for _, val := range tags.Value { - args = append(args, val) - } - - depthVal := 0 - if tags.Depth != nil { - depthVal = *tags.Depth - } - - var depthCondition string - if depthVal != -1 { - depthCondition = fmt.Sprintf("WHERE depth < %d", depthVal) - } - - query := `children AS ( - SELECT child_id AS root_id, parent_id AS item_id, 0 AS depth FROM tags_relations WHERE child_id IN` + getInBinding(len(tags.Value)) + ` - UNION - SELECT root_id, parent_id, depth + 1 FROM tags_relations INNER JOIN children ON item_id = child_id ` + depthCondition + ` - )` - - f.addRecursiveWith(query, args...) - - f.addLeftJoin("children", "", "children.item_id = tags.id") - - addHierarchicalConditionClauses(f, tags, "children", "root_id") - } - - if len(tags.Excludes) > 0 { - var args []interface{} - for _, val := range tags.Excludes { - args = append(args, val) - } - - depthVal := 0 - if tags.Depth != nil { - depthVal = *tags.Depth - } - - var depthCondition string - if depthVal != -1 { - depthCondition = fmt.Sprintf("WHERE depth < %d", depthVal) - } - - query := `children2 AS ( - SELECT child_id AS root_id, parent_id AS item_id, 0 AS depth FROM tags_relations WHERE child_id IN` + getInBinding(len(tags.Excludes)) + ` - UNION - SELECT root_id, parent_id, depth + 1 FROM tags_relations INNER JOIN children2 ON item_id = child_id ` + depthCondition + ` - )` - - f.addRecursiveWith(query, args...) - - f.addLeftJoin("children2", "", "children2.item_id = tags.id") - - addHierarchicalConditionClauses(f, models.HierarchicalMultiCriterionInput{ - Value: tags.Excludes, - Depth: tags.Depth, - Modifier: models.CriterionModifierExcludes, - }, "children2", "root_id") - } - } - } -} - -func tagParentCountCriterionHandler(qb *TagStore, parentCount *models.IntCriterionInput) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if parentCount != nil { - f.addLeftJoin("tags_relations", "parents_count", "parents_count.child_id = tags.id") - clause, args := getIntCriterionWhereClause("count(distinct parents_count.parent_id)", *parentCount) - - f.addHaving(clause, args...) - } - } -} - -func tagChildCountCriterionHandler(qb *TagStore, childCount *models.IntCriterionInput) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if childCount != nil { - f.addLeftJoin("tags_relations", "children_count", "children_count.parent_id = tags.id") - clause, args := getIntCriterionWhereClause("count(distinct children_count.child_id)", *childCount) - - f.addHaving(clause, args...) - } - } -} - var tagSortOptions = sortOptions{ "created_at", "galleries_count", @@ -915,7 +601,7 @@ func (qb *TagStore) getTagSort(query *queryBuilder, findFilter *models.FindFilte func (qb *TagStore) queryTags(ctx context.Context, query string, args []interface{}) ([]*models.Tag, error) { const single = false var ret []*models.Tag - if err := qb.queryFunc(ctx, query, args, single, func(r *sqlx.Rows) error { + if err := tagRepository.queryFunc(ctx, query, args, single, func(r *sqlx.Rows) error { var f tagRow if err := r.StructScan(&f); err != nil { return err @@ -935,7 +621,7 @@ func (qb *TagStore) queryTags(ctx context.Context, query string, args []interfac func (qb *TagStore) queryTagPaths(ctx context.Context, query string, args []interface{}) ([]*models.TagPath, error) { const single = false var ret []*models.TagPath - if err := qb.queryFunc(ctx, query, args, single, func(r *sqlx.Rows) error { + if err := tagRepository.queryFunc(ctx, query, args, single, func(r *sqlx.Rows) error { var f tagPathRow if err := r.StructScan(&f); err != nil { return err @@ -968,23 +654,12 @@ func (qb *TagStore) destroyImage(ctx context.Context, tagID int) error { return qb.blobJoinQueryBuilder.DestroyImage(ctx, tagID, tagImageBlobColumn) } -func (qb *TagStore) aliasRepository() *stringRepository { - return &stringRepository{ - repository: repository{ - tx: qb.tx, - tableName: tagAliasesTable, - idColumn: tagIDColumn, - }, - stringColumn: tagAliasColumn, - } -} - func (qb *TagStore) GetAliases(ctx context.Context, tagID int) ([]string, error) { - return qb.aliasRepository().get(ctx, tagID) + return tagRepository.aliases.get(ctx, tagID) } func (qb *TagStore) UpdateAliases(ctx context.Context, tagID int, aliases []string) error { - return qb.aliasRepository().replace(ctx, tagID, aliases) + return tagRepository.aliases.replace(ctx, tagID, aliases) } func (qb *TagStore) Merge(ctx context.Context, source []int, destination int) error { @@ -1015,7 +690,7 @@ func (qb *TagStore) Merge(ctx context.Context, source []int, destination int) er args = append(args, destination) for table, idColumn := range tagTables { - _, err := qb.tx.Exec(ctx, `UPDATE OR IGNORE `+table+` + _, err := dbWrapper.Exec(ctx, `UPDATE OR IGNORE `+table+` SET tag_id = ? WHERE tag_id IN `+inBinding+` AND NOT EXISTS(SELECT 1 FROM `+table+` o WHERE o.`+idColumn+` = `+table+`.`+idColumn+` AND o.tag_id = ?)`, @@ -1026,22 +701,22 @@ AND NOT EXISTS(SELECT 1 FROM `+table+` o WHERE o.`+idColumn+` = `+table+`.`+idCo } // delete source tag ids from the table where they couldn't be set - if _, err := qb.tx.Exec(ctx, `DELETE FROM `+table+` WHERE tag_id IN `+inBinding, srcArgs...); err != nil { + if _, err := dbWrapper.Exec(ctx, `DELETE FROM `+table+` WHERE tag_id IN `+inBinding, srcArgs...); err != nil { return err } } - _, err := qb.tx.Exec(ctx, "UPDATE "+sceneMarkerTable+" SET primary_tag_id = ? WHERE primary_tag_id IN "+inBinding, args...) + _, err := dbWrapper.Exec(ctx, "UPDATE "+sceneMarkerTable+" SET primary_tag_id = ? WHERE primary_tag_id IN "+inBinding, args...) if err != nil { return err } - _, err = qb.tx.Exec(ctx, "INSERT INTO "+tagAliasesTable+" (tag_id, alias) SELECT ?, name FROM "+tagTable+" WHERE id IN "+inBinding, args...) + _, err = dbWrapper.Exec(ctx, "INSERT INTO "+tagAliasesTable+" (tag_id, alias) SELECT ?, name FROM "+tagTable+" WHERE id IN "+inBinding, args...) if err != nil { return err } - _, err = qb.tx.Exec(ctx, "UPDATE "+tagAliasesTable+" SET tag_id = ? WHERE tag_id IN "+inBinding, args...) + _, err = dbWrapper.Exec(ctx, "UPDATE "+tagAliasesTable+" SET tag_id = ? WHERE tag_id IN "+inBinding, args...) if err != nil { return err } @@ -1057,8 +732,7 @@ AND NOT EXISTS(SELECT 1 FROM `+table+` o WHERE o.`+idColumn+` = `+table+`.`+idCo } func (qb *TagStore) UpdateParentTags(ctx context.Context, tagID int, parentIDs []int) error { - tx := qb.tx - if _, err := tx.Exec(ctx, "DELETE FROM tags_relations WHERE child_id = ?", tagID); err != nil { + if _, err := dbWrapper.Exec(ctx, "DELETE FROM tags_relations WHERE child_id = ?", tagID); err != nil { return err } @@ -1071,7 +745,7 @@ func (qb *TagStore) UpdateParentTags(ctx context.Context, tagID int, parentIDs [ } query := "INSERT INTO tags_relations (parent_id, child_id) VALUES " + strings.Join(values, ", ") - if _, err := tx.Exec(ctx, query, args...); err != nil { + if _, err := dbWrapper.Exec(ctx, query, args...); err != nil { return err } } @@ -1080,8 +754,7 @@ func (qb *TagStore) UpdateParentTags(ctx context.Context, tagID int, parentIDs [ } func (qb *TagStore) UpdateChildTags(ctx context.Context, tagID int, childIDs []int) error { - tx := qb.tx - if _, err := tx.Exec(ctx, "DELETE FROM tags_relations WHERE parent_id = ?", tagID); err != nil { + if _, err := dbWrapper.Exec(ctx, "DELETE FROM tags_relations WHERE parent_id = ?", tagID); err != nil { return err } @@ -1094,7 +767,7 @@ func (qb *TagStore) UpdateChildTags(ctx context.Context, tagID int, childIDs []i } query := "INSERT INTO tags_relations (parent_id, child_id) VALUES " + strings.Join(values, ", ") - if _, err := tx.Exec(ctx, query, args...); err != nil { + if _, err := dbWrapper.Exec(ctx, query, args...); err != nil { return err } } diff --git a/pkg/sqlite/tag_filter.go b/pkg/sqlite/tag_filter.go new file mode 100644 index 00000000000..a628a073503 --- /dev/null +++ b/pkg/sqlite/tag_filter.go @@ -0,0 +1,395 @@ +package sqlite + +import ( + "context" + "fmt" + + "github.com/stashapp/stash/pkg/models" +) + +type tagFilterHandler struct { + tagFilter *models.TagFilterType +} + +func (qb *tagFilterHandler) validate() error { + tagFilter := qb.tagFilter + if tagFilter == nil { + return nil + } + + if err := validateFilterCombination(tagFilter.OperatorFilter); err != nil { + return err + } + + if subFilter := tagFilter.SubFilter(); subFilter != nil { + sqb := &tagFilterHandler{tagFilter: subFilter} + if err := sqb.validate(); err != nil { + return err + } + } + + return nil +} + +func (qb *tagFilterHandler) handle(ctx context.Context, f *filterBuilder) { + tagFilter := qb.tagFilter + if tagFilter == nil { + return + } + + if err := qb.validate(); err != nil { + f.setError(err) + return + } + + sf := tagFilter.SubFilter() + if sf != nil { + sub := &tagFilterHandler{sf} + handleSubFilter(ctx, sub, f, tagFilter.OperatorFilter) + } + + f.handleCriterion(ctx, qb.criterionHandler()) +} + +func (qb *tagFilterHandler) criterionHandler() criterionHandler { + tagFilter := qb.tagFilter + return compoundHandler{ + stringCriterionHandler(tagFilter.Name, tagTable+".name"), + qb.aliasCriterionHandler(tagFilter.Aliases), + + boolCriterionHandler(tagFilter.Favorite, tagTable+".favorite", nil), + stringCriterionHandler(tagFilter.Description, tagTable+".description"), + boolCriterionHandler(tagFilter.IgnoreAutoTag, tagTable+".ignore_auto_tag", nil), + + qb.isMissingCriterionHandler(tagFilter.IsMissing), + qb.sceneCountCriterionHandler(tagFilter.SceneCount), + qb.imageCountCriterionHandler(tagFilter.ImageCount), + qb.galleryCountCriterionHandler(tagFilter.GalleryCount), + qb.performerCountCriterionHandler(tagFilter.PerformerCount), + qb.markerCountCriterionHandler(tagFilter.MarkerCount), + qb.parentsCriterionHandler(tagFilter.Parents), + qb.childrenCriterionHandler(tagFilter.Children), + qb.parentCountCriterionHandler(tagFilter.ParentCount), + qb.childCountCriterionHandler(tagFilter.ChildCount), + ×tampCriterionHandler{tagFilter.CreatedAt, "tags.created_at", nil}, + ×tampCriterionHandler{tagFilter.UpdatedAt, "tags.updated_at", nil}, + + &relatedFilterHandler{ + relatedIDCol: "scenes_tags.scene_id", + relatedRepo: sceneRepository.repository, + relatedHandler: &sceneFilterHandler{tagFilter.ScenesFilter}, + joinFn: func(f *filterBuilder) { + tagRepository.scenes.innerJoin(f, "", "tags.id") + }, + }, + + &relatedFilterHandler{ + relatedIDCol: "images_tags.image_id", + relatedRepo: imageRepository.repository, + relatedHandler: &imageFilterHandler{tagFilter.ImagesFilter}, + joinFn: func(f *filterBuilder) { + tagRepository.images.innerJoin(f, "", "tags.id") + }, + }, + + &relatedFilterHandler{ + relatedIDCol: "galleries_tags.gallery_id", + relatedRepo: galleryRepository.repository, + relatedHandler: &galleryFilterHandler{tagFilter.GalleriesFilter}, + joinFn: func(f *filterBuilder) { + tagRepository.galleries.innerJoin(f, "", "tags.id") + }, + }, + } +} + +func (qb *tagFilterHandler) aliasCriterionHandler(alias *models.StringCriterionInput) criterionHandlerFunc { + h := stringListCriterionHandlerBuilder{ + joinTable: tagAliasesTable, + stringColumn: tagAliasColumn, + addJoinTable: func(f *filterBuilder) { + tagRepository.aliases.join(f, "", "tags.id") + }, + } + + return h.handler(alias) +} + +func (qb *tagFilterHandler) isMissingCriterionHandler(isMissing *string) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if isMissing != nil && *isMissing != "" { + switch *isMissing { + case "image": + f.addWhere("tags.image_blob IS NULL") + default: + f.addWhere("(tags." + *isMissing + " IS NULL OR TRIM(tags." + *isMissing + ") = '')") + } + } + } +} + +func (qb *tagFilterHandler) sceneCountCriterionHandler(sceneCount *models.IntCriterionInput) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if sceneCount != nil { + f.addLeftJoin("scenes_tags", "", "scenes_tags.tag_id = tags.id") + clause, args := getIntCriterionWhereClause("count(distinct scenes_tags.scene_id)", *sceneCount) + + f.addHaving(clause, args...) + } + } +} + +func (qb *tagFilterHandler) imageCountCriterionHandler(imageCount *models.IntCriterionInput) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if imageCount != nil { + f.addLeftJoin("images_tags", "", "images_tags.tag_id = tags.id") + clause, args := getIntCriterionWhereClause("count(distinct images_tags.image_id)", *imageCount) + + f.addHaving(clause, args...) + } + } +} + +func (qb *tagFilterHandler) galleryCountCriterionHandler(galleryCount *models.IntCriterionInput) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if galleryCount != nil { + f.addLeftJoin("galleries_tags", "", "galleries_tags.tag_id = tags.id") + clause, args := getIntCriterionWhereClause("count(distinct galleries_tags.gallery_id)", *galleryCount) + + f.addHaving(clause, args...) + } + } +} + +func (qb *tagFilterHandler) performerCountCriterionHandler(performerCount *models.IntCriterionInput) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if performerCount != nil { + f.addLeftJoin("performers_tags", "", "performers_tags.tag_id = tags.id") + clause, args := getIntCriterionWhereClause("count(distinct performers_tags.performer_id)", *performerCount) + + f.addHaving(clause, args...) + } + } +} + +func (qb *tagFilterHandler) markerCountCriterionHandler(markerCount *models.IntCriterionInput) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if markerCount != nil { + f.addLeftJoin("scene_markers_tags", "", "scene_markers_tags.tag_id = tags.id") + f.addLeftJoin("scene_markers", "", "scene_markers_tags.scene_marker_id = scene_markers.id OR scene_markers.primary_tag_id = tags.id") + clause, args := getIntCriterionWhereClause("count(distinct scene_markers.id)", *markerCount) + + f.addHaving(clause, args...) + } + } +} + +func (qb *tagFilterHandler) parentsCriterionHandler(criterion *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if criterion != nil { + tags := criterion.CombineExcludes() + + // validate the modifier + switch tags.Modifier { + case models.CriterionModifierIncludesAll, models.CriterionModifierIncludes, models.CriterionModifierExcludes, models.CriterionModifierIsNull, models.CriterionModifierNotNull: + // valid + default: + f.setError(fmt.Errorf("invalid modifier %s for tag parent/children", criterion.Modifier)) + } + + if tags.Modifier == models.CriterionModifierIsNull || tags.Modifier == models.CriterionModifierNotNull { + var notClause string + if tags.Modifier == models.CriterionModifierNotNull { + notClause = "NOT" + } + + f.addLeftJoin("tags_relations", "parent_relations", "tags.id = parent_relations.child_id") + + f.addWhere(fmt.Sprintf("parent_relations.parent_id IS %s NULL", notClause)) + return + } + + if len(tags.Value) == 0 && len(tags.Excludes) == 0 { + return + } + + if len(tags.Value) > 0 { + var args []interface{} + for _, val := range tags.Value { + args = append(args, val) + } + + depthVal := 0 + if tags.Depth != nil { + depthVal = *tags.Depth + } + + var depthCondition string + if depthVal != -1 { + depthCondition = fmt.Sprintf("WHERE depth < %d", depthVal) + } + + query := `parents AS ( + SELECT parent_id AS root_id, child_id AS item_id, 0 AS depth FROM tags_relations WHERE parent_id IN` + getInBinding(len(tags.Value)) + ` + UNION + SELECT root_id, child_id, depth + 1 FROM tags_relations INNER JOIN parents ON item_id = parent_id ` + depthCondition + ` + )` + + f.addRecursiveWith(query, args...) + + f.addLeftJoin("parents", "", "parents.item_id = tags.id") + + addHierarchicalConditionClauses(f, tags, "parents", "root_id") + } + + if len(tags.Excludes) > 0 { + var args []interface{} + for _, val := range tags.Excludes { + args = append(args, val) + } + + depthVal := 0 + if tags.Depth != nil { + depthVal = *tags.Depth + } + + var depthCondition string + if depthVal != -1 { + depthCondition = fmt.Sprintf("WHERE depth < %d", depthVal) + } + + query := `parents2 AS ( + SELECT parent_id AS root_id, child_id AS item_id, 0 AS depth FROM tags_relations WHERE parent_id IN` + getInBinding(len(tags.Excludes)) + ` + UNION + SELECT root_id, child_id, depth + 1 FROM tags_relations INNER JOIN parents2 ON item_id = parent_id ` + depthCondition + ` + )` + + f.addRecursiveWith(query, args...) + + f.addLeftJoin("parents2", "", "parents2.item_id = tags.id") + + addHierarchicalConditionClauses(f, models.HierarchicalMultiCriterionInput{ + Value: tags.Excludes, + Depth: tags.Depth, + Modifier: models.CriterionModifierExcludes, + }, "parents2", "root_id") + } + } + } +} + +func (qb *tagFilterHandler) childrenCriterionHandler(criterion *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if criterion != nil { + tags := criterion.CombineExcludes() + + // validate the modifier + switch tags.Modifier { + case models.CriterionModifierIncludesAll, models.CriterionModifierIncludes, models.CriterionModifierExcludes, models.CriterionModifierIsNull, models.CriterionModifierNotNull: + // valid + default: + f.setError(fmt.Errorf("invalid modifier %s for tag parent/children", criterion.Modifier)) + } + + if tags.Modifier == models.CriterionModifierIsNull || tags.Modifier == models.CriterionModifierNotNull { + var notClause string + if tags.Modifier == models.CriterionModifierNotNull { + notClause = "NOT" + } + + f.addLeftJoin("tags_relations", "child_relations", "tags.id = child_relations.parent_id") + + f.addWhere(fmt.Sprintf("child_relations.child_id IS %s NULL", notClause)) + return + } + + if len(tags.Value) == 0 && len(tags.Excludes) == 0 { + return + } + + if len(tags.Value) > 0 { + var args []interface{} + for _, val := range tags.Value { + args = append(args, val) + } + + depthVal := 0 + if tags.Depth != nil { + depthVal = *tags.Depth + } + + var depthCondition string + if depthVal != -1 { + depthCondition = fmt.Sprintf("WHERE depth < %d", depthVal) + } + + query := `children AS ( + SELECT child_id AS root_id, parent_id AS item_id, 0 AS depth FROM tags_relations WHERE child_id IN` + getInBinding(len(tags.Value)) + ` + UNION + SELECT root_id, parent_id, depth + 1 FROM tags_relations INNER JOIN children ON item_id = child_id ` + depthCondition + ` + )` + + f.addRecursiveWith(query, args...) + + f.addLeftJoin("children", "", "children.item_id = tags.id") + + addHierarchicalConditionClauses(f, tags, "children", "root_id") + } + + if len(tags.Excludes) > 0 { + var args []interface{} + for _, val := range tags.Excludes { + args = append(args, val) + } + + depthVal := 0 + if tags.Depth != nil { + depthVal = *tags.Depth + } + + var depthCondition string + if depthVal != -1 { + depthCondition = fmt.Sprintf("WHERE depth < %d", depthVal) + } + + query := `children2 AS ( + SELECT child_id AS root_id, parent_id AS item_id, 0 AS depth FROM tags_relations WHERE child_id IN` + getInBinding(len(tags.Excludes)) + ` + UNION + SELECT root_id, parent_id, depth + 1 FROM tags_relations INNER JOIN children2 ON item_id = child_id ` + depthCondition + ` + )` + + f.addRecursiveWith(query, args...) + + f.addLeftJoin("children2", "", "children2.item_id = tags.id") + + addHierarchicalConditionClauses(f, models.HierarchicalMultiCriterionInput{ + Value: tags.Excludes, + Depth: tags.Depth, + Modifier: models.CriterionModifierExcludes, + }, "children2", "root_id") + } + } + } +} + +func (qb *tagFilterHandler) parentCountCriterionHandler(parentCount *models.IntCriterionInput) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if parentCount != nil { + f.addLeftJoin("tags_relations", "parents_count", "parents_count.child_id = tags.id") + clause, args := getIntCriterionWhereClause("count(distinct parents_count.parent_id)", *parentCount) + + f.addHaving(clause, args...) + } + } +} + +func (qb *tagFilterHandler) childCountCriterionHandler(childCount *models.IntCriterionInput) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if childCount != nil { + f.addLeftJoin("tags_relations", "children_count", "children_count.parent_id = tags.id") + clause, args := getIntCriterionWhereClause("count(distinct children_count.child_id)", *childCount) + + f.addHaving(clause, args...) + } + } +} diff --git a/pkg/sqlite/tx.go b/pkg/sqlite/tx.go index 64df163a0b6..a2e272aa9f3 100644 --- a/pkg/sqlite/tx.go +++ b/pkg/sqlite/tx.go @@ -35,7 +35,9 @@ func logSQL(start time.Time, query string, args ...interface{}) { } } -type dbWrapper struct{} +type dbWrapperType struct{} + +var dbWrapper = dbWrapperType{} func sqlError(err error, sql string, args ...interface{}) error { if err == nil { @@ -45,7 +47,7 @@ func sqlError(err error, sql string, args ...interface{}) error { return fmt.Errorf("error executing `%s` [%v]: %w", sql, args, err) } -func (*dbWrapper) Get(ctx context.Context, dest interface{}, query string, args ...interface{}) error { +func (*dbWrapperType) Get(ctx context.Context, dest interface{}, query string, args ...interface{}) error { tx, err := getDBReader(ctx) if err != nil { return sqlError(err, query, args...) @@ -58,7 +60,7 @@ func (*dbWrapper) Get(ctx context.Context, dest interface{}, query string, args return sqlError(err, query, args...) } -func (*dbWrapper) Select(ctx context.Context, dest interface{}, query string, args ...interface{}) error { +func (*dbWrapperType) Select(ctx context.Context, dest interface{}, query string, args ...interface{}) error { tx, err := getDBReader(ctx) if err != nil { return sqlError(err, query, args...) @@ -71,7 +73,7 @@ func (*dbWrapper) Select(ctx context.Context, dest interface{}, query string, ar return sqlError(err, query, args...) } -func (*dbWrapper) Queryx(ctx context.Context, query string, args ...interface{}) (*sqlx.Rows, error) { +func (*dbWrapperType) Queryx(ctx context.Context, query string, args ...interface{}) (*sqlx.Rows, error) { tx, err := getDBReader(ctx) if err != nil { return nil, sqlError(err, query, args...) @@ -84,7 +86,7 @@ func (*dbWrapper) Queryx(ctx context.Context, query string, args ...interface{}) return ret, sqlError(err, query, args...) } -func (*dbWrapper) QueryxContext(ctx context.Context, query string, args ...interface{}) (*sqlx.Rows, error) { +func (*dbWrapperType) QueryxContext(ctx context.Context, query string, args ...interface{}) (*sqlx.Rows, error) { tx, err := getDBReader(ctx) if err != nil { return nil, sqlError(err, query, args...) @@ -97,7 +99,7 @@ func (*dbWrapper) QueryxContext(ctx context.Context, query string, args ...inter return ret, sqlError(err, query, args...) } -func (*dbWrapper) NamedExec(ctx context.Context, query string, arg interface{}) (sql.Result, error) { +func (*dbWrapperType) NamedExec(ctx context.Context, query string, arg interface{}) (sql.Result, error) { tx, err := getTx(ctx) if err != nil { return nil, sqlError(err, query, arg) @@ -110,7 +112,7 @@ func (*dbWrapper) NamedExec(ctx context.Context, query string, arg interface{}) return ret, sqlError(err, query, arg) } -func (*dbWrapper) Exec(ctx context.Context, query string, args ...interface{}) (sql.Result, error) { +func (*dbWrapperType) Exec(ctx context.Context, query string, args ...interface{}) (sql.Result, error) { tx, err := getTx(ctx) if err != nil { return nil, sqlError(err, query, args...) @@ -124,7 +126,7 @@ func (*dbWrapper) Exec(ctx context.Context, query string, args ...interface{}) ( } // Prepare creates a prepared statement. -func (*dbWrapper) Prepare(ctx context.Context, query string, args ...interface{}) (*stmt, error) { +func (*dbWrapperType) Prepare(ctx context.Context, query string, args ...interface{}) (*stmt, error) { tx, err := getTx(ctx) if err != nil { return nil, sqlError(err, query, args...) @@ -142,7 +144,7 @@ func (*dbWrapper) Prepare(ctx context.Context, query string, args ...interface{} }, nil } -func (*dbWrapper) ExecStmt(ctx context.Context, stmt *stmt, args ...interface{}) (sql.Result, error) { +func (*dbWrapperType) ExecStmt(ctx context.Context, stmt *stmt, args ...interface{}) (sql.Result, error) { _, err := getTx(ctx) if err != nil { return nil, sqlError(err, stmt.query, args...) From 621e890a48a0676e2588c51faac455036eaba2b0 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Tue, 11 Jun 2024 11:35:28 +1000 Subject: [PATCH 005/103] Make pagination more compact (#4882) * Make pagination more compact Support entering page number or clicking from drop down * Fix border radius in dropdown in btn group * Separate page count control --- ui/v2.5/src/components/List/Pagination.tsx | 210 +++++++++++++++------ ui/v2.5/src/components/List/styles.scss | 13 ++ ui/v2.5/src/index.scss | 8 +- ui/v2.5/src/locales/en-GB.json | 1 + 4 files changed, 171 insertions(+), 61 deletions(-) diff --git a/ui/v2.5/src/components/List/Pagination.tsx b/ui/v2.5/src/components/List/Pagination.tsx index 47d59e6cbe0..acca41f28a2 100644 --- a/ui/v2.5/src/components/List/Pagination.tsx +++ b/ui/v2.5/src/components/List/Pagination.tsx @@ -1,6 +1,132 @@ -import React from "react"; -import { Button, ButtonGroup } from "react-bootstrap"; -import { FormattedMessage, FormattedNumber, useIntl } from "react-intl"; +import React, { useEffect, useMemo, useRef, useState } from "react"; +import { + Button, + ButtonGroup, + Dropdown, + Form, + InputGroup, + Overlay, + Popover, +} from "react-bootstrap"; +import { FormattedMessage, useIntl } from "react-intl"; +import useFocus from "src/utils/focus"; +import { Icon } from "../Shared/Icon"; +import { faCheck, faChevronDown } from "@fortawesome/free-solid-svg-icons"; + +const PageCount: React.FC<{ + totalPages: number; + currentPage: number; + onChangePage: (page: number) => void; +}> = ({ totalPages, currentPage, onChangePage }) => { + const intl = useIntl(); + + const currentPageCtrl = useRef(null); + + const [pageInput, pageFocus] = useFocus(); + + const [showSelectPage, setShowSelectPage] = useState(false); + + useEffect(() => { + if (showSelectPage) { + pageFocus(); + } + }, [showSelectPage, pageFocus]); + + const pageOptions = useMemo(() => { + const maxPagesToShow = 10; + const min = Math.max(1, currentPage - maxPagesToShow / 2); + const max = Math.min(min + maxPagesToShow, totalPages); + const pages = []; + for (let i = min; i <= max; i++) { + pages.push(i); + } + return pages; + }, [totalPages, currentPage]); + + function onCustomChangePage() { + const newPage = Number.parseInt(pageInput.current?.value ?? "0"); + if (newPage) { + onChangePage(newPage); + } + setShowSelectPage(false); + } + + return ( +
+ + + + + + + + {pageOptions.map((s) => ( + onChangePage(s)} + > + {s} + + ))} + + + + setShowSelectPage(false)} + > + +
+ + ) => { + if (e.key === "Enter") { + onCustomChangePage(); + e.preventDefault(); + } + }} + onFocus={(e: React.FocusEvent) => + e.target.select() + } + /> + + + + +
+
+
+
+ ); +}; interface IPaginationProps { itemsPerPage: number; @@ -23,91 +149,55 @@ export const Pagination: React.FC = ({ totalItems, onChangePage, }) => { - const totalPages = Math.ceil(totalItems / itemsPerPage); - - let startPage: number; - let endPage: number; - if (totalPages <= 10) { - // less than 10 total pages so show all - startPage = 1; - endPage = totalPages; - } else if (currentPage <= 6) { - startPage = 1; - endPage = 10; - } else if (currentPage + 4 >= totalPages) { - startPage = totalPages - 9; - endPage = totalPages; - } else { - startPage = currentPage - 5; - endPage = currentPage + 4; - } + const intl = useIntl(); - const pages = [...Array(endPage + 1 - startPage).keys()].map( - (i) => startPage + i + const totalPages = useMemo( + () => Math.ceil(totalItems / itemsPerPage), + [totalItems, itemsPerPage] ); - const calculatePageClass = (buttonPage: number) => { - if (pages.length <= 4) return ""; - - if (currentPage === 1 && buttonPage <= 4) return ""; - const maxPage = pages[pages.length - 1]; - if (currentPage === maxPage && buttonPage > maxPage - 3) return ""; - if (Math.abs(buttonPage - currentPage) <= 1) return ""; - return "d-none d-sm-block"; - }; - - const pageButtons = pages.map((page: number) => ( - - )); - - if (pages.length <= 1) return
; + if (totalPages <= 1) return
; return ( - + - {pageButtons} + + + ); diff --git a/ui/v2.5/src/components/List/styles.scss b/ui/v2.5/src/components/List/styles.scss index f67b5b8b33d..1c8d993a309 100644 --- a/ui/v2.5/src/components/List/styles.scss +++ b/ui/v2.5/src/components/List/styles.scss @@ -7,14 +7,27 @@ padding-right: 15px; transition: none; + &.page-count { + padding-right: 5px; + } + + &.page-count-dropdown { + padding-left: 5px; + } + &:first-child { border-left: none; + border-right: none; } &:last-child { border-right: none; } } + + .page-count-container .btn { + border-radius: 0; + } } .center-text { diff --git a/ui/v2.5/src/index.scss b/ui/v2.5/src/index.scss index 73cc4da01e9..2e9587f9632 100755 --- a/ui/v2.5/src/index.scss +++ b/ui/v2.5/src/index.scss @@ -713,7 +713,8 @@ div.dropdown-menu { } .filter-container, -.operation-container { +.operation-container, +.pagination { align-items: center; display: flex; justify-content: center; @@ -1330,6 +1331,11 @@ $detailTabWidth: calc(100% / 3); border-top-right-radius: 0; } +.btn-group > .dropdown:not(:first-child) > .btn { + border-bottom-left-radius: 0; + border-top-left-radius: 0; +} + dl.details-list { display: grid; grid-column-gap: 10px; diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index 55d834b3c0e..76d573fefd0 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -1160,6 +1160,7 @@ "version": "Version" }, "pagination": { + "current_total": "{current} of {total}", "first": "First", "last": "Last", "next": "Next", From bf25759a574b54397eb91f760991917c2e5b50c3 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Tue, 11 Jun 2024 11:36:24 +1000 Subject: [PATCH 006/103] Validate custom locale and javascript strings (#4893) * Validate locale json string * Validate custom javascript string --- ui/v2.5/src/components/Settings/Inputs.tsx | 42 +++++++-- .../SettingsInterfacePanel.tsx | 88 ++++++++++++++----- ui/v2.5/src/locales/en-GB.json | 2 + 3 files changed, 105 insertions(+), 27 deletions(-) diff --git a/ui/v2.5/src/components/Settings/Inputs.tsx b/ui/v2.5/src/components/Settings/Inputs.tsx index fc23782e7e9..61353011f53 100644 --- a/ui/v2.5/src/components/Settings/Inputs.tsx +++ b/ui/v2.5/src/components/Settings/Inputs.tsx @@ -273,9 +273,14 @@ export interface ISettingModal { subHeading?: React.ReactNode; value: T | undefined; close: (v?: T) => void; - renderField: (value: T | undefined, setValue: (v?: T) => void) => JSX.Element; + renderField: ( + value: T | undefined, + setValue: (v?: T) => void, + error?: string + ) => JSX.Element; modalProps?: ModalProps; validate?: (v: T) => boolean | undefined; + error?: string | undefined; } export const SettingModal = (props: ISettingModal) => { @@ -289,6 +294,7 @@ export const SettingModal = (props: ISettingModal) => { renderField, modalProps, validate, + error, } = props; const intl = useIntl(); @@ -306,7 +312,7 @@ export const SettingModal = (props: ISettingModal) => { {headingID ? : heading} - {renderField(currentValue, setCurrentValue)} + {renderField(currentValue, setCurrentValue, error)} {subHeadingID ? (
{intl.formatMessage({ id: subHeadingID })} @@ -341,9 +347,14 @@ interface IModalSetting extends ISetting { buttonText?: string; buttonTextID?: string; onChange: (v: T) => void; - renderField: (value: T | undefined, setValue: (v?: T) => void) => JSX.Element; + renderField: ( + value: T | undefined, + setValue: (v?: T) => void, + error?: string + ) => JSX.Element; renderValue?: (v: T | undefined) => JSX.Element; modalProps?: ModalProps; + validateChange?: (v: T) => void | undefined; } export const ModalSetting = (props: IModalSetting) => { @@ -364,10 +375,29 @@ export const ModalSetting = (props: IModalSetting) => { modalProps, disabled, advanced, + validateChange, } = props; const [showModal, setShowModal] = useState(false); + const [error, setError] = useState(); const { advancedMode } = useSettings(); + function onClose(v: T | undefined) { + setError(undefined); + if (v !== undefined) { + if (validateChange) { + try { + validateChange(v); + } catch (e) { + setError((e as Error).message); + return; + } + } + + onChange(v); + } + setShowModal(false); + } + if (advanced && !advancedMode) return null; return ( @@ -380,10 +410,8 @@ export const ModalSetting = (props: IModalSetting) => { subHeading={subHeading} value={value} renderField={renderField} - close={(v) => { - if (v !== undefined) onChange(v); - setShowModal(false); - }} + close={onClose} + error={error} {...modalProps} /> ) : undefined} diff --git a/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx b/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx index 086b27cdbb4..5e7b6db273c 100644 --- a/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx +++ b/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx @@ -135,6 +135,40 @@ export const SettingsInterfacePanel: React.FC = () => { }); } + function validateLocaleString(v: string) { + if (!v) return; + try { + JSON.parse(v); + } catch (e) { + throw new Error( + intl.formatMessage( + { id: "errors.invalid_json_string" }, + { + error: (e as SyntaxError).message, + } + ) + ); + } + } + + function validateJavascriptString(v: string) { + if (!v) return; + try { + // creates a function from the string to validate it but does not execute it + // eslint-disable-next-line @typescript-eslint/no-implied-eval + new Function(v); + } catch (e) { + throw new Error( + intl.formatMessage( + { id: "errors.invalid_javascript_string" }, + { + error: (e as SyntaxError).message, + } + ) + ); + } + } + if (error) return

{error.message}

; if (loading) return ; @@ -726,16 +760,23 @@ export const SettingsInterfacePanel: React.FC = () => { 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" - /> + validateChange={validateJavascriptString} + renderField={(value, setValue, err) => ( + <> + ) => + setValue(e.currentTarget.value) + } + rows={16} + className="text-input code" + isInvalid={!!err} + /> + + {err} + + )} renderValue={() => { return <>; @@ -756,16 +797,23 @@ export const SettingsInterfacePanel: React.FC = () => { subHeadingID="config.ui.custom_locales.description" value={iface.customLocales ?? undefined} onChange={(v) => saveInterface({ customLocales: v })} - renderField={(value, setValue) => ( - ) => - setValue(e.currentTarget.value) - } - rows={16} - className="text-input code" - /> + validateChange={validateLocaleString} + renderField={(value, setValue, err) => ( + <> + ) => + setValue(e.currentTarget.value) + } + rows={16} + className="text-input code" + isInvalid={!!err} + /> + + {err} + + )} renderValue={() => { return <>; diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index 76d573fefd0..d1072183a64 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -1017,6 +1017,8 @@ "errors": { "header": "Error", "image_index_greater_than_zero": "Image index must be greater than 0", + "invalid_javascript_string": "Invalid javascript code: {error}", + "invalid_json_string": "Invalid JSON string: {error}", "lazy_component_error_help": "If you recently upgraded Stash, please reload the page or clear your browser cache.", "loading_type": "Error loading {type}", "something_went_wrong": "Something went wrong." From 62bdff351dc94c4ae3777ccea5b2731a9412280e Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Tue, 11 Jun 2024 13:08:49 +1000 Subject: [PATCH 007/103] Movie URLs (#4900) * Fix exclude behaviour for stringListCriterionHandlerBuilder --- graphql/schema/types/movie.graphql | 10 ++- graphql/schema/types/scraped-movie.graphql | 6 +- internal/api/resolver_model_movie.go | 29 +++++++ internal/api/resolver_mutation_movie.go | 11 ++- internal/manager/task_export.go | 5 ++ pkg/models/jsonschema/movie.go | 5 +- pkg/models/mocks/MovieReaderWriter.go | 23 +++++ pkg/models/model_movie.go | 12 ++- pkg/models/model_scraped_item.go | 5 +- pkg/models/repository_movie.go | 1 + pkg/movie/export.go | 2 +- pkg/movie/export_test.go | 6 +- pkg/movie/import.go | 6 +- pkg/scraper/movie.go | 19 +++-- pkg/sqlite/anonymise.go | 8 +- pkg/sqlite/criterion_handlers.go | 37 ++++++++- pkg/sqlite/database.go | 2 +- pkg/sqlite/gallery_filter.go | 2 + pkg/sqlite/image_filter.go | 2 + pkg/sqlite/migrations/59_movie_urls.up.sql | 83 +++++++++++++++++++ pkg/sqlite/movies.go | 30 ++++++- pkg/sqlite/movies_filter.go | 16 +++- pkg/sqlite/movies_test.go | 75 ++++++++++++++++- pkg/sqlite/performer_filter.go | 2 + pkg/sqlite/scene_filter.go | 14 ++++ pkg/sqlite/setup_test.go | 13 ++- pkg/sqlite/studio_filter.go | 2 + pkg/sqlite/tables.go | 10 +++ pkg/sqlite/tag_filter.go | 2 + ui/v2.5/graphql/data/movie.graphql | 2 +- ui/v2.5/graphql/data/scrapers.graphql | 4 +- .../components/Movies/MovieDetails/Movie.tsx | 15 +--- .../Movies/MovieDetails/MovieEditPanel.tsx | 34 +++----- .../Movies/MovieDetails/MovieScrapeDialog.tsx | 23 +++-- .../components/Shared/ExternalLinksButton.tsx | 48 +++++++++++ ui/v2.5/src/components/Shared/styles.scss | 4 + 36 files changed, 484 insertions(+), 84 deletions(-) create mode 100644 pkg/sqlite/migrations/59_movie_urls.up.sql create mode 100644 ui/v2.5/src/components/Shared/ExternalLinksButton.tsx diff --git a/graphql/schema/types/movie.graphql b/graphql/schema/types/movie.graphql index 1a52c91ea27..8501d88334a 100644 --- a/graphql/schema/types/movie.graphql +++ b/graphql/schema/types/movie.graphql @@ -10,7 +10,8 @@ type Movie { studio: Studio director: String synopsis: String - url: String + url: String @deprecated(reason: "Use urls") + urls: [String!]! created_at: Time! updated_at: Time! @@ -31,7 +32,8 @@ input MovieCreateInput { studio_id: ID director: String synopsis: String - url: String + url: String @deprecated(reason: "Use urls") + urls: [String!] "This should be a URL or a base64 encoded data URL" front_image: String "This should be a URL or a base64 encoded data URL" @@ -49,7 +51,8 @@ input MovieUpdateInput { studio_id: ID director: String synopsis: String - url: String + url: String @deprecated(reason: "Use urls") + urls: [String!] "This should be a URL or a base64 encoded data URL" front_image: String "This should be a URL or a base64 encoded data URL" @@ -63,6 +66,7 @@ input BulkMovieUpdateInput { rating100: Int studio_id: ID director: String + urls: BulkUpdateStrings } input MovieDestroyInput { diff --git a/graphql/schema/types/scraped-movie.graphql b/graphql/schema/types/scraped-movie.graphql index e3110b8e178..f45903ccef1 100644 --- a/graphql/schema/types/scraped-movie.graphql +++ b/graphql/schema/types/scraped-movie.graphql @@ -7,7 +7,8 @@ type ScrapedMovie { date: String rating: String director: String - url: String + url: String @deprecated(reason: "use urls") + urls: [String!] synopsis: String studio: ScrapedStudio @@ -24,6 +25,7 @@ input ScrapedMovieInput { date: String rating: String director: String - url: String + url: String @deprecated(reason: "use urls") + urls: [String!] synopsis: String } diff --git a/internal/api/resolver_model_movie.go b/internal/api/resolver_model_movie.go index e08d99471c3..630b7d2a0ea 100644 --- a/internal/api/resolver_model_movie.go +++ b/internal/api/resolver_model_movie.go @@ -20,6 +20,35 @@ func (r *movieResolver) Rating100(ctx context.Context, obj *models.Movie) (*int, return obj.Rating, nil } +func (r *movieResolver) URL(ctx context.Context, obj *models.Movie) (*string, error) { + if !obj.URLs.Loaded() { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { + return obj.LoadURLs(ctx, r.repository.Movie) + }); err != nil { + return nil, err + } + } + + urls := obj.URLs.List() + if len(urls) == 0 { + return nil, nil + } + + return &urls[0], nil +} + +func (r *movieResolver) Urls(ctx context.Context, obj *models.Movie) ([]string, error) { + if !obj.URLs.Loaded() { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { + return obj.LoadURLs(ctx, r.repository.Movie) + }); err != nil { + return nil, err + } + } + + return obj.URLs.List(), nil +} + func (r *movieResolver) Studio(ctx context.Context, obj *models.Movie) (ret *models.Studio, err error) { if obj.StudioID == nil { return nil, nil diff --git a/internal/api/resolver_mutation_movie.go b/internal/api/resolver_mutation_movie.go index cb447465419..82198c125d4 100644 --- a/internal/api/resolver_mutation_movie.go +++ b/internal/api/resolver_mutation_movie.go @@ -38,7 +38,6 @@ func (r *mutationResolver) MovieCreate(ctx context.Context, input MovieCreateInp newMovie.Rating = input.Rating100 newMovie.Director = translator.string(input.Director) newMovie.Synopsis = translator.string(input.Synopsis) - newMovie.URL = translator.string(input.URL) var err error @@ -51,6 +50,12 @@ func (r *mutationResolver) MovieCreate(ctx context.Context, input MovieCreateInp return nil, fmt.Errorf("converting studio id: %w", err) } + if input.Urls != nil { + newMovie.URLs = models.NewRelatedStrings(input.Urls) + } else if input.URL != nil { + newMovie.URLs = models.NewRelatedStrings([]string{*input.URL}) + } + // Process the base 64 encoded image string var frontimageData []byte if input.FrontImage != nil { @@ -125,7 +130,6 @@ func (r *mutationResolver) MovieUpdate(ctx context.Context, input MovieUpdateInp updatedMovie.Rating = translator.optionalInt(input.Rating100, "rating100") updatedMovie.Director = translator.optionalString(input.Director, "director") updatedMovie.Synopsis = translator.optionalString(input.Synopsis, "synopsis") - updatedMovie.URL = translator.optionalString(input.URL, "url") updatedMovie.Date, err = translator.optionalDate(input.Date, "date") if err != nil { @@ -136,6 +140,8 @@ func (r *mutationResolver) MovieUpdate(ctx context.Context, input MovieUpdateInp return nil, fmt.Errorf("converting studio id: %w", err) } + updatedMovie.URLs = translator.optionalURLs(input.Urls, input.URL) + var frontimageData []byte frontImageIncluded := translator.hasField("front_image") if input.FrontImage != nil { @@ -205,6 +211,7 @@ func (r *mutationResolver) BulkMovieUpdate(ctx context.Context, input BulkMovieU if err != nil { return nil, fmt.Errorf("converting studio id: %w", err) } + updatedMovie.URLs = translator.optionalURLsBulk(input.Urls, nil) ret := []*models.Movie{} diff --git a/internal/manager/task_export.go b/internal/manager/task_export.go index 155f9feced8..555502dc5b0 100644 --- a/internal/manager/task_export.go +++ b/internal/manager/task_export.go @@ -1109,6 +1109,11 @@ func (t *ExportTask) exportMovie(ctx context.Context, wg *sync.WaitGroup, jobCha studioReader := r.Studio for m := range jobChan { + if err := m.LoadURLs(ctx, r.Movie); err != nil { + logger.Errorf("[movies] <%s> error getting movie urls: %v", m.Name, err) + continue + } + newMovieJSON, err := movie.ToJSON(ctx, movieReader, studioReader, m) if err != nil { diff --git a/pkg/models/jsonschema/movie.go b/pkg/models/jsonschema/movie.go index d787f8288af..33ce10c1d4a 100644 --- a/pkg/models/jsonschema/movie.go +++ b/pkg/models/jsonschema/movie.go @@ -21,10 +21,13 @@ type Movie struct { Synopsis string `json:"synopsis,omitempty"` FrontImage string `json:"front_image,omitempty"` BackImage string `json:"back_image,omitempty"` - URL string `json:"url,omitempty"` + URLs []string `json:"urls,omitempty"` Studio string `json:"studio,omitempty"` CreatedAt json.JSONTime `json:"created_at,omitempty"` UpdatedAt json.JSONTime `json:"updated_at,omitempty"` + + // deprecated - for import only + URL string `json:"url,omitempty"` } func (s Movie) Filename() string { diff --git a/pkg/models/mocks/MovieReaderWriter.go b/pkg/models/mocks/MovieReaderWriter.go index edf355e142c..3f693be94ed 100644 --- a/pkg/models/mocks/MovieReaderWriter.go +++ b/pkg/models/mocks/MovieReaderWriter.go @@ -312,6 +312,29 @@ func (_m *MovieReaderWriter) GetFrontImage(ctx context.Context, movieID int) ([] return r0, r1 } +// GetURLs provides a mock function with given fields: ctx, relatedID +func (_m *MovieReaderWriter) GetURLs(ctx context.Context, relatedID int) ([]string, error) { + ret := _m.Called(ctx, relatedID) + + var r0 []string + if rf, ok := ret.Get(0).(func(context.Context, int) []string); ok { + r0 = rf(ctx, relatedID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]string) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { + r1 = rf(ctx, relatedID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // HasBackImage provides a mock function with given fields: ctx, movieID func (_m *MovieReaderWriter) HasBackImage(ctx context.Context, movieID int) (bool, error) { ret := _m.Called(ctx, movieID) diff --git a/pkg/models/model_movie.go b/pkg/models/model_movie.go index 5880ff2d137..d1ce0d8dcbf 100644 --- a/pkg/models/model_movie.go +++ b/pkg/models/model_movie.go @@ -1,6 +1,7 @@ package models import ( + "context" "time" ) @@ -15,9 +16,10 @@ type Movie struct { StudioID *int `json:"studio_id"` Director string `json:"director"` Synopsis string `json:"synopsis"` - URL string `json:"url"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` + + URLs RelatedStrings `json:"urls"` } func NewMovie() Movie { @@ -28,6 +30,12 @@ func NewMovie() Movie { } } +func (g *Movie) LoadURLs(ctx context.Context, l URLLoader) error { + return g.URLs.load(func() ([]string, error) { + return l.GetURLs(ctx, g.ID) + }) +} + type MoviePartial struct { Name OptionalString Aliases OptionalString @@ -38,7 +46,7 @@ type MoviePartial struct { StudioID OptionalInt Director OptionalString Synopsis OptionalString - URL OptionalString + URLs *UpdateStrings CreatedAt OptionalTime UpdatedAt OptionalTime } diff --git a/pkg/models/model_scraped_item.go b/pkg/models/model_scraped_item.go index cb383c082e7..b3a7a2418b3 100644 --- a/pkg/models/model_scraped_item.go +++ b/pkg/models/model_scraped_item.go @@ -368,13 +368,16 @@ type ScrapedMovie struct { Date *string `json:"date"` Rating *string `json:"rating"` Director *string `json:"director"` - URL *string `json:"url"` + URLs []string `json:"urls"` Synopsis *string `json:"synopsis"` Studio *ScrapedStudio `json:"studio"` // This should be a base64 encoded data URL FrontImage *string `json:"front_image"` // This should be a base64 encoded data URL BackImage *string `json:"back_image"` + + // deprecated + URL *string `json:"url"` } func (ScrapedMovie) IsScrapedContent() {} diff --git a/pkg/models/repository_movie.go b/pkg/models/repository_movie.go index 9234ea7a5d1..2518e21b529 100644 --- a/pkg/models/repository_movie.go +++ b/pkg/models/repository_movie.go @@ -64,6 +64,7 @@ type MovieReader interface { MovieFinder MovieQueryer MovieCounter + URLLoader All(ctx context.Context) ([]*Movie, error) GetFrontImage(ctx context.Context, movieID int) ([]byte, error) diff --git a/pkg/movie/export.go b/pkg/movie/export.go index 5a6c49aa364..55e157168e7 100644 --- a/pkg/movie/export.go +++ b/pkg/movie/export.go @@ -23,7 +23,7 @@ func ToJSON(ctx context.Context, reader ImageGetter, studioReader models.StudioG Aliases: movie.Aliases, Director: movie.Director, Synopsis: movie.Synopsis, - URL: movie.URL, + URLs: movie.URLs.List(), CreatedAt: json.JSONTime{Time: movie.CreatedAt}, UpdatedAt: json.JSONTime{Time: movie.UpdatedAt}, } diff --git a/pkg/movie/export_test.go b/pkg/movie/export_test.go index 51d57e2b6e8..dd6c9f27409 100644 --- a/pkg/movie/export_test.go +++ b/pkg/movie/export_test.go @@ -72,7 +72,7 @@ func createFullMovie(id int, studioID int) models.Movie { Duration: &duration, Director: director, Synopsis: synopsis, - URL: url, + URLs: models.NewRelatedStrings([]string{url}), StudioID: &studioID, CreatedAt: createTime, UpdatedAt: updateTime, @@ -82,6 +82,7 @@ func createFullMovie(id int, studioID int) models.Movie { func createEmptyMovie(id int) models.Movie { return models.Movie{ ID: id, + URLs: models.NewRelatedStrings([]string{}), CreatedAt: createTime, UpdatedAt: updateTime, } @@ -96,7 +97,7 @@ func createFullJSONMovie(studio, frontImage, backImage string) *jsonschema.Movie Duration: duration, Director: director, Synopsis: synopsis, - URL: url, + URLs: []string{url}, Studio: studio, FrontImage: frontImage, BackImage: backImage, @@ -111,6 +112,7 @@ func createFullJSONMovie(studio, frontImage, backImage string) *jsonschema.Movie func createEmptyJSONMovie() *jsonschema.Movie { return &jsonschema.Movie{ + URLs: []string{}, CreatedAt: json.JSONTime{ Time: createTime, }, diff --git a/pkg/movie/import.go b/pkg/movie/import.go index 8004798ae53..00e56d4e137 100644 --- a/pkg/movie/import.go +++ b/pkg/movie/import.go @@ -55,11 +55,15 @@ func (i *Importer) movieJSONToMovie(movieJSON jsonschema.Movie) models.Movie { Aliases: movieJSON.Aliases, Director: movieJSON.Director, Synopsis: movieJSON.Synopsis, - URL: movieJSON.URL, CreatedAt: movieJSON.CreatedAt.GetTime(), UpdatedAt: movieJSON.UpdatedAt.GetTime(), } + if len(movieJSON.URLs) > 0 { + newMovie.URLs = models.NewRelatedStrings(movieJSON.URLs) + } else if movieJSON.URL != "" { + newMovie.URLs = models.NewRelatedStrings([]string{movieJSON.URL}) + } if movieJSON.Date != "" { d, err := models.ParseDate(movieJSON.Date) if err == nil { diff --git a/pkg/scraper/movie.go b/pkg/scraper/movie.go index 4416b6199cb..00c89ad9c45 100644 --- a/pkg/scraper/movie.go +++ b/pkg/scraper/movie.go @@ -1,12 +1,15 @@ package scraper type ScrapedMovieInput struct { - Name *string `json:"name"` - Aliases *string `json:"aliases"` - Duration *string `json:"duration"` - Date *string `json:"date"` - Rating *string `json:"rating"` - Director *string `json:"director"` - URL *string `json:"url"` - Synopsis *string `json:"synopsis"` + Name *string `json:"name"` + Aliases *string `json:"aliases"` + Duration *string `json:"duration"` + Date *string `json:"date"` + Rating *string `json:"rating"` + Director *string `json:"director"` + URLs []string `json:"urls"` + Synopsis *string `json:"synopsis"` + + // deprecated + URL *string `json:"url"` } diff --git a/pkg/sqlite/anonymise.go b/pkg/sqlite/anonymise.go index 7e4efd70299..44381c0700e 100644 --- a/pkg/sqlite/anonymise.go +++ b/pkg/sqlite/anonymise.go @@ -847,7 +847,6 @@ func (db *Anonymiser) anonymiseMovies(ctx context.Context) error { table.Col("name"), table.Col("aliases"), table.Col("synopsis"), - table.Col("url"), table.Col("director"), ).Where(table.Col(idColumn).Gt(lastID)).Limit(1000) @@ -860,7 +859,6 @@ func (db *Anonymiser) anonymiseMovies(ctx context.Context) error { name sql.NullString aliases sql.NullString synopsis sql.NullString - url sql.NullString director sql.NullString ) @@ -869,7 +867,6 @@ func (db *Anonymiser) anonymiseMovies(ctx context.Context) error { &name, &aliases, &synopsis, - &url, &director, ); err != nil { return err @@ -879,7 +876,6 @@ func (db *Anonymiser) anonymiseMovies(ctx context.Context) error { db.obfuscateNullString(set, "name", name) db.obfuscateNullString(set, "aliases", aliases) db.obfuscateNullString(set, "synopsis", synopsis) - db.obfuscateNullString(set, "url", url) db.obfuscateNullString(set, "director", director) if len(set) > 0 { @@ -905,6 +901,10 @@ func (db *Anonymiser) anonymiseMovies(ctx context.Context) error { } } + if err := db.anonymiseURLs(ctx, goqu.T(movieURLsTable), "movie_id"); err != nil { + return err + } + return nil } diff --git a/pkg/sqlite/criterion_handlers.go b/pkg/sqlite/criterion_handlers.go index 243f1f54e13..e021bd1759b 100644 --- a/pkg/sqlite/criterion_handlers.go +++ b/pkg/sqlite/criterion_handlers.go @@ -517,20 +517,51 @@ func (m *countCriterionHandlerBuilder) handler(criterion *models.IntCriterionInp // handler for StringCriterion for string list fields type stringListCriterionHandlerBuilder struct { + primaryTable string + // foreign key of the primary object on the join table + primaryFK string // table joining primary and foreign objects joinTable string // string field on the join table stringColumn string - addJoinTable func(f *filterBuilder) + addJoinTable func(f *filterBuilder) + excludeHandler func(f *filterBuilder, criterion *models.StringCriterionInput) } func (m *stringListCriterionHandlerBuilder) handler(criterion *models.StringCriterionInput) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if criterion != nil { - m.addJoinTable(f) + if criterion.Modifier == models.CriterionModifierExcludes { + // special handling for excludes + if m.excludeHandler != nil { + m.excludeHandler(f, criterion) + return + } - stringCriterionHandler(criterion, m.joinTable+"."+m.stringColumn)(ctx, f) + // excludes all of the provided values + // need to use actual join table name for this + // .id NOT IN (select . from where . in ) + whereClause := utils.StrFormat("{primaryTable}.id NOT IN (SELECT {joinTable}.{primaryFK} from {joinTable} where {joinTable}.{stringColumn} LIKE ?)", + utils.StrFormatMap{ + "primaryTable": m.primaryTable, + "joinTable": m.joinTable, + "primaryFK": m.primaryFK, + "stringColumn": m.stringColumn, + }, + ) + + f.addWhere(whereClause, "%"+criterion.Value+"%") + + // TODO - should we also exclude null values? + // m.addJoinTable(f) + // stringCriterionHandler(&models.StringCriterionInput{ + // Modifier: models.CriterionModifierNotNull, + // }, m.joinTable+"."+m.stringColumn)(ctx, f) + } else { + m.addJoinTable(f) + stringCriterionHandler(criterion, m.joinTable+"."+m.stringColumn)(ctx, f) + } } } } diff --git a/pkg/sqlite/database.go b/pkg/sqlite/database.go index 4da53c3528e..3475e955a72 100644 --- a/pkg/sqlite/database.go +++ b/pkg/sqlite/database.go @@ -30,7 +30,7 @@ const ( dbConnTimeout = 30 ) -var appSchemaVersion uint = 58 +var appSchemaVersion uint = 59 //go:embed migrations/*.sql var migrationsBox embed.FS diff --git a/pkg/sqlite/gallery_filter.go b/pkg/sqlite/gallery_filter.go index abca78b105c..ad5ac592ada 100644 --- a/pkg/sqlite/gallery_filter.go +++ b/pkg/sqlite/gallery_filter.go @@ -151,6 +151,8 @@ func (qb *galleryFilterHandler) criterionHandler() criterionHandler { func (qb *galleryFilterHandler) urlsCriterionHandler(url *models.StringCriterionInput) criterionHandlerFunc { h := stringListCriterionHandlerBuilder{ + primaryTable: galleryTable, + primaryFK: galleryIDColumn, joinTable: galleriesURLsTable, stringColumn: galleriesURLColumn, addJoinTable: func(f *filterBuilder) { diff --git a/pkg/sqlite/image_filter.go b/pkg/sqlite/image_filter.go index 4fef482714f..8f2d5d6b90a 100644 --- a/pkg/sqlite/image_filter.go +++ b/pkg/sqlite/image_filter.go @@ -160,6 +160,8 @@ func (qb *imageFilterHandler) missingCriterionHandler(isMissing *string) criteri func (qb *imageFilterHandler) urlsCriterionHandler(url *models.StringCriterionInput) criterionHandlerFunc { h := stringListCriterionHandlerBuilder{ + primaryTable: imageTable, + primaryFK: imageIDColumn, joinTable: imagesURLsTable, stringColumn: imageURLColumn, addJoinTable: func(f *filterBuilder) { diff --git a/pkg/sqlite/migrations/59_movie_urls.up.sql b/pkg/sqlite/migrations/59_movie_urls.up.sql new file mode 100644 index 00000000000..3ea860e3020 --- /dev/null +++ b/pkg/sqlite/migrations/59_movie_urls.up.sql @@ -0,0 +1,83 @@ +PRAGMA foreign_keys=OFF; + +CREATE TABLE `movie_urls` ( + `movie_id` integer NOT NULL, + `position` integer NOT NULL, + `url` varchar(255) NOT NULL, + foreign key(`movie_id`) references `movies`(`id`) on delete CASCADE, + PRIMARY KEY(`movie_id`, `position`, `url`) +); + +CREATE INDEX `movie_urls_url` on `movie_urls` (`url`); + +-- drop url +CREATE TABLE `movies_new` ( + `id` integer not null primary key autoincrement, + `name` varchar(255) not null, + `aliases` varchar(255), + `duration` integer, + `date` date, + `rating` tinyint, + `studio_id` integer REFERENCES `studios`(`id`) ON DELETE SET NULL, + `director` varchar(255), + `synopsis` text, + `created_at` datetime not null, + `updated_at` datetime not null, + `front_image_blob` varchar(255) REFERENCES `blobs`(`checksum`), + `back_image_blob` varchar(255) REFERENCES `blobs`(`checksum`) +); + +INSERT INTO `movies_new` + ( + `id`, + `name`, + `aliases`, + `duration`, + `date`, + `rating`, + `studio_id`, + `director`, + `synopsis`, + `created_at`, + `updated_at`, + `front_image_blob`, + `back_image_blob` + ) + SELECT + `id`, + `name`, + `aliases`, + `duration`, + `date`, + `rating`, + `studio_id`, + `director`, + `synopsis`, + `created_at`, + `updated_at`, + `front_image_blob`, + `back_image_blob` + FROM `movies`; + +INSERT INTO `movie_urls` + ( + `movie_id`, + `position`, + `url` + ) + SELECT + `id`, + '0', + `url` + FROM `movies` + WHERE `movies`.`url` IS NOT NULL AND `movies`.`url` != ''; + +DROP INDEX `index_movies_on_name_unique`; +DROP INDEX `index_movies_on_studio_id`; +DROP TABLE `movies`; +ALTER TABLE `movies_new` rename to `movies`; + +CREATE INDEX `index_movies_on_name` ON `movies`(`name`); +CREATE INDEX `index_movies_on_studio_id` on `movies` (`studio_id`); + +PRAGMA foreign_keys=ON; diff --git a/pkg/sqlite/movies.go b/pkg/sqlite/movies.go index acbf036f2bb..6fc4ce5f09e 100644 --- a/pkg/sqlite/movies.go +++ b/pkg/sqlite/movies.go @@ -22,6 +22,9 @@ const ( movieFrontImageBlobColumn = "front_image_blob" movieBackImageBlobColumn = "back_image_blob" + + movieURLsTable = "movie_urls" + movieURLColumn = "url" ) type movieRow struct { @@ -35,7 +38,6 @@ type movieRow struct { StudioID null.Int `db:"studio_id,omitempty"` Director zero.String `db:"director"` Synopsis zero.String `db:"synopsis"` - URL zero.String `db:"url"` CreatedAt Timestamp `db:"created_at"` UpdatedAt Timestamp `db:"updated_at"` @@ -54,7 +56,6 @@ func (r *movieRow) fromMovie(o models.Movie) { r.StudioID = intFromPtr(o.StudioID) r.Director = zero.StringFrom(o.Director) r.Synopsis = zero.StringFrom(o.Synopsis) - r.URL = zero.StringFrom(o.URL) r.CreatedAt = Timestamp{Timestamp: o.CreatedAt} r.UpdatedAt = Timestamp{Timestamp: o.UpdatedAt} } @@ -70,7 +71,6 @@ func (r *movieRow) resolve() *models.Movie { StudioID: nullIntPtr(r.StudioID), Director: r.Director.String, Synopsis: r.Synopsis.String, - URL: r.URL.String, CreatedAt: r.CreatedAt.Timestamp, UpdatedAt: r.UpdatedAt.Timestamp, } @@ -91,7 +91,6 @@ func (r *movieRowRecord) fromPartial(o models.MoviePartial) { r.setNullInt("studio_id", o.StudioID) r.setNullString("director", o.Director) r.setNullString("synopsis", o.Synopsis) - r.setNullString("url", o.URL) r.setTimestamp("created_at", o.CreatedAt) r.setTimestamp("updated_at", o.UpdatedAt) } @@ -148,6 +147,13 @@ func (qb *MovieStore) Create(ctx context.Context, newObject *models.Movie) error return err } + if newObject.URLs.Loaded() { + const startPos = 0 + if err := moviesURLsTableMgr.insertJoins(ctx, id, startPos, newObject.URLs.List()); err != nil { + return err + } + } + updated, err := qb.find(ctx, id) if err != nil { return fmt.Errorf("finding after create: %w", err) @@ -173,6 +179,12 @@ func (qb *MovieStore) UpdatePartial(ctx context.Context, id int, partial models. } } + if partial.URLs != nil { + if err := moviesURLsTableMgr.modifyJoins(ctx, id, partial.URLs.Values, partial.URLs.Mode); err != nil { + return nil, err + } + } + return qb.find(ctx, id) } @@ -184,6 +196,12 @@ func (qb *MovieStore) Update(ctx context.Context, updatedObject *models.Movie) e return err } + if updatedObject.URLs.Loaded() { + if err := moviesURLsTableMgr.replaceJoins(ctx, updatedObject.ID, updatedObject.URLs.List()); err != nil { + return err + } + } + return nil } @@ -537,3 +555,7 @@ WHERE movies.studio_id = ? args := []interface{}{studioID} return movieRepository.runCountQuery(ctx, query, args) } + +func (qb *MovieStore) GetURLs(ctx context.Context, movieID int) ([]string, error) { + return moviesURLsTableMgr.get(ctx, movieID) +} diff --git a/pkg/sqlite/movies_filter.go b/pkg/sqlite/movies_filter.go index 78d5abf5d22..8ef939592c7 100644 --- a/pkg/sqlite/movies_filter.go +++ b/pkg/sqlite/movies_filter.go @@ -60,7 +60,7 @@ func (qb *movieFilterHandler) criterionHandler() criterionHandler { intCriterionHandler(movieFilter.Rating100, "movies.rating", nil), floatIntCriterionHandler(movieFilter.Duration, "movies.duration", nil), qb.missingCriterionHandler(movieFilter.IsMissing), - stringCriterionHandler(movieFilter.URL, "movies.url"), + qb.urlsCriterionHandler(movieFilter.URL), studioCriterionHandler(movieTable, movieFilter.Studios), qb.performersCriterionHandler(movieFilter.Performers), &dateCriterionHandler{movieFilter.Date, "movies.date", nil}, @@ -102,6 +102,20 @@ func (qb *movieFilterHandler) missingCriterionHandler(isMissing *string) criteri } } +func (qb *movieFilterHandler) urlsCriterionHandler(url *models.StringCriterionInput) criterionHandlerFunc { + h := stringListCriterionHandlerBuilder{ + primaryTable: movieTable, + primaryFK: movieIDColumn, + joinTable: movieURLsTable, + stringColumn: movieURLColumn, + addJoinTable: func(f *filterBuilder) { + moviesURLsTableMgr.join(f, "", "movies.id") + }, + } + + return h.handler(url) +} + func (qb *movieFilterHandler) performersCriterionHandler(performers *models.MultiCriterionInput) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if performers != nil { diff --git a/pkg/sqlite/movies_test.go b/pkg/sqlite/movies_test.go index 9b9615fbd90..9c4e0135fa1 100644 --- a/pkg/sqlite/movies_test.go +++ b/pkg/sqlite/movies_test.go @@ -15,6 +15,16 @@ import ( "github.com/stashapp/stash/pkg/models" ) +func loadMovieRelationships(ctx context.Context, expected models.Movie, actual *models.Movie) error { + if expected.URLs.Loaded() { + if err := actual.LoadURLs(ctx, db.Gallery); err != nil { + return err + } + } + + return nil +} + func TestMovieFindByName(t *testing.T) { withTxn(func(ctx context.Context) error { mqb := db.Movie @@ -205,7 +215,14 @@ func TestMovieQueryURL(t *testing.T) { verifyFn := func(n *models.Movie) { t.Helper() - verifyString(t, n.URL, urlCriterion) + + urls := n.URLs.List() + var url string + if len(urls) > 0 { + url = urls[0] + } + + verifyString(t, url, urlCriterion) } verifyMovieQuery(t, filter, verifyFn) @@ -228,6 +245,56 @@ func TestMovieQueryURL(t *testing.T) { verifyMovieQuery(t, filter, verifyFn) } +func TestMovieQueryURLExcludes(t *testing.T) { + withRollbackTxn(func(ctx context.Context) error { + mqb := db.Movie + + // create movie with two URLs + movie := models.Movie{ + Name: "TestMovieQueryURLExcludes", + URLs: models.NewRelatedStrings([]string{ + "aaa", + "bbb", + }), + } + + err := mqb.Create(ctx, &movie) + + if err != nil { + return fmt.Errorf("Error creating movie: %w", err) + } + + // query for movies that exclude the URL "aaa" + urlCriterion := models.StringCriterionInput{ + Value: "aaa", + Modifier: models.CriterionModifierExcludes, + } + + nameCriterion := models.StringCriterionInput{ + Value: movie.Name, + Modifier: models.CriterionModifierEquals, + } + + filter := models.MovieFilterType{ + URL: &urlCriterion, + Name: &nameCriterion, + } + + movies := queryMovie(ctx, t, mqb, &filter, nil) + assert.Len(t, movies, 0, "Expected no movies to be found") + + // query for movies that exclude the URL "ccc" + urlCriterion.Value = "ccc" + movies = queryMovie(ctx, t, mqb, &filter, nil) + + if assert.Len(t, movies, 1, "Expected one movie to be found") { + assert.Equal(t, movie.Name, movies[0].Name) + } + + return nil + }) +} + func verifyMovieQuery(t *testing.T, filter models.MovieFilterType, verifyFn func(s *models.Movie)) { withTxn(func(ctx context.Context) error { t.Helper() @@ -235,6 +302,12 @@ func verifyMovieQuery(t *testing.T, filter models.MovieFilterType, verifyFn func movies := queryMovie(ctx, t, sqb, &filter, nil) + for _, movie := range movies { + if err := movie.LoadURLs(ctx, sqb); err != nil { + t.Errorf("Error loading movie relationships: %v", err) + } + } + // assume it should find at least one assert.Greater(t, len(movies), 0) diff --git a/pkg/sqlite/performer_filter.go b/pkg/sqlite/performer_filter.go index 100da424488..13c2ec5a248 100644 --- a/pkg/sqlite/performer_filter.go +++ b/pkg/sqlite/performer_filter.go @@ -243,6 +243,8 @@ func (qb *performerFilterHandler) performerAgeFilterCriterionHandler(age *models func (qb *performerFilterHandler) aliasCriterionHandler(alias *models.StringCriterionInput) criterionHandlerFunc { h := stringListCriterionHandlerBuilder{ + primaryTable: performerTable, + primaryFK: performerIDColumn, joinTable: performersAliasesTable, stringColumn: performerAliasColumn, addJoinTable: func(f *filterBuilder) { diff --git a/pkg/sqlite/scene_filter.go b/pkg/sqlite/scene_filter.go index 2ce329a9626..b9c219695d0 100644 --- a/pkg/sqlite/scene_filter.go +++ b/pkg/sqlite/scene_filter.go @@ -345,6 +345,8 @@ func (qb *sceneFilterHandler) isMissingCriterionHandler(isMissing *string) crite func (qb *sceneFilterHandler) urlsCriterionHandler(url *models.StringCriterionInput) criterionHandlerFunc { h := stringListCriterionHandlerBuilder{ + primaryTable: sceneTable, + primaryFK: sceneIDColumn, joinTable: scenesURLsTable, stringColumn: sceneURLColumn, addJoinTable: func(f *filterBuilder) { @@ -368,12 +370,24 @@ func (qb *sceneFilterHandler) getMultiCriterionHandlerBuilder(foreignTable, join func (qb *sceneFilterHandler) captionCriterionHandler(captions *models.StringCriterionInput) criterionHandlerFunc { h := stringListCriterionHandlerBuilder{ + primaryTable: sceneTable, + primaryFK: sceneIDColumn, joinTable: videoCaptionsTable, stringColumn: captionCodeColumn, addJoinTable: func(f *filterBuilder) { qb.addSceneFilesTable(f) f.addLeftJoin(videoCaptionsTable, "", "video_captions.file_id = scenes_files.file_id") }, + excludeHandler: func(f *filterBuilder, criterion *models.StringCriterionInput) { + excludeClause := `scenes.id NOT IN ( + SELECT scenes_files.scene_id from scenes_files + INNER JOIN video_captions on video_captions.file_id = scenes_files.file_id + WHERE video_captions.language_code LIKE ? + )` + f.addWhere(excludeClause, criterion.Value) + + // TODO - should we also exclude null values? + }, } return h.handler(captions) diff --git a/pkg/sqlite/setup_test.go b/pkg/sqlite/setup_test.go index 91b2b49fb7d..1ccab4574f7 100644 --- a/pkg/sqlite/setup_test.go +++ b/pkg/sqlite/setup_test.go @@ -1303,6 +1303,15 @@ func getMovieNullStringValue(index int, field string) string { return ret.String } +func getMovieEmptyString(index int, field string) string { + v := getPrefixedNullStringValue("movie", index, field) + if !v.Valid { + return "" + } + + return v.String +} + // createMoviees creates n movies with plain Name and o movies with camel cased NaMe included func createMovies(ctx context.Context, mqb models.MovieReaderWriter, n int, o int) error { const namePlain = "Name" @@ -1321,7 +1330,9 @@ func createMovies(ctx context.Context, mqb models.MovieReaderWriter, n int, o in name = getMovieStringValue(index, name) movie := models.Movie{ Name: name, - URL: getMovieNullStringValue(index, urlField), + URLs: models.NewRelatedStrings([]string{ + getMovieEmptyString(i, urlField), + }), } err := mqb.Create(ctx, &movie) diff --git a/pkg/sqlite/studio_filter.go b/pkg/sqlite/studio_filter.go index 1a3aa2131f0..45745c4717d 100644 --- a/pkg/sqlite/studio_filter.go +++ b/pkg/sqlite/studio_filter.go @@ -178,6 +178,8 @@ func (qb *studioFilterHandler) parentCriterionHandler(parents *models.MultiCrite func (qb *studioFilterHandler) aliasCriterionHandler(alias *models.StringCriterionInput) criterionHandlerFunc { h := stringListCriterionHandlerBuilder{ + primaryTable: studioTable, + primaryFK: studioIDColumn, joinTable: studioAliasesTable, stringColumn: studioAliasColumn, addJoinTable: func(f *filterBuilder) { diff --git a/pkg/sqlite/tables.go b/pkg/sqlite/tables.go index 2eebf033f56..64d1e4eb236 100644 --- a/pkg/sqlite/tables.go +++ b/pkg/sqlite/tables.go @@ -34,6 +34,8 @@ var ( studiosAliasesJoinTable = goqu.T(studioAliasesTable) studiosStashIDsJoinTable = goqu.T("studio_stash_ids") + + moviesURLsJoinTable = goqu.T(movieURLsTable) ) var ( @@ -299,6 +301,14 @@ var ( table: goqu.T(movieTable), idColumn: goqu.T(movieTable).Col(idColumn), } + + moviesURLsTableMgr = &orderedValueTable[string]{ + table: table{ + table: moviesURLsJoinTable, + idColumn: moviesURLsJoinTable.Col(movieIDColumn), + }, + valueColumn: moviesURLsJoinTable.Col(movieURLColumn), + } ) var ( diff --git a/pkg/sqlite/tag_filter.go b/pkg/sqlite/tag_filter.go index a628a073503..55321dbbabf 100644 --- a/pkg/sqlite/tag_filter.go +++ b/pkg/sqlite/tag_filter.go @@ -105,6 +105,8 @@ func (qb *tagFilterHandler) criterionHandler() criterionHandler { func (qb *tagFilterHandler) aliasCriterionHandler(alias *models.StringCriterionInput) criterionHandlerFunc { h := stringListCriterionHandlerBuilder{ + primaryTable: tagTable, + primaryFK: tagIDColumn, joinTable: tagAliasesTable, stringColumn: tagAliasColumn, addJoinTable: func(f *filterBuilder) { diff --git a/ui/v2.5/graphql/data/movie.graphql b/ui/v2.5/graphql/data/movie.graphql index 3fd4273d28f..a0ed1f67f32 100644 --- a/ui/v2.5/graphql/data/movie.graphql +++ b/ui/v2.5/graphql/data/movie.graphql @@ -12,7 +12,7 @@ fragment MovieData on Movie { } synopsis - url + urls front_image_path back_image_path scene_count diff --git a/ui/v2.5/graphql/data/scrapers.graphql b/ui/v2.5/graphql/data/scrapers.graphql index 94b6434b164..a59d74b096e 100644 --- a/ui/v2.5/graphql/data/scrapers.graphql +++ b/ui/v2.5/graphql/data/scrapers.graphql @@ -90,7 +90,7 @@ fragment ScrapedMovieData on ScrapedMovie { date rating director - url + urls synopsis front_image back_image @@ -108,7 +108,7 @@ fragment ScrapedSceneMovieData on ScrapedMovie { date rating director - url + urls synopsis front_image back_image diff --git a/ui/v2.5/src/components/Movies/MovieDetails/Movie.tsx b/ui/v2.5/src/components/Movies/MovieDetails/Movie.tsx index d61d9a61d77..723b1a7ac58 100644 --- a/ui/v2.5/src/components/Movies/MovieDetails/Movie.tsx +++ b/ui/v2.5/src/components/Movies/MovieDetails/Movie.tsx @@ -26,10 +26,8 @@ import { MovieEditPanel } from "./MovieEditPanel"; import { faChevronDown, faChevronUp, - faLink, faTrashAlt, } from "@fortawesome/free-solid-svg-icons"; -import TextUtils from "src/utils/text"; import { Icon } from "src/components/Shared/Icon"; import { RatingSystem } from "src/components/Shared/Rating/RatingSystem"; import { ConfigurationContext } from "src/hooks/Config"; @@ -37,7 +35,7 @@ import { DetailImage } from "src/components/Shared/DetailImage"; import { useRatingKeybinds } from "src/hooks/keybinds"; import { useLoadStickyHeader } from "src/hooks/detailsPanel"; import { useScrollToTopOnMount } from "src/hooks/scrollToTop"; -import { ExternalLink } from "src/components/Shared/ExternalLink"; +import { ExternalLinksButton } from "src/components/Shared/ExternalLinksButton"; interface IProps { movie: GQL.MovieDataFragment; @@ -273,16 +271,7 @@ const MoviePage: React.FC = ({ movie }) => { const renderClickableIcons = () => ( - {movie.url && ( - - )} + {movie.urls.length > 0 && } ); diff --git a/ui/v2.5/src/components/Movies/MovieDetails/MovieEditPanel.tsx b/ui/v2.5/src/components/Movies/MovieDetails/MovieEditPanel.tsx index 80217eff504..5b9bac5f8d9 100644 --- a/ui/v2.5/src/components/Movies/MovieDetails/MovieEditPanel.tsx +++ b/ui/v2.5/src/components/Movies/MovieDetails/MovieEditPanel.tsx @@ -9,7 +9,6 @@ import { } from "src/core/StashService"; import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; import { DetailsEditNavbar } from "src/components/Shared/DetailsEditNavbar"; -import { URLField } from "src/components/Shared/URLField"; import { useToast } from "src/hooks/Toast"; import { Modal as BSModal, Form, Button } from "react-bootstrap"; import TextUtils from "src/utils/text"; @@ -20,7 +19,11 @@ import { MovieScrapeDialog } from "./MovieScrapeDialog"; import isEqual from "lodash-es/isEqual"; import { handleUnsavedChanges } from "src/utils/navigation"; import { formikUtils } from "src/utils/form"; -import { yupDateString, yupFormikValidate } from "src/utils/yup"; +import { + yupDateString, + yupFormikValidate, + yupUniqueStringList, +} from "src/utils/yup"; import { Studio, StudioSelect } from "src/components/Studios/StudioSelect"; interface IMovieEditPanel { @@ -64,7 +67,7 @@ export const MovieEditPanel: React.FC = ({ date: yupDateString(intl), studio_id: yup.string().required().nullable(), director: yup.string().ensure(), - url: yup.string().ensure(), + urls: yupUniqueStringList(intl), synopsis: yup.string().ensure(), front_image: yup.string().nullable().optional(), back_image: yup.string().nullable().optional(), @@ -77,7 +80,7 @@ export const MovieEditPanel: React.FC = ({ date: movie?.date ?? "", studio_id: movie?.studio?.id ?? null, director: movie?.director ?? "", - url: movie?.url ?? "", + urls: movie?.urls ?? [], synopsis: movie?.synopsis ?? "", }; @@ -153,8 +156,8 @@ export const MovieEditPanel: React.FC = ({ if (state.synopsis) { formik.setFieldValue("synopsis", state.synopsis); } - if (state.url) { - formik.setFieldValue("url", state.url); + if (state.urls) { + formik.setFieldValue("urls", state.urls); } if (state.front_image) { @@ -178,8 +181,7 @@ export const MovieEditPanel: React.FC = ({ setIsLoading(false); } - async function onScrapeMovieURL() { - const { url } = formik.values; + async function onScrapeMovieURL(url: string) { if (!url) return; setIsLoading(true); @@ -334,6 +336,7 @@ export const MovieEditPanel: React.FC = ({ renderInputField, renderDateField, renderDurationField, + renderURLListField, } = formikUtils(intl, formik); function renderStudioField() { @@ -348,19 +351,6 @@ export const MovieEditPanel: React.FC = ({ return renderField("studio_id", title, control); } - function renderUrlField() { - const title = intl.formatMessage({ id: "url" }); - const control = ( - - ); - - return renderField("url", title, control); - } - // TODO: CSS class return (
@@ -391,7 +381,7 @@ export const MovieEditPanel: React.FC = ({ {renderDateField("date")} {renderStudioField()} {renderInputField("director")} - {renderUrlField()} + {renderURLListField("urls", onScrapeMovieURL, urlScrapable)} {renderInputField("synopsis", "textarea")} diff --git a/ui/v2.5/src/components/Movies/MovieDetails/MovieScrapeDialog.tsx b/ui/v2.5/src/components/Movies/MovieDetails/MovieScrapeDialog.tsx index 3ae243ab5aa..eff3c8b284c 100644 --- a/ui/v2.5/src/components/Movies/MovieDetails/MovieScrapeDialog.tsx +++ b/ui/v2.5/src/components/Movies/MovieDetails/MovieScrapeDialog.tsx @@ -6,6 +6,7 @@ import { ScrapedInputGroupRow, ScrapedImageRow, ScrapedTextAreaRow, + ScrapedStringListRow, } from "src/components/Shared/ScrapeDialog/ScrapeDialog"; import TextUtils from "src/utils/text"; import { @@ -15,6 +16,7 @@ import { import { Studio } from "src/components/Studios/StudioSelect"; import { useCreateScrapedStudio } from "src/components/Shared/ScrapeDialog/createObjects"; import { ScrapedStudioRow } from "src/components/Shared/ScrapeDialog/ScrapedObjectsRow"; +import { uniq } from "lodash-es"; interface IMovieScrapeDialogProps { movie: Partial; @@ -64,8 +66,13 @@ export const MovieScrapeDialog: React.FC = ( props.scraped.studio?.stored_id ? props.scraped.studio : undefined ) ); - const [url, setURL] = useState>( - new ScrapeResult(props.movie.url, props.scraped.url) + const [urls, setURLs] = useState>( + new ScrapeResult( + props.movie.urls, + props.scraped.urls + ? uniq((props.movie.urls ?? []).concat(props.scraped.urls ?? [])) + : undefined + ) ); const [frontImage, setFrontImage] = useState>( new ScrapeResult(props.movie.front_image, props.scraped.front_image) @@ -94,7 +101,7 @@ export const MovieScrapeDialog: React.FC = ( director, synopsis, studio, - url, + urls, frontImage, backImage, ]; @@ -117,7 +124,7 @@ export const MovieScrapeDialog: React.FC = ( director: director.getNewValue(), synopsis: synopsis.getNewValue(), studio: newStudioValue, - url: url.getNewValue(), + urls: urls.getNewValue(), front_image: frontImage.getNewValue(), back_image: backImage.getNewValue(), }; @@ -164,10 +171,10 @@ export const MovieScrapeDialog: React.FC = ( newStudio={newStudio} onCreateNew={createNewStudio} /> - setURL(value)} + setURLs(value)} /> = ({ urls, icon = faLink }) => { + if (!urls.length) { + return null; + } + + if (urls.length === 1) { + return ( + + ); + } + + return ( + + + + + + + {urls.map((url) => ( + + {url} + + ))} + + + ); +}; diff --git a/ui/v2.5/src/components/Shared/styles.scss b/ui/v2.5/src/components/Shared/styles.scss index 3736ad52432..983e517834b 100644 --- a/ui/v2.5/src/components/Shared/styles.scss +++ b/ui/v2.5/src/components/Shared/styles.scss @@ -592,3 +592,7 @@ button.btn.favorite-button { } } } + +.external-links-button { + display: inline-block; +} From dcb86d9186a2a78611dc6eeefffea0a0a8c8faae Mon Sep 17 00:00:00 2001 From: Flashy78 <90150289+Flashy78@users.noreply.github.com> Date: Mon, 10 Jun 2024 20:11:41 -0700 Subject: [PATCH 008/103] Allow SSL cert paths to be specified in config (#4910) --- internal/manager/config/config.go | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/internal/manager/config/config.go b/internal/manager/config/config.go index 184d78494eb..d56d3359bab 100644 --- a/internal/manager/config/config.go +++ b/internal/manager/config/config.go @@ -232,6 +232,9 @@ const ( SecurityTripwireAccessedFromPublicInternet = "security_tripwire_accessed_from_public_internet" securityTripwireAccessedFromPublicInternetDefault = "" + sslCertPath = "ssl_cert_path" + sslKeyPath = "ssl_key_path" + // DLNA options DLNAServerName = "dlna.server_name" DLNADefaultEnabled = "dlna.default_enabled" @@ -356,8 +359,17 @@ func (i *Config) InitTLS() { paths.GetStashHomeDirectory(), } - i.certFile = fsutil.FindInPaths(tlsPaths, "stash.crt") - i.keyFile = fsutil.FindInPaths(tlsPaths, "stash.key") + i.certFile = i.getString(sslCertPath) + if i.certFile == "" { + // Look for default file + i.certFile = fsutil.FindInPaths(tlsPaths, "stash.crt") + } + + i.keyFile = i.getString(sslKeyPath) + if i.keyFile == "" { + // Look for default file + i.keyFile = fsutil.FindInPaths(tlsPaths, "stash.key") + } } func (i *Config) GetTLSFiles() (certFile, keyFile string) { From 94a978d0636e86a23cfe38d815c97d744fc0f005 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Tue, 11 Jun 2024 13:12:45 +1000 Subject: [PATCH 009/103] Scraper inputs (#4922) * Pass more details in scene/gallery scrape --- pkg/scraper/cache.go | 20 ++++- pkg/scraper/json.go | 2 +- pkg/scraper/script.go | 197 +++++++++++++++++++++++++++++++++++++++++- pkg/scraper/stash.go | 60 ------------- 4 files changed, 210 insertions(+), 69 deletions(-) diff --git a/pkg/scraper/cache.go b/pkg/scraper/cache.go index 498a4ce3996..167b7676bdd 100644 --- a/pkg/scraper/cache.go +++ b/pkg/scraper/cache.go @@ -53,6 +53,7 @@ func isCDPPathWS(c GlobalConfig) bool { type SceneFinder interface { models.SceneGetter models.URLLoader + models.VideoFileLoader } type PerformerFinder interface { @@ -380,7 +381,15 @@ func (c Cache) getScene(ctx context.Context, sceneID int) (*models.Scene, error) return fmt.Errorf("scene with id %d not found", sceneID) } - return ret.LoadURLs(ctx, qb) + if err := ret.LoadURLs(ctx, qb); err != nil { + return err + } + + if err := ret.LoadFiles(ctx, qb); err != nil { + return err + } + + return nil }); err != nil { return nil, err } @@ -403,12 +412,15 @@ func (c Cache) getGallery(ctx context.Context, galleryID int) (*models.Gallery, return fmt.Errorf("gallery with id %d not found", galleryID) } - err = ret.LoadFiles(ctx, qb) - if err != nil { + if err := ret.LoadURLs(ctx, qb); err != nil { + return err + } + + if err := ret.LoadFiles(ctx, qb); err != nil { return err } - return ret.LoadURLs(ctx, qb) + return nil }); err != nil { return nil, err } diff --git a/pkg/scraper/json.go b/pkg/scraper/json.go index 1d6358a921f..98e85378580 100644 --- a/pkg/scraper/json.go +++ b/pkg/scraper/json.go @@ -66,7 +66,7 @@ func (s *jsonScraper) loadURL(ctx context.Context, url string) (string, error) { return "", errors.New("not valid json") } - if err == nil && s.config.DebugOptions != nil && s.config.DebugOptions.PrintHTML { + if s.config.DebugOptions != nil && s.config.DebugOptions.PrintHTML { logger.Infof("loadURL (%s) response: \n%s", url, docStr) } diff --git a/pkg/scraper/script.go b/pkg/scraper/script.go index bfb03ee3aad..51ee8526259 100644 --- a/pkg/scraper/script.go +++ b/pkg/scraper/script.go @@ -8,14 +8,203 @@ import ( "io" "os/exec" "path/filepath" + "strconv" "strings" stashExec "github.com/stashapp/stash/pkg/exec" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" + stashJson "github.com/stashapp/stash/pkg/models/json" "github.com/stashapp/stash/pkg/python" ) +// inputs for scrapers + +type fingerprintInput struct { + Type string `json:"type,omitempty"` + Fingerprint string `json:"fingerprint,omitempty"` +} + +type fileInput struct { + ID string `json:"id"` + ZipFile *fileInput `json:"zip_file,omitempty"` + ModTime stashJson.JSONTime `json:"mod_time"` + + Path string `json:"path,omitempty"` + + Fingerprints []fingerprintInput `json:"fingerprints,omitempty"` + Size int64 `json:"size,omitempty"` +} + +type videoFileInput struct { + fileInput + Format string `json:"format,omitempty"` + Width int `json:"width,omitempty"` + Height int `json:"height,omitempty"` + Duration float64 `json:"duration,omitempty"` + VideoCodec string `json:"video_codec,omitempty"` + AudioCodec string `json:"audio_codec,omitempty"` + FrameRate float64 `json:"frame_rate,omitempty"` + BitRate int64 `json:"bitrate,omitempty"` + + Interactive bool `json:"interactive,omitempty"` + InteractiveSpeed *int `json:"interactive_speed,omitempty"` +} + +// sceneInput is the input passed to the scraper for an existing scene +type sceneInput struct { + ID string `json:"id"` + Title string `json:"title"` + Code string `json:"code,omitempty"` + + // deprecated - use urls instead + URL *string `json:"url"` + URLs []string `json:"urls"` + + // don't use omitempty for these to maintain backwards compatibility + Date *string `json:"date"` + Details string `json:"details"` + + Director string `json:"director,omitempty"` + + Files []videoFileInput `json:"files,omitempty"` +} + +func fileInputFromFile(f models.BaseFile) fileInput { + b := f.Base() + var z *fileInput + if b.ZipFile != nil { + zz := fileInputFromFile(*b.ZipFile.Base()) + z = &zz + } + + ret := fileInput{ + ID: f.ID.String(), + ZipFile: z, + ModTime: stashJson.JSONTime{Time: f.ModTime}, + Path: f.Path, + Size: f.Size, + } + + for _, fp := range f.Fingerprints { + ret.Fingerprints = append(ret.Fingerprints, fingerprintInput{ + Type: fp.Type, + Fingerprint: fp.Value(), + }) + } + + return ret +} + +func videoFileInputFromVideoFile(vf *models.VideoFile) videoFileInput { + return videoFileInput{ + fileInput: fileInputFromFile(*vf.Base()), + Format: vf.Format, + Width: vf.Width, + Height: vf.Height, + Duration: vf.Duration, + VideoCodec: vf.VideoCodec, + AudioCodec: vf.AudioCodec, + FrameRate: vf.FrameRate, + BitRate: vf.BitRate, + Interactive: vf.Interactive, + InteractiveSpeed: vf.InteractiveSpeed, + } +} + +func sceneInputFromScene(scene *models.Scene) sceneInput { + dateToStringPtr := func(s *models.Date) *string { + if s != nil { + v := s.String() + return &v + } + + return nil + } + + // fallback to file basename if title is empty + title := scene.GetTitle() + + var url *string + urls := scene.URLs.List() + if len(urls) > 0 { + url = &urls[0] + } + + ret := sceneInput{ + ID: strconv.Itoa(scene.ID), + Title: title, + Details: scene.Details, + // include deprecated URL for now + URL: url, + URLs: urls, + Date: dateToStringPtr(scene.Date), + Code: scene.Code, + Director: scene.Director, + } + + for _, f := range scene.Files.List() { + vf := videoFileInputFromVideoFile(f) + ret.Files = append(ret.Files, vf) + } + + return ret +} + +type galleryInput struct { + ID string `json:"id"` + Title string `json:"title"` + Urls []string `json:"urls"` + Date *string `json:"date"` + Details string `json:"details"` + + Code string `json:"code,omitempty"` + Photographer string `json:"photographer,omitempty"` + + Files []fileInput `json:"files,omitempty"` + + // deprecated + URL *string `json:"url"` +} + +func galleryInputFromGallery(gallery *models.Gallery) galleryInput { + dateToStringPtr := func(s *models.Date) *string { + if s != nil { + v := s.String() + return &v + } + + return nil + } + + // fallback to file basename if title is empty + title := gallery.GetTitle() + + var url *string + urls := gallery.URLs.List() + if len(urls) > 0 { + url = &urls[0] + } + + ret := galleryInput{ + ID: strconv.Itoa(gallery.ID), + Title: title, + Details: gallery.Details, + URL: url, + Urls: urls, + Date: dateToStringPtr(gallery.Date), + Code: gallery.Code, + Photographer: gallery.Photographer, + } + + for _, f := range gallery.Files.List() { + fi := fileInputFromFile(*f.Base()) + ret.Files = append(ret.Files, fi) + } + + return ret +} + var ErrScraperScript = errors.New("scraper script error") type scriptScraper struct { @@ -43,7 +232,7 @@ func (s *scriptScraper) runScraperScript(ctx context.Context, inString string, o if err != nil { logger.Warnf("%s", err) } else { - cmd = p.Command(context.TODO(), command[1:]) + cmd = p.Command(ctx, command[1:]) envVariable, _ := filepath.Abs(filepath.Dir(filepath.Dir(s.config.path))) python.AppendPythonPath(cmd, envVariable) } @@ -51,7 +240,7 @@ func (s *scriptScraper) runScraperScript(ctx context.Context, inString string, o if cmd == nil { // if could not find python, just use the command args as-is - cmd = stashExec.Command(command[0], command[1:]...) + cmd = stashExec.CommandContext(ctx, command[0], command[1:]...) } cmd.Dir = filepath.Dir(s.config.path) @@ -205,7 +394,7 @@ func (s *scriptScraper) scrape(ctx context.Context, input string, ty ScrapeConte } func (s *scriptScraper) scrapeSceneByScene(ctx context.Context, scene *models.Scene) (*ScrapedScene, error) { - inString, err := json.Marshal(sceneToUpdateInput(scene)) + inString, err := json.Marshal(sceneInputFromScene(scene)) if err != nil { return nil, err @@ -219,7 +408,7 @@ func (s *scriptScraper) scrapeSceneByScene(ctx context.Context, scene *models.Sc } func (s *scriptScraper) scrapeGalleryByGallery(ctx context.Context, gallery *models.Gallery) (*ScrapedGallery, error) { - inString, err := json.Marshal(galleryToUpdateInput(gallery)) + inString, err := json.Marshal(galleryInputFromGallery(gallery)) if err != nil { return nil, err diff --git a/pkg/scraper/stash.go b/pkg/scraper/stash.go index b7f483667a3..a50db8b5e61 100644 --- a/pkg/scraper/stash.go +++ b/pkg/scraper/stash.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "net/http" - "strconv" "github.com/jinzhu/copier" "github.com/shurcooL/graphql" @@ -310,62 +309,3 @@ func (s *stashScraper) scrapeGalleryByGallery(ctx context.Context, gallery *mode func (s *stashScraper) scrapeByURL(_ context.Context, _ string, _ ScrapeContentType) (ScrapedContent, error) { return nil, ErrNotSupported } - -func sceneToUpdateInput(scene *models.Scene) models.SceneUpdateInput { - dateToStringPtr := func(s *models.Date) *string { - if s != nil { - v := s.String() - return &v - } - - return nil - } - - // fallback to file basename if title is empty - title := scene.GetTitle() - - var url *string - urls := scene.URLs.List() - if len(urls) > 0 { - url = &urls[0] - } - - return models.SceneUpdateInput{ - ID: strconv.Itoa(scene.ID), - Title: &title, - Details: &scene.Details, - // include deprecated URL for now - URL: url, - Urls: urls, - Date: dateToStringPtr(scene.Date), - } -} - -func galleryToUpdateInput(gallery *models.Gallery) models.GalleryUpdateInput { - dateToStringPtr := func(s *models.Date) *string { - if s != nil { - v := s.String() - return &v - } - - return nil - } - - // fallback to file basename if title is empty - title := gallery.GetTitle() - - var url *string - urls := gallery.URLs.List() - if len(urls) > 0 { - url = &urls[0] - } - - return models.GalleryUpdateInput{ - ID: strconv.Itoa(gallery.ID), - Title: &title, - Details: &gallery.Details, - URL: url, - Urls: urls, - Date: dateToStringPtr(gallery.Date), - } -} From ed057c971f0613ce0f1856bae0451c41fa5820aa Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Tue, 11 Jun 2024 13:14:12 +1000 Subject: [PATCH 010/103] Correct Stash box endpoint inputs (#4924) * Use stashbox endpoint instead of index * Update UI to not use deprecated fields --- graphql/schema/types/scraper.graphql | 16 ++++-- graphql/schema/types/stash-box.graphql | 6 ++- internal/api/resolver_mutation_stash_box.go | 50 +++++++++--------- internal/api/resolver_query_scraper.go | 46 ++++++++--------- internal/api/stash_box.go | 45 ++++++++++++++++ internal/manager/manager_tasks.go | 21 ++------ pkg/scraper/stashbox/stash_box.go | 10 ++-- .../src/components/Dialogs/SubmitDraft.tsx | 17 ++++--- .../PerformerStashBoxModal.tsx | 2 +- .../Scenes/SceneDetails/SceneEditPanel.tsx | 4 +- ui/v2.5/src/components/Tagger/constants.ts | 1 - ui/v2.5/src/components/Tagger/context.tsx | 51 +++++++++++-------- .../Tagger/performers/PerformerTagger.tsx | 43 +++++++--------- .../Tagger/scenes/StashSearchResult.tsx | 20 +++++--- .../Tagger/scenes/sceneTaggerModals.tsx | 2 +- .../Tagger/studios/StudioTagger.tsx | 43 ++++++---------- ui/v2.5/src/core/StashService.ts | 42 ++++++++------- 17 files changed, 234 insertions(+), 185 deletions(-) create mode 100644 internal/api/stash_box.go diff --git a/graphql/schema/types/scraper.graphql b/graphql/schema/types/scraper.graphql index 958aff5d228..ccc888dc395 100644 --- a/graphql/schema/types/scraper.graphql +++ b/graphql/schema/types/scraper.graphql @@ -128,7 +128,7 @@ input ScraperSourceInput { stash_box_index: Int @deprecated(reason: "use stash_box_endpoint") "Stash-box endpoint" stash_box_endpoint: String - "Scraper ID to scrape with. Should be unset if stash_box_index is set" + "Scraper ID to scrape with. Should be unset if stash_box_endpoint/stash_box_index is set" scraper_id: ID } @@ -137,7 +137,7 @@ type ScraperSource { stash_box_index: Int @deprecated(reason: "use stash_box_endpoint") "Stash-box endpoint" stash_box_endpoint: String - "Scraper ID to scrape with. Should be unset if stash_box_index is set" + "Scraper ID to scrape with. Should be unset if stash_box_endpoint/stash_box_index is set" scraper_id: ID } @@ -196,7 +196,9 @@ input ScrapeSingleMovieInput { input StashBoxSceneQueryInput { "Index of the configured stash-box instance to use" - stash_box_index: Int! + stash_box_index: Int @deprecated(reason: "use stash_box_endpoint") + "Endpoint of the stash-box instance to use" + stash_box_endpoint: String "Instructs query by scene fingerprints" scene_ids: [ID!] "Query by query string" @@ -205,7 +207,9 @@ input StashBoxSceneQueryInput { input StashBoxPerformerQueryInput { "Index of the configured stash-box instance to use" - stash_box_index: Int! + stash_box_index: Int @deprecated(reason: "use stash_box_endpoint") + "Endpoint of the stash-box instance to use" + stash_box_endpoint: String "Instructs query by scene fingerprints" performer_ids: [ID!] "Query by query string" @@ -226,7 +230,9 @@ type StashBoxFingerprint { "If neither ids nor names are set, tag all items" input StashBoxBatchTagInput { "Stash endpoint to use for the tagging" - endpoint: Int! + endpoint: Int @deprecated(reason: "use stash_box_endpoint") + "Endpoint of the stash-box instance to use" + stash_box_endpoint: String "Fields to exclude when executing the tagging" exclude_fields: [String!] "Refresh items already tagged by StashBox if true. Only tag items with no StashBox tagging if false" diff --git a/graphql/schema/types/stash-box.graphql b/graphql/schema/types/stash-box.graphql index 865311e4ae4..71ea757f443 100644 --- a/graphql/schema/types/stash-box.graphql +++ b/graphql/schema/types/stash-box.graphql @@ -22,10 +22,12 @@ input StashIDInput { input StashBoxFingerprintSubmissionInput { scene_ids: [String!]! - stash_box_index: Int! + stash_box_index: Int @deprecated(reason: "use stash_box_endpoint") + stash_box_endpoint: String } input StashBoxDraftSubmissionInput { id: String! - stash_box_index: Int! + stash_box_index: Int @deprecated(reason: "use stash_box_endpoint") + stash_box_endpoint: String } diff --git a/internal/api/resolver_mutation_stash_box.go b/internal/api/resolver_mutation_stash_box.go index 2198ab6ff4a..b853df65e49 100644 --- a/internal/api/resolver_mutation_stash_box.go +++ b/internal/api/resolver_mutation_stash_box.go @@ -6,41 +6,46 @@ import ( "strconv" "github.com/stashapp/stash/internal/manager" - "github.com/stashapp/stash/internal/manager/config" "github.com/stashapp/stash/pkg/logger" - "github.com/stashapp/stash/pkg/scraper/stashbox" ) func (r *mutationResolver) SubmitStashBoxFingerprints(ctx context.Context, input StashBoxFingerprintSubmissionInput) (bool, error) { - boxes := config.GetInstance().GetStashBoxes() - - if input.StashBoxIndex < 0 || input.StashBoxIndex >= len(boxes) { - return false, fmt.Errorf("invalid stash_box_index %d", input.StashBoxIndex) + b, err := resolveStashBox(input.StashBoxIndex, input.StashBoxEndpoint) + if err != nil { + return false, err } - client := stashbox.NewClient(*boxes[input.StashBoxIndex], r.stashboxRepository()) - - return client.SubmitStashBoxFingerprints(ctx, input.SceneIds, boxes[input.StashBoxIndex].Endpoint) + client := r.newStashBoxClient(*b) + return client.SubmitStashBoxFingerprints(ctx, input.SceneIds) } func (r *mutationResolver) StashBoxBatchPerformerTag(ctx context.Context, input manager.StashBoxBatchTagInput) (string, error) { - jobID := manager.GetInstance().StashBoxBatchPerformerTag(ctx, input) + b, err := resolveStashBoxBatchTagInput(input.Endpoint, input.StashBoxEndpoint) + if err != nil { + return "", err + } + + jobID := manager.GetInstance().StashBoxBatchPerformerTag(ctx, b, input) return strconv.Itoa(jobID), nil } func (r *mutationResolver) StashBoxBatchStudioTag(ctx context.Context, input manager.StashBoxBatchTagInput) (string, error) { - jobID := manager.GetInstance().StashBoxBatchStudioTag(ctx, input) + b, err := resolveStashBoxBatchTagInput(input.Endpoint, input.StashBoxEndpoint) + if err != nil { + return "", err + } + + jobID := manager.GetInstance().StashBoxBatchStudioTag(ctx, b, input) return strconv.Itoa(jobID), nil } func (r *mutationResolver) SubmitStashBoxSceneDraft(ctx context.Context, input StashBoxDraftSubmissionInput) (*string, error) { - boxes := config.GetInstance().GetStashBoxes() - - if input.StashBoxIndex < 0 || input.StashBoxIndex >= len(boxes) { - return nil, fmt.Errorf("invalid stash_box_index %d", input.StashBoxIndex) + b, err := resolveStashBox(input.StashBoxIndex, input.StashBoxEndpoint) + if err != nil { + return nil, err } - client := stashbox.NewClient(*boxes[input.StashBoxIndex], r.stashboxRepository()) + client := r.newStashBoxClient(*b) id, err := strconv.Atoi(input.ID) if err != nil { @@ -68,7 +73,7 @@ func (r *mutationResolver) SubmitStashBoxSceneDraft(ctx context.Context, input S return fmt.Errorf("loading scene URLs: %w", err) } - res, err = client.SubmitSceneDraft(ctx, scene, boxes[input.StashBoxIndex].Endpoint, cover) + res, err = client.SubmitSceneDraft(ctx, scene, cover) return err }) @@ -76,13 +81,12 @@ func (r *mutationResolver) SubmitStashBoxSceneDraft(ctx context.Context, input S } func (r *mutationResolver) SubmitStashBoxPerformerDraft(ctx context.Context, input StashBoxDraftSubmissionInput) (*string, error) { - boxes := config.GetInstance().GetStashBoxes() - - if input.StashBoxIndex < 0 || input.StashBoxIndex >= len(boxes) { - return nil, fmt.Errorf("invalid stash_box_index %d", input.StashBoxIndex) + b, err := resolveStashBox(input.StashBoxIndex, input.StashBoxEndpoint) + if err != nil { + return nil, err } - client := stashbox.NewClient(*boxes[input.StashBoxIndex], r.stashboxRepository()) + client := r.newStashBoxClient(*b) id, err := strconv.Atoi(input.ID) if err != nil { @@ -101,7 +105,7 @@ func (r *mutationResolver) SubmitStashBoxPerformerDraft(ctx context.Context, inp return fmt.Errorf("performer with id %d not found", id) } - res, err = client.SubmitPerformerDraft(ctx, performer, boxes[input.StashBoxIndex].Endpoint) + res, err = client.SubmitPerformerDraft(ctx, performer) return err }) diff --git a/internal/api/resolver_query_scraper.go b/internal/api/resolver_query_scraper.go index 5f27db3dec1..4a65c52f5c6 100644 --- a/internal/api/resolver_query_scraper.go +++ b/internal/api/resolver_query_scraper.go @@ -9,7 +9,6 @@ import ( "strings" "github.com/stashapp/stash/internal/manager" - "github.com/stashapp/stash/internal/manager/config" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/scraper" @@ -190,18 +189,6 @@ func (r *queryResolver) ScrapeMovieURL(ctx context.Context, url string) (*models return marshalScrapedMovie(content) } -func (r *queryResolver) getStashBoxClient(index int) (*stashbox.Client, error) { - boxes := config.GetInstance().GetStashBoxes() - - if index < 0 || index >= len(boxes) { - return nil, fmt.Errorf("%w: invalid stash_box_index %d", ErrInput, index) - } - - return stashbox.NewClient(*boxes[index], r.stashboxRepository()), nil -} - -// FIXME - in the following resolvers, we're processing the deprecated field and not processing the new endpoint input - func (r *queryResolver) ScrapeSingleScene(ctx context.Context, source scraper.Source, input ScrapeSingleSceneInput) ([]*scraper.ScrapedScene, error) { var ret []*scraper.ScrapedScene @@ -245,12 +232,14 @@ func (r *queryResolver) ScrapeSingleScene(ctx context.Context, source scraper.So if err != nil { return nil, err } - case source.StashBoxIndex != nil: - client, err := r.getStashBoxClient(*source.StashBoxIndex) + case source.StashBoxIndex != nil || source.StashBoxEndpoint != nil: + b, err := resolveStashBox(source.StashBoxIndex, source.StashBoxEndpoint) if err != nil { return nil, err } + client := r.newStashBoxClient(*b) + switch { case input.SceneID != nil: ret, err = client.FindStashBoxSceneByFingerprints(ctx, sceneID) @@ -275,12 +264,14 @@ func (r *queryResolver) ScrapeSingleScene(ctx context.Context, source scraper.So func (r *queryResolver) ScrapeMultiScenes(ctx context.Context, source scraper.Source, input ScrapeMultiScenesInput) ([][]*scraper.ScrapedScene, error) { if source.ScraperID != nil { return nil, ErrNotImplemented - } else if source.StashBoxIndex != nil { - client, err := r.getStashBoxClient(*source.StashBoxIndex) + } else if source.StashBoxIndex != nil || source.StashBoxEndpoint != nil { + b, err := resolveStashBox(source.StashBoxIndex, source.StashBoxEndpoint) if err != nil { return nil, err } + client := r.newStashBoxClient(*b) + sceneIDs, err := stringslice.StringSliceToIntSlice(input.SceneIds) if err != nil { return nil, err @@ -293,12 +284,14 @@ func (r *queryResolver) ScrapeMultiScenes(ctx context.Context, source scraper.So } func (r *queryResolver) ScrapeSingleStudio(ctx context.Context, source scraper.Source, input ScrapeSingleStudioInput) ([]*models.ScrapedStudio, error) { - if source.StashBoxIndex != nil { - client, err := r.getStashBoxClient(*source.StashBoxIndex) + if source.StashBoxIndex != nil || source.StashBoxEndpoint != nil { + b, err := resolveStashBox(source.StashBoxIndex, source.StashBoxEndpoint) if err != nil { return nil, err } + client := r.newStashBoxClient(*b) + var ret []*models.ScrapedStudio out, err := client.FindStashBoxStudio(ctx, *input.Query) @@ -346,13 +339,14 @@ func (r *queryResolver) ScrapeSinglePerformer(ctx context.Context, source scrape default: return nil, ErrNotImplemented } - // FIXME - we're relying on a deprecated field and not processing the endpoint input - case source.StashBoxIndex != nil: - client, err := r.getStashBoxClient(*source.StashBoxIndex) + case source.StashBoxIndex != nil || source.StashBoxEndpoint != nil: + b, err := resolveStashBox(source.StashBoxIndex, source.StashBoxEndpoint) if err != nil { return nil, err } + client := r.newStashBoxClient(*b) + var res []*stashbox.StashBoxPerformerQueryResult switch { case input.PerformerID != nil: @@ -382,12 +376,14 @@ func (r *queryResolver) ScrapeSinglePerformer(ctx context.Context, source scrape func (r *queryResolver) ScrapeMultiPerformers(ctx context.Context, source scraper.Source, input ScrapeMultiPerformersInput) ([][]*models.ScrapedPerformer, error) { if source.ScraperID != nil { return nil, ErrNotImplemented - } else if source.StashBoxIndex != nil { - client, err := r.getStashBoxClient(*source.StashBoxIndex) + } else if source.StashBoxIndex != nil || source.StashBoxEndpoint != nil { + b, err := resolveStashBox(source.StashBoxIndex, source.StashBoxEndpoint) if err != nil { return nil, err } + client := r.newStashBoxClient(*b) + return client.FindStashBoxPerformersByPerformerNames(ctx, input.PerformerIds) } @@ -397,7 +393,7 @@ func (r *queryResolver) ScrapeMultiPerformers(ctx context.Context, source scrape func (r *queryResolver) ScrapeSingleGallery(ctx context.Context, source scraper.Source, input ScrapeSingleGalleryInput) ([]*scraper.ScrapedGallery, error) { var ret []*scraper.ScrapedGallery - if source.StashBoxIndex != nil { + if source.StashBoxIndex != nil || source.StashBoxEndpoint != nil { return nil, ErrNotSupported } diff --git a/internal/api/stash_box.go b/internal/api/stash_box.go new file mode 100644 index 00000000000..6aa5e6ddcda --- /dev/null +++ b/internal/api/stash_box.go @@ -0,0 +1,45 @@ +package api + +import ( + "fmt" + "strings" + + "github.com/stashapp/stash/internal/manager/config" + "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/scraper/stashbox" +) + +func (r *Resolver) newStashBoxClient(box models.StashBox) *stashbox.Client { + return stashbox.NewClient(box, r.stashboxRepository()) +} + +func resolveStashBoxFn(indexField, endpointField string) func(index *int, endpoint *string) (*models.StashBox, error) { + return func(index *int, endpoint *string) (*models.StashBox, error) { + boxes := config.GetInstance().GetStashBoxes() + + // prefer endpoint over index + if endpoint != nil { + for _, box := range boxes { + if strings.EqualFold(*endpoint, box.Endpoint) { + return box, nil + } + } + return nil, fmt.Errorf("stash box not found") + } + + if index != nil { + if *index < 0 || *index >= len(boxes) { + return nil, fmt.Errorf("invalid %s %d", indexField, index) + } + + return boxes[*index], nil + } + + return nil, fmt.Errorf("%s not provided", endpointField) + } +} + +var ( + resolveStashBox = resolveStashBoxFn("stash_box_index", "stash_box_endpoint") + resolveStashBoxBatchTagInput = resolveStashBoxFn("endpoint", "stash_box_endpoint") +) diff --git a/internal/manager/manager_tasks.go b/internal/manager/manager_tasks.go index dd2b9dcc29a..b85a4c2cf75 100644 --- a/internal/manager/manager_tasks.go +++ b/internal/manager/manager_tasks.go @@ -366,8 +366,9 @@ func (s *Manager) MigrateHash(ctx context.Context) int { // If neither ids nor names are set, tag all items type StashBoxBatchTagInput struct { - // Stash endpoint to use for the tagging - Endpoint int `json:"endpoint"` + // Stash endpoint to use for the tagging - deprecated - use StashBoxEndpoint + Endpoint *int `json:"endpoint"` + StashBoxEndpoint *string `json:"stash_box_endpoint"` // Fields to exclude when executing the tagging ExcludeFields []string `json:"exclude_fields"` // Refresh items already tagged by StashBox if true. Only tag items with no StashBox tagging if false @@ -388,16 +389,10 @@ type StashBoxBatchTagInput struct { PerformerNames []string `json:"performer_names"` } -func (s *Manager) StashBoxBatchPerformerTag(ctx context.Context, input StashBoxBatchTagInput) int { +func (s *Manager) StashBoxBatchPerformerTag(ctx context.Context, box *models.StashBox, input StashBoxBatchTagInput) int { j := job.MakeJobExec(func(ctx context.Context, progress *job.Progress) error { logger.Infof("Initiating stash-box batch performer tag") - boxes := config.GetInstance().GetStashBoxes() - if input.Endpoint < 0 || input.Endpoint >= len(boxes) { - return fmt.Errorf("invalid stash_box_index %d", input.Endpoint) - } - box := boxes[input.Endpoint] - var tasks []StashBoxBatchTagTask // The gocritic linter wants to turn this ifElseChain into a switch. @@ -526,16 +521,10 @@ func (s *Manager) StashBoxBatchPerformerTag(ctx context.Context, input StashBoxB return s.JobManager.Add(ctx, "Batch stash-box performer tag...", j) } -func (s *Manager) StashBoxBatchStudioTag(ctx context.Context, input StashBoxBatchTagInput) int { +func (s *Manager) StashBoxBatchStudioTag(ctx context.Context, box *models.StashBox, input StashBoxBatchTagInput) int { j := job.MakeJobExec(func(ctx context.Context, progress *job.Progress) error { logger.Infof("Initiating stash-box batch studio tag") - boxes := config.GetInstance().GetStashBoxes() - if input.Endpoint < 0 || input.Endpoint >= len(boxes) { - return fmt.Errorf("invalid stash_box_index %d", input.Endpoint) - } - box := boxes[input.Endpoint] - var tasks []StashBoxBatchTagTask // The gocritic linter wants to turn this ifElseChain into a switch. diff --git a/pkg/scraper/stashbox/stash_box.go b/pkg/scraper/stashbox/stash_box.go index c833d3d0ca7..407238dae2e 100644 --- a/pkg/scraper/stashbox/stash_box.go +++ b/pkg/scraper/stashbox/stash_box.go @@ -251,12 +251,14 @@ func (c Client) findStashBoxScenesByFingerprints(ctx context.Context, scenes [][ return ret, nil } -func (c Client) SubmitStashBoxFingerprints(ctx context.Context, sceneIDs []string, endpoint string) (bool, error) { +func (c Client) SubmitStashBoxFingerprints(ctx context.Context, sceneIDs []string) (bool, error) { ids, err := stringslice.StringSliceToIntSlice(sceneIDs) if err != nil { return false, err } + endpoint := c.box.Endpoint + var fingerprints []graphql.FingerprintSubmission r := c.repository @@ -945,12 +947,13 @@ func appendFingerprintUnique(v []*graphql.FingerprintInput, toAdd *graphql.Finge return append(v, toAdd) } -func (c Client) SubmitSceneDraft(ctx context.Context, scene *models.Scene, endpoint string, cover []byte) (*string, error) { +func (c Client) SubmitSceneDraft(ctx context.Context, scene *models.Scene, cover []byte) (*string, error) { draft := graphql.SceneDraftInput{} var image io.Reader r := c.repository pqb := r.Performer sqb := r.Studio + endpoint := c.box.Endpoint if scene.Title != "" { draft.Title = &scene.Title @@ -1115,10 +1118,11 @@ func (c Client) SubmitSceneDraft(ctx context.Context, scene *models.Scene, endpo // return id, nil } -func (c Client) SubmitPerformerDraft(ctx context.Context, performer *models.Performer, endpoint string) (*string, error) { +func (c Client) SubmitPerformerDraft(ctx context.Context, performer *models.Performer) (*string, error) { draft := graphql.PerformerDraftInput{} var image io.Reader pqb := c.repository.Performer + endpoint := c.box.Endpoint if err := performer.LoadAliases(ctx, pqb); err != nil { return nil, err diff --git a/ui/v2.5/src/components/Dialogs/SubmitDraft.tsx b/ui/v2.5/src/components/Dialogs/SubmitDraft.tsx index 03d41063138..2521ed02c62 100644 --- a/ui/v2.5/src/components/Dialogs/SubmitDraft.tsx +++ b/ui/v2.5/src/components/Dialogs/SubmitDraft.tsx @@ -52,17 +52,18 @@ export const SubmitStashBoxDraft: React.FC = ({ }, [show, type, boxes, entity]); async function doSubmit() { + if (!selectedBox) return; + + const input = { + id: entity.id, + stash_box_endpoint: selectedBox.endpoint, + }; + if (type === "scene") { - const r = await mutateSubmitStashBoxSceneDraft({ - id: entity.id, - stash_box_index: selectedBoxIndex, - }); + const r = await mutateSubmitStashBoxSceneDraft(input); return r.data?.submitStashBoxSceneDraft; } else if (type === "performer") { - const r = await mutateSubmitStashBoxPerformerDraft({ - id: entity.id, - stash_box_index: selectedBoxIndex, - }); + const r = await mutateSubmitStashBoxPerformerDraft(input); return r.data?.submitStashBoxPerformerDraft; } } diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerStashBoxModal.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerStashBoxModal.tsx index 773cd62d28d..8d7c9178b67 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerStashBoxModal.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerStashBoxModal.tsx @@ -160,7 +160,7 @@ const PerformerStashBoxModal: React.FC = ({ const { data, loading } = GQL.useScrapeSinglePerformerQuery({ variables: { source: { - stash_box_index: instance.index, + stash_box_endpoint: instance.endpoint, }, input: { query, diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx index bee912c599f..a79422db984 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx @@ -419,7 +419,6 @@ export const SceneEditPanel: React.FC = ({ key={s.endpoint} onClick={() => onScrapeQueryClicked({ - stash_box_index: index, stash_box_endpoint: s.endpoint, }) } @@ -451,7 +450,7 @@ export const SceneEditPanel: React.FC = ({ function onSceneSelected(s: GQL.ScrapedSceneDataFragment) { if (!scraper) return; - if (scraper?.stash_box_index !== undefined) { + if (scraper?.stash_box_endpoint !== undefined) { // must be stash-box - assume full scene setScrapedScene(s); } else { @@ -491,7 +490,6 @@ export const SceneEditPanel: React.FC = ({ key={s.endpoint} onClick={() => onScrapeClicked({ - stash_box_index: index, stash_box_endpoint: s.endpoint, }) } diff --git a/ui/v2.5/src/components/Tagger/constants.ts b/ui/v2.5/src/components/Tagger/constants.ts index a180757b11c..cbfacc76d54 100644 --- a/ui/v2.5/src/components/Tagger/constants.ts +++ b/ui/v2.5/src/components/Tagger/constants.ts @@ -5,7 +5,6 @@ export const SCRAPER_PREFIX = "scraper:"; export interface ITaggerSource { id: string; - stashboxEndpoint?: string; sourceInput: ScraperSourceInput; displayName: string; supportSceneQuery?: boolean; diff --git a/ui/v2.5/src/components/Tagger/context.tsx b/ui/v2.5/src/components/Tagger/context.tsx index cbdb73b2cb5..832d9335999 100644 --- a/ui/v2.5/src/components/Tagger/context.tsx +++ b/ui/v2.5/src/components/Tagger/context.tsx @@ -146,10 +146,9 @@ export const TaggerContext: React.FC = ({ children }) => { const scrapers = Scrapers.data.listScrapers; const stashboxSources: ITaggerSource[] = stashBoxes.map((s, i) => ({ - id: `${STASH_BOX_PREFIX}${i}`, - stashboxEndpoint: s.endpoint, + id: `${STASH_BOX_PREFIX}${s.endpoint}`, sourceInput: { - stash_box_index: i, + stash_box_endpoint: s.endpoint, }, displayName: `stash-box: ${s.name || `#${i + 1}`}`, supportSceneFragment: true, @@ -192,14 +191,14 @@ export const TaggerContext: React.FC = ({ children }) => { }, [currentSource]); function getPendingFingerprints() { - const endpoint = currentSource?.stashboxEndpoint; + const endpoint = currentSource?.sourceInput.stash_box_endpoint; if (!config || !endpoint) return []; return config.fingerprintQueue[endpoint] ?? []; } function clearSubmissionQueue() { - const endpoint = currentSource?.stashboxEndpoint; + const endpoint = currentSource?.sourceInput.stash_box_endpoint; if (!config || !endpoint) return; setConfig({ @@ -215,18 +214,16 @@ export const TaggerContext: React.FC = ({ children }) => { GQL.useSubmitStashBoxFingerprintsMutation(); async function submitFingerprints() { - const endpoint = currentSource?.stashboxEndpoint; - const stashBoxIndex = - currentSource?.sourceInput.stash_box_index ?? undefined; + const endpoint = currentSource?.sourceInput.stash_box_endpoint; - if (!config || !endpoint || stashBoxIndex === undefined) return; + if (!config || !endpoint) return; try { setLoading(true); await submitFingerprintsMutation({ variables: { input: { - stash_box_index: stashBoxIndex, + stash_box_endpoint: endpoint, scene_ids: config.fingerprintQueue[endpoint], }, }, @@ -241,7 +238,7 @@ export const TaggerContext: React.FC = ({ children }) => { } function queueFingerprintSubmission(sceneId: string) { - const endpoint = currentSource?.stashboxEndpoint; + const endpoint = currentSource?.sourceInput.stash_box_endpoint; if (!config || !endpoint) return; setConfig({ @@ -276,7 +273,8 @@ export const TaggerContext: React.FC = ({ children }) => { ); let newResult: ISceneQueryResult; // scenes are already resolved if they come from stash-box - const resolved = currentSource.sourceInput.stash_box_index !== undefined; + const resolved = + currentSource.sourceInput.stash_box_endpoint !== undefined; if (results.error) { newResult = { error: results.error.message }; @@ -365,13 +363,16 @@ export const TaggerContext: React.FC = ({ children }) => { setLoading(true); setMultiError(undefined); - const stashBoxIndex = - currentSource.sourceInput.stash_box_index ?? undefined; + const stashBoxEndpoint = + currentSource.sourceInput.stash_box_endpoint ?? undefined; // if current source is stash-box, we can use the multi-scene // interface - if (stashBoxIndex !== undefined) { - const results = await stashBoxSceneBatchQuery(sceneIDs, stashBoxIndex); + if (stashBoxEndpoint !== undefined) { + const results = await stashBoxSceneBatchQuery( + sceneIDs, + stashBoxEndpoint + ); if (results.error) { setMultiError(results.error.message); @@ -604,7 +605,11 @@ export const TaggerContext: React.FC = ({ children }) => { performer: GQL.ScrapedPerformer, performerID: string ) { - if (!performer.remote_site_id || !currentSource?.stashboxEndpoint) return; + if ( + !performer.remote_site_id || + !currentSource?.sourceInput.stash_box_endpoint + ) + return; try { const queryResult = await queryFindPerformer(performerID); @@ -620,7 +625,7 @@ export const TaggerContext: React.FC = ({ children }) => { stashIDs.push({ stash_id: performer.remote_site_id, - endpoint: currentSource?.stashboxEndpoint, + endpoint: currentSource?.sourceInput.stash_box_endpoint, }); await updatePerformer({ @@ -722,7 +727,7 @@ export const TaggerContext: React.FC = ({ children }) => { const studioID = result.data?.studioUpdate?.id; const stashID = input.stash_ids?.find((e) => { - return e.endpoint === currentSource?.stashboxEndpoint; + return e.endpoint === currentSource?.sourceInput.stash_box_endpoint; })?.stash_id; if (stashID) { @@ -757,7 +762,11 @@ export const TaggerContext: React.FC = ({ children }) => { } async function linkStudio(studio: GQL.ScrapedStudio, studioID: string) { - if (!studio.remote_site_id || !currentSource?.stashboxEndpoint) return; + if ( + !studio.remote_site_id || + !currentSource?.sourceInput.stash_box_endpoint + ) + return; try { const queryResult = await queryFindStudio(studioID); @@ -773,7 +782,7 @@ export const TaggerContext: React.FC = ({ children }) => { stashIDs.push({ stash_id: studio.remote_site_id, - endpoint: currentSource?.stashboxEndpoint, + endpoint: currentSource?.sourceInput.stash_box_endpoint, }); await updateStudio({ diff --git a/ui/v2.5/src/components/Tagger/performers/PerformerTagger.tsx b/ui/v2.5/src/components/Tagger/performers/PerformerTagger.tsx index 6ba6734c86f..311d8007ad3 100755 --- a/ui/v2.5/src/components/Tagger/performers/PerformerTagger.tsx +++ b/ui/v2.5/src/components/Tagger/performers/PerformerTagger.tsx @@ -238,7 +238,6 @@ interface IPerformerTaggerListProps { selectedEndpoint: { endpoint: string; index: number }; isIdle: boolean; config: ITaggerConfig; - stashBoxes?: GQL.StashBox[]; onBatchAdd: (performerInput: string) => void; onBatchUpdate: (ids: string[] | undefined, refresh: boolean) => void; } @@ -248,7 +247,6 @@ const PerformerTaggerList: React.FC = ({ selectedEndpoint, isIdle, config, - stashBoxes, onBatchAdd, onBatchUpdate, }) => { @@ -277,7 +275,7 @@ const PerformerTaggerList: React.FC = ({ >(); const doBoxSearch = (performerID: string, searchVal: string) => { - stashBoxPerformerQuery(searchVal, selectedEndpoint.index) + stashBoxPerformerQuery(searchVal, selectedEndpoint.endpoint) .then((queryData) => { const s = queryData.data?.scrapeSinglePerformer ?? []; setSearchResults({ @@ -309,14 +307,14 @@ const PerformerTaggerList: React.FC = ({ const doBoxUpdate = ( performerID: string, stashID: string, - endpointIndex: number + endpoint: string ) => { setLoadingUpdate(stashID); setError({ ...error, [performerID]: undefined, }); - stashBoxPerformerQuery(stashID, endpointIndex) + stashBoxPerformerQuery(stashID, endpoint) .then((queryData) => { const data = queryData.data?.scrapeSinglePerformer ?? []; if (data.length > 0) { @@ -477,29 +475,27 @@ const PerformerTaggerList: React.FC = ({
{stashID.stash_id}
); - const endpointIndex = - stashBoxes?.findIndex((box) => box.endpoint === stashID.endpoint) ?? - -1; - subContent = (
{link} - {endpointIndex !== -1 && ( - - )} + {error[performer.id] && ( @@ -787,7 +783,6 @@ export const PerformerTagger: React.FC = ({ performers }) => { }} isIdle={batchJobID === undefined} config={config} - stashBoxes={stashConfig?.general.stashBoxes} onBatchAdd={batchAdd} onBatchUpdate={batchUpdate} /> diff --git a/ui/v2.5/src/components/Tagger/scenes/StashSearchResult.tsx b/ui/v2.5/src/components/Tagger/scenes/StashSearchResult.tsx index 7a8f97837ef..cc8f6a132e6 100755 --- a/ui/v2.5/src/components/Tagger/scenes/StashSearchResult.tsx +++ b/ui/v2.5/src/components/Tagger/scenes/StashSearchResult.tsx @@ -327,8 +327,8 @@ const StashSearchResult: React.FC = ({ } }, [isActive, loading, stashScene, index, resolveScene, scene]); - const stashBoxBaseURL = currentSource?.stashboxEndpoint - ? getStashboxBase(currentSource.stashboxEndpoint) + const stashBoxBaseURL = currentSource?.sourceInput.stash_box_endpoint + ? getStashboxBase(currentSource.sourceInput.stash_box_endpoint) : undefined; const stashBoxURL = useMemo(() => { if (stashBoxBaseURL) { @@ -404,7 +404,7 @@ const StashSearchResult: React.FC = ({ const includeStashID = !excludedFieldList.includes("stash_ids"); if ( includeStashID && - currentSource?.stashboxEndpoint && + currentSource?.sourceInput.stash_box_endpoint && scene.remote_site_id ) { sceneCreateInput.stash_ids = [ @@ -415,9 +415,11 @@ const StashSearchResult: React.FC = ({ stash_id: s.stash_id, }; }) - .filter((s) => s.endpoint !== currentSource.stashboxEndpoint) ?? []), + .filter( + (s) => s.endpoint !== currentSource.sourceInput.stash_box_endpoint + ) ?? []), { - endpoint: currentSource.stashboxEndpoint, + endpoint: currentSource.sourceInput.stash_box_endpoint, stash_id: scene.remote_site_id, }, ]; @@ -662,7 +664,9 @@ const StashSearchResult: React.FC = ({ selectedID={studioID} setSelectedID={(id) => setStudioID(id)} onCreate={() => showStudioModal(scene.studio!)} - endpoint={currentSource?.stashboxEndpoint} + endpoint={ + currentSource?.sourceInput.stash_box_endpoint ?? undefined + } onLink={async () => { await linkStudio(scene.studio!, studioID!); }} @@ -691,7 +695,9 @@ const StashSearchResult: React.FC = ({ onLink={async () => { await linkPerformer(performer, performerIDs[performerIndex]!); }} - endpoint={currentSource?.stashboxEndpoint} + endpoint={ + currentSource?.sourceInput.stash_box_endpoint ?? undefined + } key={`${performer.name ?? performer.remote_site_id ?? ""}`} /> ))} diff --git a/ui/v2.5/src/components/Tagger/scenes/sceneTaggerModals.tsx b/ui/v2.5/src/components/Tagger/scenes/sceneTaggerModals.tsx index 324047e5636..816e4e29469 100644 --- a/ui/v2.5/src/components/Tagger/scenes/sceneTaggerModals.tsx +++ b/ui/v2.5/src/components/Tagger/scenes/sceneTaggerModals.tsx @@ -106,7 +106,7 @@ export const SceneTaggerModals: React.FC = ({ children }) => { setStudioCallback(() => callback); } - const endpoint = currentSource?.stashboxEndpoint; + const endpoint = currentSource?.sourceInput.stash_box_endpoint ?? undefined; return ( void; onBatchUpdate: ( ids: string[] | undefined, @@ -282,7 +281,6 @@ const StudioTaggerList: React.FC = ({ selectedEndpoint, isIdle, config, - stashBoxes, onBatchAdd, onBatchUpdate, }) => { @@ -315,7 +313,7 @@ const StudioTaggerList: React.FC = ({ >(); const doBoxSearch = (studioID: string, searchVal: string) => { - stashBoxStudioQuery(searchVal, selectedEndpoint.index) + stashBoxStudioQuery(searchVal, selectedEndpoint.endpoint) .then((queryData) => { const s = queryData.data?.scrapeSingleStudio ?? []; setSearchResults({ @@ -344,17 +342,13 @@ const StudioTaggerList: React.FC = ({ setLoading(true); }; - const doBoxUpdate = ( - studioID: string, - stashID: string, - endpointIndex: number - ) => { + const doBoxUpdate = (studioID: string, stashID: string, endpoint: string) => { setLoadingUpdate(stashID); setError({ ...error, [studioID]: undefined, }); - stashBoxStudioQuery(stashID, endpointIndex) + stashBoxStudioQuery(stashID, endpoint) .then((queryData) => { const data = queryData.data?.scrapeSingleStudio ?? []; if (data.length > 0) { @@ -535,29 +529,23 @@ const StudioTaggerList: React.FC = ({
{stashID.stash_id}
); - const endpointIndex = - stashBoxes?.findIndex((box) => box.endpoint === stashID.endpoint) ?? - -1; - subContent = (
{link} - {endpointIndex !== -1 && ( - - )} + {error[studio.id] && ( @@ -849,7 +837,6 @@ export const StudioTagger: React.FC = ({ studios }) => { }} isIdle={batchJobID === undefined} config={config} - stashBoxes={stashConfig?.general.stashBoxes} onBatchAdd={batchAdd} onBatchUpdate={batchUpdate} /> diff --git a/ui/v2.5/src/core/StashService.ts b/ui/v2.5/src/core/StashService.ts index 5a7b6d811bd..7559210a555 100644 --- a/ui/v2.5/src/core/StashService.ts +++ b/ui/v2.5/src/core/StashService.ts @@ -2054,19 +2054,21 @@ export const queryScrapeSceneQueryFragment = ( export const stashBoxSceneBatchQuery = ( sceneIds: string[], - stashBoxIndex: number + stashBoxEndpoint: string ) => - client.query({ - query: GQL.ScrapeMultiScenesDocument, - variables: { - source: { - stash_box_index: stashBoxIndex, - }, - input: { - scene_ids: sceneIds, + client.query( + { + query: GQL.ScrapeMultiScenesDocument, + variables: { + source: { + stash_box_endpoint: stashBoxEndpoint, + }, + input: { + scene_ids: sceneIds, + }, }, - }, - }); + } + ); export const useListPerformerScrapers = () => GQL.useListPerformerScrapersQuery(); @@ -2110,13 +2112,16 @@ export const queryScrapePerformerURL = (url: string) => export const stashBoxPerformerQuery = ( searchVal: string, - stashBoxIndex: number + stashBoxEndpoint: string ) => - client.query({ + client.query< + GQL.ScrapeSinglePerformerQuery, + GQL.ScrapeSinglePerformerQueryVariables + >({ query: GQL.ScrapeSinglePerformerDocument, variables: { source: { - stash_box_index: stashBoxIndex, + stash_box_endpoint: stashBoxEndpoint, }, input: { query: searchVal, @@ -2127,13 +2132,16 @@ export const stashBoxPerformerQuery = ( export const stashBoxStudioQuery = ( query: string | null, - stashBoxIndex: number + stashBoxEndpoint: string ) => - client.query({ + client.query< + GQL.ScrapeSingleStudioQuery, + GQL.ScrapeSingleStudioQueryVariables + >({ query: GQL.ScrapeSingleStudioDocument, variables: { source: { - stash_box_index: stashBoxIndex, + stash_box_endpoint: stashBoxEndpoint, }, input: { query: query, From 845d718c67c2c3c6b713a6ffbfc246fa9922ebd5 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Tue, 11 Jun 2024 13:18:45 +1000 Subject: [PATCH 011/103] Plugin api improvements (#4935) * Support hook into App component * Add hookable PluginSettings component * Add useSettings to plugin hooks * Make setting inputs hookable * Add hooks for performer details panel * Update docs --- ui/v2.5/src/App.tsx | 80 ++-- .../PerformerDetailsPanel.tsx | 414 +++++++++--------- ui/v2.5/src/components/Settings/Inputs.tsx | 323 +++++++------- .../Settings/SettingsPluginsPanel.tsx | 66 +-- ui/v2.5/src/docs/en/Manual/UIPluginApi.md | 16 + ui/v2.5/src/pluginApi.d.ts | 41 +- ui/v2.5/src/pluginApi.tsx | 2 + 7 files changed, 521 insertions(+), 421 deletions(-) diff --git a/ui/v2.5/src/App.tsx b/ui/v2.5/src/App.tsx index d670786afdb..b3ff5e10fae 100644 --- a/ui/v2.5/src/App.tsx +++ b/ui/v2.5/src/App.tsx @@ -50,6 +50,7 @@ import { PluginRoutes } from "./plugins"; // import plugin_api to run code import "./pluginApi"; import { ConnectionMonitor } from "./ConnectionMonitor"; +import { PatchFunction } from "./patch"; const Performers = lazyComponent( () => import("./components/Performers/Performers") @@ -144,6 +145,13 @@ function sortPlugins(plugins: PluginList) { return sorted; } +const AppContainer: React.FC> = PatchFunction( + "App", + (props: React.PropsWithChildren<{}>) => { + return <>{props.children}; + } +) as React.FC; + export const App: React.FC = () => { const config = useConfiguration(); const [saveUI] = useConfigureUI(); @@ -357,41 +365,43 @@ export const App: React.FC = () => { const titleProps = makeTitleProps(); return ( - - {messages ? ( - - + + {messages ? ( + - {maybeRenderReleaseNotes()} - - - }> - - - - - {maybeRenderNavbar()} -
- {renderContent()} -
-
-
-
-
-
-
-
- ) : null} -
+ + {maybeRenderReleaseNotes()} + + + }> + + + + + {maybeRenderNavbar()} +
+ {renderContent()} +
+
+
+
+
+
+
+ + ) : null} + + ); }; diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerDetailsPanel.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerDetailsPanel.tsx index 2a330a6f1f9..94e27a3628d 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerDetailsPanel.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerDetailsPanel.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { PropsWithChildren } from "react"; import { useIntl } from "react-intl"; import { TagLink } from "src/components/Shared/TagLink"; import * as GQL from "src/core/generated-graphql"; @@ -13,6 +13,7 @@ import { FormatPenisLength, FormatWeight, } from "../PerformerList"; +import { PatchComponent } from "src/patch"; interface IPerformerDetails { performer: GQL.PerformerDataFragment; @@ -20,231 +21,236 @@ interface IPerformerDetails { fullWidth?: boolean; } -export const PerformerDetailsPanel: React.FC = ({ - performer, - collapsed, - fullWidth, -}) => { - // Network state - const intl = useIntl(); +const PerformerDetailGroup: React.FC> = + PatchComponent("PerformerDetailsPanel.DetailGroup", ({ children }) => { + return
{children}
; + }); - function renderTagsField() { - if (!performer.tags.length) { - return; - } - return ( -
    - {(performer.tags ?? []).map((tag) => ( - - ))} -
- ); - } +export const PerformerDetailsPanel: React.FC = + PatchComponent("PerformerDetailsPanel", (props) => { + const { performer, collapsed, fullWidth } = props; - function renderStashIDs() { - if (!performer.stash_ids.length) { - return; + // Network state + const intl = useIntl(); + + function renderTagsField() { + if (!performer.tags.length) { + return; + } + return ( +
    + {(performer.tags ?? []).map((tag) => ( + + ))} +
+ ); } - return ( -
    - {performer.stash_ids.map((stashID) => ( -
  • - -
  • - ))} -
- ); - } + function renderStashIDs() { + if (!performer.stash_ids.length) { + return; + } - function maybeRenderExtraDetails() { - if (!collapsed) { - /* Remove extra urls provided in details since they will be present by perfomr name */ - /* This code can be removed once multple urls are supported for performers */ - let details = performer?.details - ?.replace(/\[((?:http|www\.)[^\n\]]+)\]/gm, "") - .trim(); return ( - <> - - - - - - - +
    + {performer.stash_ids.map((stashID) => ( +
  • + +
  • + ))} +
); } - } - return ( -
- {performer.gender ? ( - - ) : ( - "" - )} - - - {performer.country ? ( - + - } - fullWidth={fullWidth} - /> - ) : ( - "" - )} - - - - - - - - - - {maybeRenderExtraDetails()} -
- ); -}; - -export const CompressedPerformerDetailsPanel: React.FC = ({ - performer, -}) => { - // Network state - const intl = useIntl(); - - function scrollToTop() { - window.scrollTo({ top: 0, behavior: "smooth" }); - } + + + + + + + ); + } + } - return ( -
-
- scrollToTop()}> - {performer.name} - + return ( + {performer.gender ? ( - <> - / - - {intl.formatMessage({ id: "gender_types." + performer.gender })} - - - ) : ( - "" - )} - {performer.birthdate ? ( - <> - / - - {TextUtils.age(performer.birthdate, performer.death_date)} - - + ) : ( "" )} + + {performer.country ? ( - <> - / - + - - + } + fullWidth={fullWidth} + /> ) : ( "" )} + + + + + + + + + + {maybeRenderExtraDetails()} + + ); + }); + +export const CompressedPerformerDetailsPanel: React.FC = + PatchComponent("CompressedPerformerDetailsPanel", ({ performer }) => { + // Network state + const intl = useIntl(); + + function scrollToTop() { + window.scrollTo({ top: 0, behavior: "smooth" }); + } + + return ( +
+
+ scrollToTop()}> + {performer.name} + + {performer.gender ? ( + <> + / + + {intl.formatMessage({ id: "gender_types." + performer.gender })} + + + ) : ( + "" + )} + {performer.birthdate ? ( + <> + / + + {TextUtils.age(performer.birthdate, performer.death_date)} + + + ) : ( + "" + )} + {performer.country ? ( + <> + / + + + + + ) : ( + "" + )} +
-
- ); -}; + ); + }); diff --git a/ui/v2.5/src/components/Settings/Inputs.tsx b/ui/v2.5/src/components/Settings/Inputs.tsx index 61353011f53..073be4e11d9 100644 --- a/ui/v2.5/src/components/Settings/Inputs.tsx +++ b/ui/v2.5/src/components/Settings/Inputs.tsx @@ -92,60 +92,58 @@ interface ISettingGroup { collapsedDefault?: boolean; } -export const SettingGroup: React.FC> = ({ - settingProps, - topLevel, - collapsible, - collapsedDefault, - children, -}) => { - const [open, setOpen] = useState(!collapsedDefault); - - function renderCollapseButton() { - if (!collapsible) return; +export const SettingGroup: React.FC> = + PatchComponent( + "SettingGroup", + ({ settingProps, topLevel, collapsible, collapsedDefault, children }) => { + const [open, setOpen] = useState(!collapsedDefault); - return ( - - ); - } + function renderCollapseButton() { + if (!collapsible) return; - function onDivClick(e: React.MouseEvent) { - if (!collapsible) return; - - // ensure button was not clicked - let target: HTMLElement | null = e.target as HTMLElement; - while (target && target !== e.currentTarget) { - if ( - target.nodeName.toLowerCase() === "button" || - target.nodeName.toLowerCase() === "a" - ) { - // button clicked, swallow event - return; + return ( + + ); } - target = target.parentElement; - } - setOpen(!open); - } + function onDivClick(e: React.MouseEvent) { + if (!collapsible) return; + + // ensure button was not clicked + let target: HTMLElement | null = e.target as HTMLElement; + while (target && target !== e.currentTarget) { + if ( + target.nodeName.toLowerCase() === "button" || + target.nodeName.toLowerCase() === "a" + ) { + // button clicked, swallow event + return; + } + target = target.parentElement; + } - return ( -
- - {topLevel} - {renderCollapseButton()} - - -
{children}
-
-
+ setOpen(!open); + } + + return ( +
+ + {topLevel} + {renderCollapseButton()} + + +
{children}
+
+
+ ); + } ); -}; interface IBooleanSetting extends ISetting { id: string; @@ -153,53 +151,52 @@ interface IBooleanSetting extends ISetting { onChange: (v: boolean) => void; } -export const BooleanSetting: React.FC = (props) => { - const { id, disabled, checked, onChange, ...settingProps } = props; +export const BooleanSetting: React.FC = PatchComponent( + "BooleanSetting", + (props) => { + const { id, disabled, checked, onChange, ...settingProps } = props; - return ( - - onChange(!checked)} - /> - - ); -}; + return ( + + onChange(!checked)} + /> + + ); + } +); interface ISelectSetting extends ISetting { value?: string | number | string[]; onChange: (v: string) => void; } -export const SelectSetting: React.FC> = ({ - id, - headingID, - subHeadingID, - value, - children, - onChange, - advanced, -}) => { - return ( - - onChange(e.currentTarget.value)} - > - {children} - - +export const SelectSetting: React.FC> = + PatchComponent( + "SelectSetting", + ({ id, headingID, subHeadingID, value, children, onChange, advanced }) => { + return ( + + onChange(e.currentTarget.value)} + > + {children} + + + ); + } ); -}; interface IDialogSetting extends ISetting { buttonText?: string; @@ -208,8 +205,7 @@ interface IDialogSetting extends ISetting { renderValue?: (v: T | undefined) => JSX.Element; onChange: () => void; } - -export const ChangeButtonSetting = (props: IDialogSetting) => { +const _ChangeButtonSetting = (props: IDialogSetting) => { const { id, className, @@ -266,6 +262,11 @@ export const ChangeButtonSetting = (props: IDialogSetting) => { ); }; +export const ChangeButtonSetting = PatchComponent( + "ChangeButtonSetting", + _ChangeButtonSetting +) as typeof _ChangeButtonSetting; + export interface ISettingModal { heading?: React.ReactNode; headingID?: string; @@ -283,7 +284,7 @@ export interface ISettingModal { error?: string | undefined; } -export const SettingModal = (props: ISettingModal) => { +const _SettingModal = (props: ISettingModal) => { const { heading, headingID, @@ -342,6 +343,11 @@ export const SettingModal = (props: ISettingModal) => { ); }; +export const SettingModal = PatchComponent( + "SettingModal", + _SettingModal +) as typeof _SettingModal; + interface IModalSetting extends ISetting { value: T | undefined; buttonText?: string; @@ -357,7 +363,7 @@ interface IModalSetting extends ISetting { validateChange?: (v: T) => void | undefined; } -export const ModalSetting = (props: IModalSetting) => { +export const _ModalSetting = (props: IModalSetting) => { const { id, className, @@ -435,52 +441,63 @@ export const ModalSetting = (props: IModalSetting) => { ); }; +export const ModalSetting = PatchComponent( + "ModalSetting", + _ModalSetting +) as typeof _ModalSetting; + interface IStringSetting extends ISetting { value: string | undefined; onChange: (v: string) => void; } -export const StringSetting: React.FC = (props) => { - return ( - - {...props} - renderField={(value, setValue) => ( - ) => - setValue(e.currentTarget.value) - } - /> - )} - renderValue={(value) => {value}} - /> - ); -}; +export const StringSetting: React.FC = PatchComponent( + "StringSetting", + (props) => { + return ( + + {...props} + renderField={(value, setValue) => ( + ) => + setValue(e.currentTarget.value) + } + /> + )} + renderValue={(value) => {value}} + /> + ); + } +); interface INumberSetting extends ISetting { value: number | undefined; onChange: (v: number) => void; } -export const NumberSetting: React.FC = (props) => { - return ( - - {...props} - renderField={(value, setValue) => ( - ) => - setValue(Number.parseInt(e.currentTarget.value || "0", 10)) - } - /> - )} - renderValue={(value) => {value}} - /> - ); -}; +export const NumberSetting: React.FC = PatchComponent( + "NumberSetting", + (props) => { + return ( + + {...props} + renderField={(value, setValue) => ( + ) => + setValue(Number.parseInt(e.currentTarget.value || "0", 10)) + } + /> + )} + renderValue={(value) => {value}} + /> + ); + } +); interface IStringListSetting extends ISetting { value: string[] | undefined; @@ -488,35 +505,38 @@ interface IStringListSetting extends ISetting { onChange: (v: string[]) => void; } -export const StringListSetting: React.FC = (props) => { - return ( - - {...props} - renderField={(value, setValue) => ( - - )} - renderValue={(value) => ( -
- {value?.map((v, i) => ( - // eslint-disable-next-line react/no-array-index-key -
{v}
- ))} -
- )} - /> - ); -}; +export const StringListSetting: React.FC = PatchComponent( + "StringListSetting", + (props) => { + return ( + + {...props} + renderField={(value, setValue) => ( + + )} + renderValue={(value) => ( +
+ {value?.map((v, i) => ( + // eslint-disable-next-line react/no-array-index-key +
{v}
+ ))} +
+ )} + /> + ); + } +); interface IConstantSetting extends ISetting { value?: T; renderValue?: (v: T | undefined) => JSX.Element; } -export const ConstantSetting = (props: IConstantSetting) => { +export const _ConstantSetting = (props: IConstantSetting) => { const { id, headingID, subHeading, subHeadingID, renderValue, value } = props; const intl = useIntl(); @@ -539,3 +559,8 @@ export const ConstantSetting = (props: IConstantSetting) => {
); }; + +export const ConstantSetting = PatchComponent( + "ConstantSetting", + _ConstantSetting +) as typeof _ConstantSetting; diff --git a/ui/v2.5/src/components/Settings/SettingsPluginsPanel.tsx b/ui/v2.5/src/components/Settings/SettingsPluginsPanel.tsx index 102d236f1ed..a4ad2b5a7ad 100644 --- a/ui/v2.5/src/components/Settings/SettingsPluginsPanel.tsx +++ b/ui/v2.5/src/components/Settings/SettingsPluginsPanel.tsx @@ -27,6 +27,7 @@ import { InstalledPluginPackages, } from "./PluginPackageManager"; import { ExternalLink } from "../Shared/ExternalLink"; +import { PatchComponent } from "src/patch"; interface IPluginSettingProps { pluginID: string; @@ -75,11 +76,38 @@ const PluginSetting: React.FC = ({ } }; +const PluginSettings: React.FC<{ + pluginID: string; + settings: GQL.PluginSetting[]; +}> = PatchComponent("PluginSettings", ({ pluginID, settings }) => { + const { plugins, savePluginSettings } = useSettings(); + const pluginSettings = plugins[pluginID] ?? {}; + + return ( +
+ {settings.map((setting) => ( + + savePluginSettings(pluginID, { + ...pluginSettings, + [setting.name]: v, + }) + } + /> + ))} +
+ ); +}); + export const SettingsPluginsPanel: React.FC = () => { const Toast = useToast(); const intl = useIntl(); - const { loading: configLoading, plugins, savePluginSettings } = useSettings(); + const { loading: configLoading } = useSettings(); const { data, loading } = usePlugins(); const [changedPluginID, setChangedPluginID] = React.useState< @@ -163,7 +191,10 @@ export const SettingsPluginsPanel: React.FC = () => { } > {renderPluginHooks(plugin.hooks ?? undefined)} - {renderPluginSettings(plugin.id, plugin.settings ?? [])} + )); @@ -208,37 +239,8 @@ export const SettingsPluginsPanel: React.FC = () => { ); } - function renderPluginSettings( - pluginID: string, - settings: GQL.PluginSetting[] - ) { - const pluginSettings = plugins[pluginID] ?? {}; - - return settings.map((setting) => ( - - savePluginSettings(pluginID, { - ...pluginSettings, - [setting.name]: v, - }) - } - /> - )); - } - return renderPlugins(); - }, [ - data?.plugins, - intl, - Toast, - changedPluginID, - plugins, - savePluginSettings, - ]); + }, [data?.plugins, intl, Toast, changedPluginID]); if (loading || configLoading) return ; diff --git a/ui/v2.5/src/docs/en/Manual/UIPluginApi.md b/ui/v2.5/src/docs/en/Manual/UIPluginApi.md index 2ad2b714a7e..fd96cc52dc0 100644 --- a/ui/v2.5/src/docs/en/Manual/UIPluginApi.md +++ b/ui/v2.5/src/docs/en/Manual/UIPluginApi.md @@ -139,6 +139,11 @@ Returns `void`. #### Patchable components and functions +- `App` +- `BooleanSetting` +- `ChangeButtonSetting` +- `CompressedPerformerDetailsPanel` +- `ConstantSetting` - `CountrySelect` - `DateInput` - `FolderSelect` @@ -146,9 +151,13 @@ Returns `void`. - `GallerySelect` - `GallerySelect.sort` - `Icon` +- `ModalSetting` - `MovieIDSelect` - `MovieSelect` - `MovieSelect.sort` +- `NumberSetting` +- `PerformerDetailsPanel` +- `PerformerDetailsPanel.DetailGroup` - `PerformerIDSelect` - `PerformerSelect` - `PerformerSelect.sort` @@ -161,13 +170,20 @@ Returns `void`. - `SceneIDSelect` - `SceneSelect` - `SceneSelect.sort` +- `SelectSetting` - `Setting` +- `SettingModal` +- `StringSetting` +- `StringListSetting` - `StudioIDSelect` - `StudioSelect` - `StudioSelect.sort` - `TagIDSelect` - `TagSelect` - `TagSelect.sort` +- `PluginSettings` +- `Setting` +- `SettingGroup` ### `PluginApi.Event` diff --git a/ui/v2.5/src/pluginApi.d.ts b/ui/v2.5/src/pluginApi.d.ts index f16e672aa5f..4967eedbfa9 100644 --- a/ui/v2.5/src/pluginApi.d.ts +++ b/ui/v2.5/src/pluginApi.d.ts @@ -681,7 +681,18 @@ declare namespace PluginApi { "SceneCard.Details": React.FC; "SceneCard.Overlays": React.FC; "SceneCard.Image": React.FC; - SceneCard: React.FC; + PluginSettings: React.FC; + Setting: React.FC; + SettingGroup: React.FC; + BooleanSetting: React.FC; + SelectSetting: React.FC; + ChangeButtonSetting: React.FC; + SettingModal: React.FC; + ModalSetting: React.FC; + StringSetting: React.FC; + NumberSetting: React.FC; + StringListSetting: React.FC; + ConstantSetting: React.FC; }; namespace utils { namespace NavUtils { @@ -922,6 +933,34 @@ declare namespace PluginApi { success(message: JSX.Element | string): void; error(error: unknown): void; }; + + function useSettings(): { + loading: boolean; + error: any | undefined; + general: any; + interface: any; + defaults: any; + scraping: any; + dlna: any; + ui: any; + plugins: any; + + advancedMode: boolean; + + // apikey isn't directly settable, so expose it here + apiKey: string; + + saveGeneral: (input: any) => void; + saveInterface: (input: any) => void; + saveDefaults: (input: any) => void; + saveScraping: (input: any) => void; + saveDLNA: (input: any) => void; + saveUI: (input: any) => void; + savePluginSettings: (pluginID: string, input: {}) => void; + setAdvancedMode: (value: boolean) => void; + + refetch: () => void; + }; } namespace patch { function before(target: string, fn: Function): void; diff --git a/ui/v2.5/src/pluginApi.tsx b/ui/v2.5/src/pluginApi.tsx index f3c2ee7d58b..f35fb0fe6f0 100644 --- a/ui/v2.5/src/pluginApi.tsx +++ b/ui/v2.5/src/pluginApi.tsx @@ -15,6 +15,7 @@ import { useSpriteInfo } from "./hooks/sprite"; import { useToast } from "./hooks/Toast"; import Event from "./hooks/event"; import { before, instead, after, components, RegisterComponent } from "./patch"; +import { useSettings } from "./components/Settings/context"; // due to code splitting, some components may not have been loaded when a plugin // page is loaded. This function will load all components passed to it. @@ -92,6 +93,7 @@ export const PluginApi = { useLoadComponents, useSpriteInfo, useToast, + useSettings, }, patch: { // intercept the arguments of supported functions From da4d49d94016a5c7e086153051b6c46450ee7f85 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 11 Jun 2024 13:19:35 +1000 Subject: [PATCH 012/103] Bump braces from 3.0.2 to 3.0.3 in /ui/v2.5 (#4955) Bumps [braces](https://github.com/micromatch/braces) from 3.0.2 to 3.0.3. - [Changelog](https://github.com/micromatch/braces/blob/master/CHANGELOG.md) - [Commits](https://github.com/micromatch/braces/compare/3.0.2...3.0.3) --- updated-dependencies: - dependency-name: braces dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- ui/v2.5/yarn.lock | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/ui/v2.5/yarn.lock b/ui/v2.5/yarn.lock index ae117fd9a0c..ca0e9cbf7e1 100644 --- a/ui/v2.5/yarn.lock +++ b/ui/v2.5/yarn.lock @@ -3076,11 +3076,11 @@ brace-expansion@^1.1.7: concat-map "0.0.1" braces@^3.0.2, braces@~3.0.2: - version "3.0.2" - resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107" - integrity sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A== + version "3.0.3" + resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.3.tgz#490332f40919452272d55a8480adc0c441358789" + integrity sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA== dependencies: - fill-range "^7.0.1" + fill-range "^7.1.1" browserslist@^4.21.4, browserslist@^4.21.5, browserslist@^4.22.2: version "4.22.3" @@ -4266,10 +4266,10 @@ file-entry-cache@^6.0.1: dependencies: flat-cache "^3.0.4" -fill-range@^7.0.1: - version "7.0.1" - resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.0.1.tgz#1919a6a7c75fe38b2c7c77e5198535da9acdda40" - integrity sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ== +fill-range@^7.1.1: + version "7.1.1" + resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.1.1.tgz#44265d3cac07e3ea7dc247516380643754a05292" + integrity sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg== dependencies: to-regex-range "^5.0.1" From e18c050fb12c89628dc37556706e5ab7a43a2725 Mon Sep 17 00:00:00 2001 From: Maista <131594657+Maista6969@users.noreply.github.com> Date: Tue, 11 Jun 2024 05:21:39 +0200 Subject: [PATCH 013/103] Add log and utils modules to the Javascript VM used in scrapers (#4937) --- pkg/scraper/mapped.go | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/pkg/scraper/mapped.go b/pkg/scraper/mapped.go index f8a09601503..1b24379cab0 100644 --- a/pkg/scraper/mapped.go +++ b/pkg/scraper/mapped.go @@ -534,6 +534,21 @@ func (p *postProcessJavascript) Apply(ctx context.Context, value string, q mappe return value } + log := &javascript.Log{ + Logger: logger.Logger, + Prefix: "", + ProgressChan: make(chan float64), + } + + if err := log.AddToVM("log", vm); err != nil { + logger.Logger.Errorf("error adding log API: %w", err) + } + + util := &javascript.Util{} + if err := util.AddToVM("util", vm); err != nil { + logger.Logger.Errorf("error adding util API: %w", err) + } + script, err := javascript.CompileScript("", "(function() { "+string(*p)+"})()") if err != nil { logger.Warnf("javascript failed to compile: %v", err) From 2d483f2d116e64b408ee2d133fa8b111790c3daf Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Tue, 11 Jun 2024 13:41:20 +1000 Subject: [PATCH 014/103] Bulk edit tags (#4925) * Refactor tag relationships and add bulk edit * Add bulk edit tags dialog --- graphql/schema/schema.graphql | 1 + graphql/schema/types/tag.graphql | 11 + internal/api/resolver_model_tag.go | 44 ++-- internal/api/resolver_mutation_tag.go | 175 ++++++------- pkg/models/mocks/TagReaderWriter.go | 46 ++++ pkg/models/model_tag.go | 27 ++ pkg/models/relationships.go | 5 + pkg/models/repository_tag.go | 1 + pkg/sqlite/tables.go | 21 ++ pkg/sqlite/tag.go | 66 +++++ pkg/tag/update.go | 119 ++++++--- pkg/tag/update_test.go | 19 +- pkg/tag/validate.go | 102 ++++++++ ui/v2.5/graphql/mutations/tag.graphql | 6 + .../src/components/Tags/EditTagsDialog.tsx | 237 ++++++++++++++++++ ui/v2.5/src/components/Tags/TagList.tsx | 9 + ui/v2.5/src/core/StashService.ts | 11 + 17 files changed, 736 insertions(+), 164 deletions(-) create mode 100644 pkg/tag/validate.go create mode 100644 ui/v2.5/src/components/Tags/EditTagsDialog.tsx diff --git a/graphql/schema/schema.graphql b/graphql/schema/schema.graphql index 5ec16b17b07..c5b8d60892d 100644 --- a/graphql/schema/schema.graphql +++ b/graphql/schema/schema.graphql @@ -325,6 +325,7 @@ type Mutation { tagDestroy(input: TagDestroyInput!): Boolean! tagsDestroy(ids: [ID!]!): Boolean! tagsMerge(input: TagsMergeInput!): Tag + bulkTagUpdate(input: BulkTagUpdateInput!): [Tag!] """ Moves the given files to the given destination. Returns true if successful. diff --git a/graphql/schema/types/tag.graphql b/graphql/schema/types/tag.graphql index 69b8221c5f1..6438b52e1fa 100644 --- a/graphql/schema/types/tag.graphql +++ b/graphql/schema/types/tag.graphql @@ -60,3 +60,14 @@ input TagsMergeInput { source: [ID!]! destination: ID! } + +input BulkTagUpdateInput { + ids: [ID!] + description: String + aliases: BulkUpdateStrings + ignore_auto_tag: Boolean + favorite: Boolean + + parent_ids: BulkUpdateIds + child_ids: BulkUpdateIds +} diff --git a/internal/api/resolver_model_tag.go b/internal/api/resolver_model_tag.go index 9124b18f483..d219fcc66d7 100644 --- a/internal/api/resolver_model_tag.go +++ b/internal/api/resolver_model_tag.go @@ -3,6 +3,7 @@ package api import ( "context" + "github.com/stashapp/stash/internal/api/loaders" "github.com/stashapp/stash/internal/api/urlbuilders" "github.com/stashapp/stash/pkg/gallery" "github.com/stashapp/stash/pkg/image" @@ -12,36 +13,43 @@ import ( ) func (r *tagResolver) Parents(ctx context.Context, obj *models.Tag) (ret []*models.Tag, err error) { - if err := r.withReadTxn(ctx, func(ctx context.Context) error { - ret, err = r.repository.Tag.FindByChildTagID(ctx, obj.ID) - return err - }); err != nil { - return nil, err + if !obj.ParentIDs.Loaded() { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { + return obj.LoadParentIDs(ctx, r.repository.Tag) + }); err != nil { + return nil, err + } } - return ret, nil + var errs []error + ret, errs = loaders.From(ctx).TagByID.LoadAll(obj.ParentIDs.List()) + return ret, firstError(errs) } func (r *tagResolver) Children(ctx context.Context, obj *models.Tag) (ret []*models.Tag, err error) { - if err := r.withReadTxn(ctx, func(ctx context.Context) error { - ret, err = r.repository.Tag.FindByParentTagID(ctx, obj.ID) - return err - }); err != nil { - return nil, err + if !obj.ChildIDs.Loaded() { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { + return obj.LoadChildIDs(ctx, r.repository.Tag) + }); err != nil { + return nil, err + } } - return ret, nil + var errs []error + ret, errs = loaders.From(ctx).TagByID.LoadAll(obj.ChildIDs.List()) + return ret, firstError(errs) } func (r *tagResolver) Aliases(ctx context.Context, obj *models.Tag) (ret []string, err error) { - if err := r.withReadTxn(ctx, func(ctx context.Context) error { - ret, err = r.repository.Tag.GetAliases(ctx, obj.ID) - return err - }); err != nil { - return nil, err + if !obj.Aliases.Loaded() { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { + return obj.LoadAliases(ctx, r.repository.Tag) + }); err != nil { + return nil, err + } } - return ret, err + return obj.Aliases.List(), nil } func (r *tagResolver) SceneCount(ctx context.Context, obj *models.Tag, depth *int) (ret int, err error) { diff --git a/internal/api/resolver_mutation_tag.go b/internal/api/resolver_mutation_tag.go index 2c3128c58d4..2554f1bb55f 100644 --- a/internal/api/resolver_mutation_tag.go +++ b/internal/api/resolver_mutation_tag.go @@ -33,26 +33,21 @@ func (r *mutationResolver) TagCreate(ctx context.Context, input TagCreateInput) newTag := models.NewTag() newTag.Name = input.Name + newTag.Aliases = models.NewRelatedStrings(input.Aliases) newTag.Favorite = translator.bool(input.Favorite) newTag.Description = translator.string(input.Description) newTag.IgnoreAutoTag = translator.bool(input.IgnoreAutoTag) var err error - var parentIDs []int - if len(input.ParentIds) > 0 { - parentIDs, err = stringslice.StringSliceToIntSlice(input.ParentIds) - if err != nil { - return nil, fmt.Errorf("converting parent ids: %w", err) - } + newTag.ParentIDs, err = translator.relatedIds(input.ParentIds) + if err != nil { + return nil, fmt.Errorf("converting parent tag ids: %w", err) } - var childIDs []int - if len(input.ChildIds) > 0 { - childIDs, err = stringslice.StringSliceToIntSlice(input.ChildIds) - if err != nil { - return nil, fmt.Errorf("converting child ids: %w", err) - } + newTag.ChildIDs, err = translator.relatedIds(input.ChildIds) + if err != nil { + return nil, fmt.Errorf("converting child tag ids: %w", err) } // Process the base 64 encoded image string @@ -68,8 +63,7 @@ func (r *mutationResolver) TagCreate(ctx context.Context, input TagCreateInput) if err := r.withTxn(ctx, func(ctx context.Context) error { qb := r.repository.Tag - // ensure name is unique - if err := tag.EnsureTagNameUnique(ctx, 0, newTag.Name, qb); err != nil { + if err := tag.ValidateCreate(ctx, newTag, qb); err != nil { return err } @@ -85,36 +79,6 @@ func (r *mutationResolver) TagCreate(ctx context.Context, input TagCreateInput) } } - if len(input.Aliases) > 0 { - if err := tag.EnsureAliasesUnique(ctx, newTag.ID, input.Aliases, qb); err != nil { - return err - } - - if err := qb.UpdateAliases(ctx, newTag.ID, input.Aliases); err != nil { - return err - } - } - - if len(parentIDs) > 0 { - if err := qb.UpdateParentTags(ctx, newTag.ID, parentIDs); err != nil { - return err - } - } - - if len(childIDs) > 0 { - if err := qb.UpdateChildTags(ctx, newTag.ID, childIDs); err != nil { - return err - } - } - - // FIXME: This should be called before any changes are made, but - // requires a rewrite of ValidateHierarchy. - if len(parentIDs) > 0 || len(childIDs) > 0 { - if err := tag.ValidateHierarchy(ctx, &newTag, parentIDs, childIDs, qb); err != nil { - return err - } - } - return nil }); err != nil { return nil, err @@ -137,24 +101,21 @@ func (r *mutationResolver) TagUpdate(ctx context.Context, input TagUpdateInput) // Populate tag from the input updatedTag := models.NewTagPartial() + updatedTag.Name = translator.optionalString(input.Name, "name") updatedTag.Favorite = translator.optionalBool(input.Favorite, "favorite") updatedTag.IgnoreAutoTag = translator.optionalBool(input.IgnoreAutoTag, "ignore_auto_tag") updatedTag.Description = translator.optionalString(input.Description, "description") - var parentIDs []int - if translator.hasField("parent_ids") { - parentIDs, err = stringslice.StringSliceToIntSlice(input.ParentIds) - if err != nil { - return nil, fmt.Errorf("converting parent ids: %w", err) - } + updatedTag.Aliases = translator.updateStrings(input.Aliases, "aliases") + + updatedTag.ParentIDs, err = translator.updateIds(input.ParentIds, "parent_ids") + if err != nil { + return nil, fmt.Errorf("converting parent tag ids: %w", err) } - var childIDs []int - if translator.hasField("child_ids") { - childIDs, err = stringslice.StringSliceToIntSlice(input.ChildIds) - if err != nil { - return nil, fmt.Errorf("converting child ids: %w", err) - } + updatedTag.ChildIDs, err = translator.updateIds(input.ChildIds, "child_ids") + if err != nil { + return nil, fmt.Errorf("converting child tag ids: %w", err) } var imageData []byte @@ -171,24 +132,10 @@ func (r *mutationResolver) TagUpdate(ctx context.Context, input TagUpdateInput) if err := r.withTxn(ctx, func(ctx context.Context) error { qb := r.repository.Tag - // ensure name is unique - t, err = qb.Find(ctx, tagID) - if err != nil { + if err := tag.ValidateUpdate(ctx, tagID, updatedTag, qb); err != nil { return err } - if t == nil { - return fmt.Errorf("tag with id %d not found", tagID) - } - - if input.Name != nil && t.Name != *input.Name { - if err := tag.EnsureTagNameUnique(ctx, tagID, *input.Name, qb); err != nil { - return err - } - - updatedTag.Name = models.NewOptionalString(*input.Name) - } - t, err = qb.UpdatePartial(ctx, tagID, updatedTag) if err != nil { return err @@ -201,35 +148,61 @@ func (r *mutationResolver) TagUpdate(ctx context.Context, input TagUpdateInput) } } - if translator.hasField("aliases") { - if err := tag.EnsureAliasesUnique(ctx, tagID, input.Aliases, qb); err != nil { - return err - } + return nil + }); err != nil { + return nil, err + } - if err := qb.UpdateAliases(ctx, tagID, input.Aliases); err != nil { - return err - } - } + r.hookExecutor.ExecutePostHooks(ctx, t.ID, hook.TagUpdatePost, input, translator.getFields()) + return r.getTag(ctx, t.ID) +} - if parentIDs != nil { - if err := qb.UpdateParentTags(ctx, tagID, parentIDs); err != nil { - return err - } - } +func (r *mutationResolver) BulkTagUpdate(ctx context.Context, input BulkTagUpdateInput) ([]*models.Tag, error) { + tagIDs, err := stringslice.StringSliceToIntSlice(input.Ids) + if err != nil { + return nil, fmt.Errorf("converting ids: %w", err) + } + + translator := changesetTranslator{ + inputMap: getUpdateInputMap(ctx), + } + + // Populate scene from the input + updatedTag := models.NewTagPartial() + + updatedTag.Description = translator.optionalString(input.Description, "description") + updatedTag.Favorite = translator.optionalBool(input.Favorite, "favorite") + updatedTag.IgnoreAutoTag = translator.optionalBool(input.IgnoreAutoTag, "ignore_auto_tag") + + updatedTag.Aliases = translator.updateStringsBulk(input.Aliases, "aliases") + + updatedTag.ParentIDs, err = translator.updateIdsBulk(input.ParentIds, "parent_ids") + if err != nil { + return nil, fmt.Errorf("converting parent tag ids: %w", err) + } + + updatedTag.ChildIDs, err = translator.updateIdsBulk(input.ChildIds, "child_ids") + if err != nil { + return nil, fmt.Errorf("converting child tag ids: %w", err) + } + + ret := []*models.Tag{} + + // Start the transaction and save the scenes + if err := r.withTxn(ctx, func(ctx context.Context) error { + qb := r.repository.Tag - if childIDs != nil { - if err := qb.UpdateChildTags(ctx, tagID, childIDs); err != nil { + for _, tagID := range tagIDs { + if err := tag.ValidateUpdate(ctx, tagID, updatedTag, qb); err != nil { return err } - } - // FIXME: This should be called before any changes are made, but - // requires a rewrite of ValidateHierarchy. - if parentIDs != nil || childIDs != nil { - if err := tag.ValidateHierarchy(ctx, t, parentIDs, childIDs, qb); err != nil { - logger.Errorf("Error saving tag: %s", err) + tag, err := qb.UpdatePartial(ctx, tagID, updatedTag) + if err != nil { return err } + + ret = append(ret, tag) } return nil @@ -237,8 +210,20 @@ func (r *mutationResolver) TagUpdate(ctx context.Context, input TagUpdateInput) return nil, err } - r.hookExecutor.ExecutePostHooks(ctx, t.ID, hook.TagUpdatePost, input, translator.getFields()) - return r.getTag(ctx, t.ID) + // execute post hooks outside of txn + var newRet []*models.Tag + for _, tag := range ret { + r.hookExecutor.ExecutePostHooks(ctx, tag.ID, hook.TagUpdatePost, input, translator.getFields()) + + tag, err = r.getTag(ctx, tag.ID) + if err != nil { + return nil, err + } + + newRet = append(newRet, tag) + } + + return newRet, nil } func (r *mutationResolver) TagDestroy(ctx context.Context, input TagDestroyInput) (bool, error) { @@ -331,7 +316,7 @@ func (r *mutationResolver) TagsMerge(ctx context.Context, input TagsMergeInput) return err } - err = tag.ValidateHierarchy(ctx, t, parents, children, qb) + err = tag.ValidateHierarchyExisting(ctx, t, parents, children, qb) if err != nil { logger.Errorf("Error merging tag: %s", err) return err diff --git a/pkg/models/mocks/TagReaderWriter.go b/pkg/models/mocks/TagReaderWriter.go index 9b610e49b6e..f4c494016f3 100644 --- a/pkg/models/mocks/TagReaderWriter.go +++ b/pkg/models/mocks/TagReaderWriter.go @@ -450,6 +450,29 @@ func (_m *TagReaderWriter) GetAliases(ctx context.Context, relatedID int) ([]str return r0, r1 } +// GetChildIDs provides a mock function with given fields: ctx, relatedID +func (_m *TagReaderWriter) GetChildIDs(ctx context.Context, relatedID int) ([]int, error) { + ret := _m.Called(ctx, relatedID) + + var r0 []int + if rf, ok := ret.Get(0).(func(context.Context, int) []int); ok { + r0 = rf(ctx, relatedID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]int) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { + r1 = rf(ctx, relatedID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // GetImage provides a mock function with given fields: ctx, tagID func (_m *TagReaderWriter) GetImage(ctx context.Context, tagID int) ([]byte, error) { ret := _m.Called(ctx, tagID) @@ -473,6 +496,29 @@ func (_m *TagReaderWriter) GetImage(ctx context.Context, tagID int) ([]byte, err return r0, r1 } +// GetParentIDs provides a mock function with given fields: ctx, relatedID +func (_m *TagReaderWriter) GetParentIDs(ctx context.Context, relatedID int) ([]int, error) { + ret := _m.Called(ctx, relatedID) + + var r0 []int + if rf, ok := ret.Get(0).(func(context.Context, int) []int); ok { + r0 = rf(ctx, relatedID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]int) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { + r1 = rf(ctx, relatedID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // HasImage provides a mock function with given fields: ctx, tagID func (_m *TagReaderWriter) HasImage(ctx context.Context, tagID int) (bool, error) { ret := _m.Called(ctx, tagID) diff --git a/pkg/models/model_tag.go b/pkg/models/model_tag.go index 04f5ac1a2ec..e8a797e8760 100644 --- a/pkg/models/model_tag.go +++ b/pkg/models/model_tag.go @@ -1,6 +1,7 @@ package models import ( + "context" "time" ) @@ -12,6 +13,10 @@ type Tag struct { IgnoreAutoTag bool `json:"ignore_auto_tag"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` + + Aliases RelatedStrings `json:"aliases"` + ParentIDs RelatedIDs `json:"parent_ids"` + ChildIDs RelatedIDs `json:"tag_ids"` } func NewTag() Tag { @@ -22,6 +27,24 @@ func NewTag() Tag { } } +func (s *Tag) LoadAliases(ctx context.Context, l AliasLoader) error { + return s.Aliases.load(func() ([]string, error) { + return l.GetAliases(ctx, s.ID) + }) +} + +func (s *Tag) LoadParentIDs(ctx context.Context, l TagRelationLoader) error { + return s.ParentIDs.load(func() ([]int, error) { + return l.GetParentIDs(ctx, s.ID) + }) +} + +func (s *Tag) LoadChildIDs(ctx context.Context, l TagRelationLoader) error { + return s.ChildIDs.load(func() ([]int, error) { + return l.GetChildIDs(ctx, s.ID) + }) +} + type TagPartial struct { Name OptionalString Description OptionalString @@ -29,6 +52,10 @@ type TagPartial struct { IgnoreAutoTag OptionalBool CreatedAt OptionalTime UpdatedAt OptionalTime + + Aliases *UpdateStrings + ParentIDs *UpdateIDs + ChildIDs *UpdateIDs } func NewTagPartial() TagPartial { diff --git a/pkg/models/relationships.go b/pkg/models/relationships.go index 29772890f04..021fab4dbfb 100644 --- a/pkg/models/relationships.go +++ b/pkg/models/relationships.go @@ -24,6 +24,11 @@ type TagIDLoader interface { GetTagIDs(ctx context.Context, relatedID int) ([]int, error) } +type TagRelationLoader interface { + GetParentIDs(ctx context.Context, relatedID int) ([]int, error) + GetChildIDs(ctx context.Context, relatedID int) ([]int, error) +} + type FileIDLoader interface { GetManyFileIDs(ctx context.Context, ids []int) ([][]FileID, error) } diff --git a/pkg/models/repository_tag.go b/pkg/models/repository_tag.go index ca8f6971bf7..6d38785e6d0 100644 --- a/pkg/models/repository_tag.go +++ b/pkg/models/repository_tag.go @@ -84,6 +84,7 @@ type TagReader interface { TagCounter AliasLoader + TagRelationLoader All(ctx context.Context) ([]*Tag, error) GetImage(ctx context.Context, tagID int) ([]byte, error) diff --git a/pkg/sqlite/tables.go b/pkg/sqlite/tables.go index 64d1e4eb236..701c503305d 100644 --- a/pkg/sqlite/tables.go +++ b/pkg/sqlite/tables.go @@ -36,6 +36,9 @@ var ( studiosStashIDsJoinTable = goqu.T("studio_stash_ids") moviesURLsJoinTable = goqu.T(movieURLsTable) + + tagsAliasesJoinTable = goqu.T(tagAliasesTable) + tagRelationsJoinTable = goqu.T(tagRelationsTable) ) var ( @@ -294,6 +297,24 @@ var ( table: goqu.T(tagTable), idColumn: goqu.T(tagTable).Col(idColumn), } + + tagsAliasesTableMgr = &stringTable{ + table: table{ + table: tagsAliasesJoinTable, + idColumn: tagsAliasesJoinTable.Col(tagIDColumn), + }, + stringColumn: tagsAliasesJoinTable.Col(tagAliasColumn), + } + + tagsParentTagsTableMgr = &joinTable{ + table: table{ + table: tagRelationsJoinTable, + idColumn: tagRelationsJoinTable.Col(tagChildIDColumn), + }, + fkColumn: tagRelationsJoinTable.Col(tagParentIDColumn), + } + + tagsChildTagsTableMgr = *tagsParentTagsTableMgr.invert() ) var ( diff --git a/pkg/sqlite/tag.go b/pkg/sqlite/tag.go index 99cc42edcdf..127ad3310e1 100644 --- a/pkg/sqlite/tag.go +++ b/pkg/sqlite/tag.go @@ -24,6 +24,10 @@ const ( tagAliasColumn = "alias" tagImageBlobColumn = "image_blob" + + tagRelationsTable = "tags_relations" + tagParentIDColumn = "parent_id" + tagChildIDColumn = "child_id" ) type tagRow struct { @@ -173,6 +177,24 @@ func (qb *TagStore) Create(ctx context.Context, newObject *models.Tag) error { return err } + if newObject.Aliases.Loaded() { + if err := tagsAliasesTableMgr.insertJoins(ctx, id, newObject.Aliases.List()); err != nil { + return err + } + } + + if newObject.ParentIDs.Loaded() { + if err := tagsParentTagsTableMgr.insertJoins(ctx, id, newObject.ParentIDs.List()); err != nil { + return err + } + } + + if newObject.ChildIDs.Loaded() { + if err := tagsChildTagsTableMgr.insertJoins(ctx, id, newObject.ChildIDs.List()); err != nil { + return err + } + } + updated, err := qb.find(ctx, id) if err != nil { return fmt.Errorf("finding after create: %w", err) @@ -198,6 +220,24 @@ func (qb *TagStore) UpdatePartial(ctx context.Context, id int, partial models.Ta } } + if partial.Aliases != nil { + if err := tagsAliasesTableMgr.modifyJoins(ctx, id, partial.Aliases.Values, partial.Aliases.Mode); err != nil { + return nil, err + } + } + + if partial.ParentIDs != nil { + if err := tagsParentTagsTableMgr.modifyJoins(ctx, id, partial.ParentIDs.IDs, partial.ParentIDs.Mode); err != nil { + return nil, err + } + } + + if partial.ChildIDs != nil { + if err := tagsChildTagsTableMgr.modifyJoins(ctx, id, partial.ChildIDs.IDs, partial.ChildIDs.Mode); err != nil { + return nil, err + } + } + return qb.find(ctx, id) } @@ -209,6 +249,24 @@ func (qb *TagStore) Update(ctx context.Context, updatedObject *models.Tag) error return err } + if updatedObject.Aliases.Loaded() { + if err := tagsAliasesTableMgr.replaceJoins(ctx, updatedObject.ID, updatedObject.Aliases.List()); err != nil { + return err + } + } + + if updatedObject.ParentIDs.Loaded() { + if err := tagsParentTagsTableMgr.replaceJoins(ctx, updatedObject.ID, updatedObject.ParentIDs.List()); err != nil { + return err + } + } + + if updatedObject.ChildIDs.Loaded() { + if err := tagsChildTagsTableMgr.replaceJoins(ctx, updatedObject.ID, updatedObject.ChildIDs.List()); err != nil { + return err + } + } + return nil } @@ -423,6 +481,14 @@ func (qb *TagStore) FindByNames(ctx context.Context, names []string, nocase bool return ret, nil } +func (qb *TagStore) GetParentIDs(ctx context.Context, relatedID int) ([]int, error) { + return tagsParentTagsTableMgr.get(ctx, relatedID) +} + +func (qb *TagStore) GetChildIDs(ctx context.Context, relatedID int) ([]int, error) { + return tagsChildTagsTableMgr.get(ctx, relatedID) +} + func (qb *TagStore) FindByParentTagID(ctx context.Context, parentID int) ([]*models.Tag, error) { query := ` SELECT tags.* FROM tags diff --git a/pkg/tag/update.go b/pkg/tag/update.go index dcb78bf9cab..99e9b916569 100644 --- a/pkg/tag/update.go +++ b/pkg/tag/update.go @@ -33,6 +33,10 @@ type InvalidTagHierarchyError struct { } func (e *InvalidTagHierarchyError) Error() string { + if e.ApplyingTag == "" { + return fmt.Sprintf("cannot apply tag \"%s\" as a %s of tag as it is already %s", e.InvalidTag, e.Direction, e.CurrentRelation) + } + return fmt.Sprintf("cannot apply tag \"%s\" as a %s of \"%s\" as it is already %s (%s)", e.InvalidTag, e.Direction, e.ApplyingTag, e.CurrentRelation, e.TagPath) } @@ -80,16 +84,83 @@ func EnsureAliasesUnique(ctx context.Context, id int, aliases []string, qb model type RelationshipFinder interface { FindAllAncestors(ctx context.Context, tagID int, excludeIDs []int) ([]*models.TagPath, error) FindAllDescendants(ctx context.Context, tagID int, excludeIDs []int) ([]*models.TagPath, error) - FindByChildTagID(ctx context.Context, childID int) ([]*models.Tag, error) - FindByParentTagID(ctx context.Context, parentID int) ([]*models.Tag, error) + models.TagRelationLoader +} + +func ValidateHierarchyNew(ctx context.Context, parentIDs, childIDs []int, qb RelationshipFinder) error { + allAncestors := make(map[int]*models.TagPath) + allDescendants := make(map[int]*models.TagPath) + + for _, parentID := range parentIDs { + parentsAncestors, err := qb.FindAllAncestors(ctx, parentID, nil) + if err != nil { + return err + } + + for _, ancestorTag := range parentsAncestors { + allAncestors[ancestorTag.ID] = ancestorTag + } + } + + for _, childID := range childIDs { + childsDescendants, err := qb.FindAllDescendants(ctx, childID, nil) + if err != nil { + return err + } + + for _, descendentTag := range childsDescendants { + allDescendants[descendentTag.ID] = descendentTag + } + } + + // Validate that the tag is not a parent of any of its ancestors + validateParent := func(testID int) error { + if parentTag, exists := allDescendants[testID]; exists { + return &InvalidTagHierarchyError{ + Direction: "parent", + CurrentRelation: "a descendant", + InvalidTag: parentTag.Name, + TagPath: parentTag.Path, + } + } + + return nil + } + + // Validate that the tag is not a child of any of its ancestors + validateChild := func(testID int) error { + if childTag, exists := allAncestors[testID]; exists { + return &InvalidTagHierarchyError{ + Direction: "child", + CurrentRelation: "an ancestor", + InvalidTag: childTag.Name, + TagPath: childTag.Path, + } + } + + return nil + } + + for _, parentID := range parentIDs { + if err := validateParent(parentID); err != nil { + return err + } + } + + for _, childID := range childIDs { + if err := validateChild(childID); err != nil { + return err + } + } + + return nil } -func ValidateHierarchy(ctx context.Context, tag *models.Tag, parentIDs, childIDs []int, qb RelationshipFinder) error { - id := tag.ID +func ValidateHierarchyExisting(ctx context.Context, tag *models.Tag, parentIDs, childIDs []int, qb RelationshipFinder) error { allAncestors := make(map[int]*models.TagPath) allDescendants := make(map[int]*models.TagPath) - parentsAncestors, err := qb.FindAllAncestors(ctx, id, nil) + parentsAncestors, err := qb.FindAllAncestors(ctx, tag.ID, nil) if err != nil { return err } @@ -98,7 +169,7 @@ func ValidateHierarchy(ctx context.Context, tag *models.Tag, parentIDs, childIDs allAncestors[ancestorTag.ID] = ancestorTag } - childsDescendants, err := qb.FindAllDescendants(ctx, id, nil) + childsDescendants, err := qb.FindAllDescendants(ctx, tag.ID, nil) if err != nil { return err } @@ -135,28 +206,6 @@ func ValidateHierarchy(ctx context.Context, tag *models.Tag, parentIDs, childIDs return nil } - if parentIDs == nil { - parentTags, err := qb.FindByChildTagID(ctx, id) - if err != nil { - return err - } - - for _, parentTag := range parentTags { - parentIDs = append(parentIDs, parentTag.ID) - } - } - - if childIDs == nil { - childTags, err := qb.FindByParentTagID(ctx, id) - if err != nil { - return err - } - - for _, childTag := range childTags { - childIDs = append(childIDs, childTag.ID) - } - } - for _, parentID := range parentIDs { if err := validateParent(parentID); err != nil { return err @@ -176,38 +225,38 @@ func MergeHierarchy(ctx context.Context, destination int, sources []int, qb Rela var mergedParents, mergedChildren []int allIds := append([]int{destination}, sources...) - addTo := func(mergedItems []int, tags []*models.Tag) []int { + addTo := func(mergedItems []int, tagIDs []int) []int { Tags: - for _, tag := range tags { + for _, tagID := range tagIDs { // Ignore tags which are already set for _, existingItem := range mergedItems { - if tag.ID == existingItem { + if tagID == existingItem { continue Tags } } // Ignore tags which are being merged, as these are rolled up anyway (if A is merged into B any direct link between them can be ignored) for _, id := range allIds { - if tag.ID == id { + if tagID == id { continue Tags } } - mergedItems = append(mergedItems, tag.ID) + mergedItems = append(mergedItems, tagID) } return mergedItems } for _, id := range allIds { - parents, err := qb.FindByChildTagID(ctx, id) + parents, err := qb.GetParentIDs(ctx, id) if err != nil { return nil, nil, err } mergedParents = addTo(mergedParents, parents) - children, err := qb.FindByParentTagID(ctx, id) + children, err := qb.GetChildIDs(ctx, id) if err != nil { return nil, nil, err } diff --git a/pkg/tag/update_test.go b/pkg/tag/update_test.go index c581d34ac43..462c981434f 100644 --- a/pkg/tag/update_test.go +++ b/pkg/tag/update_test.go @@ -211,14 +211,11 @@ var testUniqueHierarchyCases = []testUniqueHierarchyCase{ func TestEnsureHierarchy(t *testing.T) { for _, tc := range testUniqueHierarchyCases { - testEnsureHierarchy(t, tc, false, false) - testEnsureHierarchy(t, tc, true, false) - testEnsureHierarchy(t, tc, false, true) - testEnsureHierarchy(t, tc, true, true) + testEnsureHierarchy(t, tc) } } -func testEnsureHierarchy(t *testing.T, tc testUniqueHierarchyCase, queryParents, queryChildren bool) { +func testEnsureHierarchy(t *testing.T, tc testUniqueHierarchyCase) { db := mocks.NewDatabase() var parentIDs, childIDs []int @@ -244,16 +241,6 @@ func testEnsureHierarchy(t *testing.T, tc testUniqueHierarchyCase, queryParents, } } - if queryParents { - parentIDs = nil - db.Tag.On("FindByChildTagID", testCtx, tc.id).Return(tc.parents, nil).Once() - } - - if queryChildren { - childIDs = nil - db.Tag.On("FindByParentTagID", testCtx, tc.id).Return(tc.children, nil).Once() - } - db.Tag.On("FindAllAncestors", testCtx, mock.AnythingOfType("int"), []int(nil)).Return(func(ctx context.Context, tagID int, excludeIDs []int) []*models.TagPath { return tc.onFindAllAncestors }, func(ctx context.Context, tagID int, excludeIDs []int) error { @@ -272,7 +259,7 @@ func testEnsureHierarchy(t *testing.T, tc testUniqueHierarchyCase, queryParents, return fmt.Errorf("undefined descendants for: %d", tagID) }).Maybe() - res := ValidateHierarchy(testCtx, testUniqueHierarchyTags[tc.id], parentIDs, childIDs, db.Tag) + res := ValidateHierarchyExisting(testCtx, testUniqueHierarchyTags[tc.id], parentIDs, childIDs, db.Tag) assert := assert.New(t) diff --git a/pkg/tag/validate.go b/pkg/tag/validate.go new file mode 100644 index 00000000000..966cec9451b --- /dev/null +++ b/pkg/tag/validate.go @@ -0,0 +1,102 @@ +package tag + +import ( + "context" + "errors" + "fmt" + + "github.com/stashapp/stash/pkg/models" +) + +var ( + ErrNameMissing = errors.New("tag name must not be blank") +) + +type NotFoundError struct { + id int +} + +func (e *NotFoundError) Error() string { + return fmt.Sprintf("tag with id %d not found", e.id) +} + +func ValidateCreate(ctx context.Context, tag models.Tag, qb models.TagReader) error { + if tag.Name == "" { + return ErrNameMissing + } + + if err := EnsureTagNameUnique(ctx, 0, tag.Name, qb); err != nil { + return err + } + + if tag.Aliases.Loaded() { + if err := EnsureAliasesUnique(ctx, tag.ID, tag.Aliases.List(), qb); err != nil { + return err + } + } + + if len(tag.ParentIDs.List()) > 0 || len(tag.ChildIDs.List()) > 0 { + if err := ValidateHierarchyNew(ctx, tag.ParentIDs.List(), tag.ChildIDs.List(), qb); err != nil { + return err + } + } + + return nil +} + +func ValidateUpdate(ctx context.Context, id int, partial models.TagPartial, qb models.TagReader) error { + existing, err := qb.Find(ctx, id) + if err != nil { + return err + } + + if existing == nil { + return &NotFoundError{id} + } + + if partial.Name.Set { + if partial.Name.Value == "" { + return ErrNameMissing + } + + if err := EnsureTagNameUnique(ctx, id, partial.Name.Value, qb); err != nil { + return err + } + } + + if partial.Aliases != nil { + if err := existing.LoadAliases(ctx, qb); err != nil { + return err + } + + if err := EnsureAliasesUnique(ctx, id, partial.Aliases.Apply(existing.Aliases.List()), qb); err != nil { + return err + } + } + + if partial.ParentIDs != nil || partial.ChildIDs != nil { + if err := existing.LoadParentIDs(ctx, qb); err != nil { + return err + } + + if err := existing.LoadChildIDs(ctx, qb); err != nil { + return err + } + + parentIDs := partial.ParentIDs + if parentIDs == nil { + parentIDs = &models.UpdateIDs{IDs: existing.ParentIDs.List(), Mode: models.RelationshipUpdateModeSet} + } + + childIDs := partial.ChildIDs + if childIDs == nil { + childIDs = &models.UpdateIDs{IDs: existing.ChildIDs.List(), Mode: models.RelationshipUpdateModeSet} + } + + if err := ValidateHierarchyExisting(ctx, existing, parentIDs.Apply(existing.ParentIDs.List()), childIDs.Apply(existing.ChildIDs.List()), qb); err != nil { + return err + } + } + + return nil +} diff --git a/ui/v2.5/graphql/mutations/tag.graphql b/ui/v2.5/graphql/mutations/tag.graphql index 20e3b4b81a5..f2138e05702 100644 --- a/ui/v2.5/graphql/mutations/tag.graphql +++ b/ui/v2.5/graphql/mutations/tag.graphql @@ -18,6 +18,12 @@ mutation TagUpdate($input: TagUpdateInput!) { } } +mutation BulkTagUpdate($input: BulkTagUpdateInput!) { + bulkTagUpdate(input: $input) { + ...TagData + } +} + mutation TagsMerge($source: [ID!]!, $destination: ID!) { tagsMerge(input: { source: $source, destination: $destination }) { ...TagData diff --git a/ui/v2.5/src/components/Tags/EditTagsDialog.tsx b/ui/v2.5/src/components/Tags/EditTagsDialog.tsx new file mode 100644 index 00000000000..d771ea1c94c --- /dev/null +++ b/ui/v2.5/src/components/Tags/EditTagsDialog.tsx @@ -0,0 +1,237 @@ +import React, { useEffect, useState } from "react"; +import { Form } from "react-bootstrap"; +import { FormattedMessage, useIntl } from "react-intl"; +import { useBulkTagUpdate } from "src/core/StashService"; +import * as GQL from "src/core/generated-graphql"; +import { ModalComponent } from "../Shared/Modal"; +import { useToast } from "src/hooks/Toast"; +import { MultiSet } from "../Shared/MultiSet"; +import { + getAggregateState, + getAggregateStateObject, +} from "src/utils/bulkUpdate"; +import { IndeterminateCheckbox } from "../Shared/IndeterminateCheckbox"; +import { BulkUpdateTextInput } from "../Shared/BulkUpdateTextInput"; +import { faPencilAlt } from "@fortawesome/free-solid-svg-icons"; + +function Tags(props: { + isUpdating: boolean; + controlId: string; + messageId: string; + existingTagIds: string[] | undefined; + tagIDs: GQL.BulkUpdateIds; + setTagIDs: (value: React.SetStateAction) => void; +}) { + const { + isUpdating, + controlId, + messageId, + existingTagIds, + tagIDs, + setTagIDs, + } = props; + + return ( + + + + + + setTagIDs((existing) => ({ ...existing, ids: itemIDs })) + } + onSetMode={(newMode) => + setTagIDs((existing) => ({ ...existing, mode: newMode })) + } + existingIds={existingTagIds ?? []} + ids={tagIDs.ids ?? []} + mode={tagIDs.mode} + /> + + ); +} + +interface IListOperationProps { + selected: GQL.TagDataFragment[]; + onClose: (applied: boolean) => void; +} + +const tagFields = ["favorite", "description", "ignore_auto_tag"]; + +export const EditTagsDialog: React.FC = ( + props: IListOperationProps +) => { + const intl = useIntl(); + const Toast = useToast(); + + const [parentTagIDs, setParentTagIDs_] = useState({ + mode: GQL.BulkUpdateIdMode.Add, + }); + + function setParentTagIDs(value: React.SetStateAction) { + console.log(value); + setParentTagIDs_(value); + } + + const [existingParentTagIds, setExistingParentTagIds] = useState(); + + const [childTagIDs, setChildTagIDs] = useState({ + mode: GQL.BulkUpdateIdMode.Add, + }); + const [existingChildTagIds, setExistingChildTagIds] = useState(); + + const [updateInput, setUpdateInput] = useState({}); + + const [updateTags] = useBulkTagUpdate(getTagInput()); + + // Network state + const [isUpdating, setIsUpdating] = useState(false); + + function setUpdateField(input: Partial) { + setUpdateInput({ ...updateInput, ...input }); + } + + function getTagInput(): GQL.BulkTagUpdateInput { + const tagInput: GQL.BulkTagUpdateInput = { + ids: props.selected.map((tag) => { + return tag.id; + }), + ...updateInput, + parent_ids: parentTagIDs, + child_ids: childTagIDs, + }; + + return tagInput; + } + + async function onSave() { + setIsUpdating(true); + try { + await updateTags(); + Toast.success( + intl.formatMessage( + { id: "toast.updated_entity" }, + { + entity: intl.formatMessage({ id: "tags" }).toLocaleLowerCase(), + } + ) + ); + props.onClose(true); + } catch (e) { + Toast.error(e); + } + setIsUpdating(false); + } + + useEffect(() => { + const updateState: GQL.BulkTagUpdateInput = {}; + + const state = props.selected; + let updateParentTagIds: string[] = []; + let updateChildTagIds: string[] = []; + let first = true; + + state.forEach((tag: GQL.TagDataFragment) => { + getAggregateStateObject(updateState, tag, tagFields, first); + + const thisParents = (tag.parents ?? []).map((t) => t.id).sort(); + updateParentTagIds = + getAggregateState(updateParentTagIds, thisParents, first) ?? []; + + const thisChildren = (tag.children ?? []).map((t) => t.id).sort(); + updateChildTagIds = + getAggregateState(updateChildTagIds, thisChildren, first) ?? []; + + first = false; + }); + + setExistingParentTagIds(updateParentTagIds); + setExistingChildTagIds(updateChildTagIds); + setUpdateInput(updateState); + }, [props.selected]); + + function renderTextField( + name: string, + value: string | undefined | null, + setter: (newValue: string | undefined) => void + ) { + return ( + + + + + setter(newValue)} + unsetDisabled={props.selected.length < 2} + /> + + ); + } + + return ( + props.onClose(false), + text: intl.formatMessage({ id: "actions.cancel" }), + variant: "secondary", + }} + isRunning={isUpdating} + > +
+ + setUpdateField({ favorite: checked })} + checked={updateInput.favorite ?? undefined} + label={intl.formatMessage({ id: "favourite" })} + /> + + + {renderTextField("description", updateInput.description, (v) => + setUpdateField({ description: v }) + )} + + + + + + + + setUpdateField({ ignore_auto_tag: checked }) + } + checked={updateInput.ignore_auto_tag ?? undefined} + /> + + +
+ ); +}; diff --git a/ui/v2.5/src/components/Tags/TagList.tsx b/ui/v2.5/src/components/Tags/TagList.tsx index ea580c2c948..2458a273ba8 100644 --- a/ui/v2.5/src/components/Tags/TagList.tsx +++ b/ui/v2.5/src/components/Tags/TagList.tsx @@ -28,6 +28,7 @@ import { ExportDialog } from "../Shared/ExportDialog"; import { tagRelationHook } from "../../core/tags"; import { faTrashAlt } from "@fortawesome/free-solid-svg-icons"; import { TagCardGrid } from "./TagCardGrid"; +import { EditTagsDialog } from "./EditTagsDialog"; interface ITagList { filterHook?: (filter: ListFilterModel) => ListFilterModel; @@ -325,6 +326,13 @@ export const TagList: React.FC = ({ filterHook, alterQuery }) => { ); } + function renderEditDialog( + selectedTags: GQL.TagDataFragment[], + onClose: (confirmed: boolean) => void + ) { + return ; + } + function renderDeleteDialog( selectedTags: GQL.TagDataFragment[], onClose: (confirmed: boolean) => void @@ -361,6 +369,7 @@ export const TagList: React.FC = ({ filterHook, alterQuery }) => { addKeybinds={addKeybinds} renderContent={renderContent} renderDeleteDialog={renderDeleteDialog} + renderEditDialog={renderEditDialog} /> ); }; diff --git a/ui/v2.5/src/core/StashService.ts b/ui/v2.5/src/core/StashService.ts index 7559210a555..251df72f57a 100644 --- a/ui/v2.5/src/core/StashService.ts +++ b/ui/v2.5/src/core/StashService.ts @@ -1873,6 +1873,17 @@ export const useTagUpdate = () => }, }); +export const useBulkTagUpdate = (input: GQL.BulkTagUpdateInput) => + GQL.useBulkTagUpdateMutation({ + variables: { input }, + update(cache, result) { + if (!result.data?.bulkTagUpdate) return; + + evictTypeFields(cache, tagMutationImpactedTypeFields); + evictQueries(cache, tagMutationImpactedQueries); + }, + }); + export const useTagDestroy = (input: GQL.TagDestroyInput) => GQL.useTagDestroyMutation({ variables: input, From 4be60310c3df0c644f4de965280b6287e7098474 Mon Sep 17 00:00:00 2001 From: well <85684982+well-thats-funny@users.noreply.github.com> Date: Tue, 11 Jun 2024 05:41:43 +0200 Subject: [PATCH 015/103] In performer scrapers, forward non-http single performer images (#4947) * Forward non-http single performer images * Don't set if Images already set --------- Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com> --- pkg/models/model_scraped_item.go | 2 +- pkg/scraper/image.go | 10 +++++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/pkg/models/model_scraped_item.go b/pkg/models/model_scraped_item.go index b3a7a2418b3..5a9f2acb036 100644 --- a/pkg/models/model_scraped_item.go +++ b/pkg/models/model_scraped_item.go @@ -125,7 +125,7 @@ type ScrapedPerformer struct { Aliases *string `json:"aliases"` Tags []*ScrapedTag `json:"tags"` // This should be a base64 encoded data URL - Image *string `json:"image"` + Image *string `json:"image"` // deprecated: use Images Images []string `json:"images"` Details *string `json:"details"` DeathDate *string `json:"death_date"` diff --git a/pkg/scraper/image.go b/pkg/scraper/image.go index 5757bc9b383..78652f11d7e 100644 --- a/pkg/scraper/image.go +++ b/pkg/scraper/image.go @@ -12,11 +12,19 @@ import ( ) func setPerformerImage(ctx context.Context, client *http.Client, p *models.ScrapedPerformer, globalConfig GlobalConfig) error { - if p.Image == nil || !strings.HasPrefix(*p.Image, "http") { + // backwards compatibility: we fetch the image if it's a URL and set it to the first image + // Image is deprecated, so only do this if Images is unset + if p.Image == nil || len(p.Images) > 0 { // nothing to do return nil } + // don't try to get the image if it doesn't appear to be a URL + if !strings.HasPrefix(*p.Image, "http") { + p.Images = []string{*p.Image} + return nil + } + img, err := getImage(ctx, *p.Image, client, globalConfig) if err != nil { return err From f9a624b8037b74a9c7fa06e7e2b57af07eca93aa Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Tue, 18 Jun 2024 10:51:52 +1000 Subject: [PATCH 016/103] Default view filters (#4962) * Merge/adapt from yoshnopa:defaultDetails * Deprecate and remove default filter calls * Fix weird behaviour when clicking set as default * Update deprecated get/set default filter resolvers * Add config migration --------- Co-authored-by: yoshnopa --- go.mod | 2 +- graphql/schema/schema.graphql | 2 + .../api/resolver_mutation_saved_filter.go | 59 ++++-- .../api/resolver_query_find_saved_filter.go | 38 +++- pkg/models/saved_filter.go | 2 - pkg/sqlite/database.go | 2 +- .../migrations/60_default_filter_move.up.sql | 2 + pkg/sqlite/migrations/60_postmigrate.go | 176 ++++++++++++++++++ pkg/sqlite/saved_filter.go | 33 ---- pkg/sqlite/saved_filter_test.go | 60 ------ ui/v2.5/graphql/client-schema.graphql | 5 - ui/v2.5/graphql/mutations/filter.graphql | 4 - ui/v2.5/graphql/queries/filter.graphql | 6 - .../src/components/Galleries/Galleries.tsx | 4 +- .../GalleryDetails/GalleryImagesPanel.tsx | 9 +- .../src/components/Galleries/GalleryList.tsx | 13 +- ui/v2.5/src/components/Images/ImageList.tsx | 11 +- ui/v2.5/src/components/Images/Images.tsx | 4 +- ui/v2.5/src/components/List/ItemList.tsx | 100 ++-------- ui/v2.5/src/components/List/ListFilter.tsx | 58 ++---- .../src/components/List/SavedFilterList.tsx | 75 ++++++-- ui/v2.5/src/components/List/styles.scss | 2 + ui/v2.5/src/components/List/util.ts | 32 ++++ ui/v2.5/src/components/List/views.ts | 34 ++++ .../Movies/MovieDetails/MovieScenesPanel.tsx | 2 + ui/v2.5/src/components/Movies/MovieList.tsx | 16 +- ui/v2.5/src/components/Movies/Movies.tsx | 3 +- .../PerformerGalleriesPanel.tsx | 9 +- .../PerformerDetails/PerformerImagesPanel.tsx | 9 +- .../PerformerDetails/PerformerMoviesPanel.tsx | 9 +- .../PerformerDetails/PerformerScenesPanel.tsx | 9 +- .../performerAppearsWithPanel.tsx | 2 + .../components/Performers/PerformerList.tsx | 13 +- .../src/components/Performers/Performers.tsx | 4 +- ui/v2.5/src/components/Scenes/SceneList.tsx | 13 +- .../src/components/Scenes/SceneMarkerList.tsx | 7 +- ui/v2.5/src/components/Scenes/Scenes.tsx | 6 +- .../StudioDetails/StudioChildrenPanel.tsx | 10 +- .../StudioDetails/StudioGalleriesPanel.tsx | 9 +- .../StudioDetails/StudioImagesPanel.tsx | 9 +- .../StudioDetails/StudioMoviesPanel.tsx | 9 +- .../StudioDetails/StudioPerformersPanel.tsx | 2 + .../StudioDetails/StudioScenesPanel.tsx | 9 +- ui/v2.5/src/components/Studios/StudioList.tsx | 11 +- ui/v2.5/src/components/Studios/Studios.tsx | 3 +- .../Tags/TagDetails/TagGalleriesPanel.tsx | 9 +- .../Tags/TagDetails/TagImagesPanel.tsx | 9 +- .../Tags/TagDetails/TagMarkersPanel.tsx | 9 +- .../Tags/TagDetails/TagPerformersPanel.tsx | 9 +- .../Tags/TagDetails/TagScenesPanel.tsx | 9 +- ui/v2.5/src/components/Tags/TagList.tsx | 9 +- ui/v2.5/src/core/StashService.ts | 16 -- ui/v2.5/src/core/config.ts | 13 +- ui/v2.5/src/core/createClient.ts | 3 - ui/v2.5/src/docs/en/MigrationNotes/60.md | 1 + ui/v2.5/src/docs/en/MigrationNotes/index.ts | 2 + ui/v2.5/src/models/list-filter/filter.ts | 15 +- ui/v2.5/src/models/sceneQueue.ts | 7 +- ui/v2.5/src/pluginApi.d.ts | 6 - 59 files changed, 611 insertions(+), 403 deletions(-) create mode 100644 pkg/sqlite/migrations/60_default_filter_move.up.sql create mode 100644 pkg/sqlite/migrations/60_postmigrate.go create mode 100644 ui/v2.5/src/components/List/util.ts create mode 100644 ui/v2.5/src/components/List/views.ts create mode 100644 ui/v2.5/src/docs/en/MigrationNotes/60.md diff --git a/go.mod b/go.mod index 1795a6d34eb..3056e6a9530 100644 --- a/go.mod +++ b/go.mod @@ -34,6 +34,7 @@ require ( github.com/knadh/koanf v1.5.0 github.com/lucasb-eyer/go-colorful v1.2.0 github.com/mattn/go-sqlite3 v1.14.22 + github.com/mitchellh/mapstructure v1.5.0 github.com/natefinch/pie v0.0.0-20170715172608-9a0d72014007 github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 github.com/remeh/sizedwaitgroup v1.0.0 @@ -88,7 +89,6 @@ require ( github.com/mattn/go-isatty v0.0.19 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect - github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect diff --git a/graphql/schema/schema.graphql b/graphql/schema/schema.graphql index c5b8d60892d..a1f163ecca0 100644 --- a/graphql/schema/schema.graphql +++ b/graphql/schema/schema.graphql @@ -4,6 +4,7 @@ type Query { findSavedFilter(id: ID!): SavedFilter findSavedFilters(mode: FilterMode): [SavedFilter!]! findDefaultFilter(mode: FilterMode!): SavedFilter + @deprecated(reason: "default filter now stored in UI config") "Find a scene by ID or Checksum" findScene(id: ID, checksum: String): Scene @@ -345,6 +346,7 @@ type Mutation { saveFilter(input: SaveFilterInput!): SavedFilter! destroySavedFilter(input: DestroyFilterInput!): Boolean! setDefaultFilter(input: SetDefaultFilterInput!): Boolean! + @deprecated(reason: "now uses UI config") "Change general configuration options" configureGeneral(input: ConfigGeneralInput!): ConfigGeneralResult! diff --git a/internal/api/resolver_mutation_saved_filter.go b/internal/api/resolver_mutation_saved_filter.go index 13b5d87fafa..e49c1214cff 100644 --- a/internal/api/resolver_mutation_saved_filter.go +++ b/internal/api/resolver_mutation_saved_filter.go @@ -7,7 +7,10 @@ import ( "strconv" "strings" + "github.com/mitchellh/mapstructure" + "github.com/stashapp/stash/internal/manager/config" "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/utils" ) func (r *mutationResolver) SaveFilter(ctx context.Context, input SaveFilterInput) (ret *models.SavedFilter, err error) { @@ -67,30 +70,48 @@ func (r *mutationResolver) DestroySavedFilter(ctx context.Context, input Destroy } func (r *mutationResolver) SetDefaultFilter(ctx context.Context, input SetDefaultFilterInput) (bool, error) { - if err := r.withTxn(ctx, func(ctx context.Context) error { - qb := r.repository.SavedFilter + // deprecated - write to the config in the meantime + config := config.GetInstance() + + uiConfig := config.GetUIConfiguration() + if uiConfig == nil { + uiConfig = make(map[string]interface{}) + } - if input.FindFilter == nil && input.ObjectFilter == nil && input.UIOptions == nil { - // clearing - def, err := qb.FindDefault(ctx, input.Mode) - if err != nil { - return err - } + m := utils.NestedMap(uiConfig) - if def != nil { - return qb.Destroy(ctx, def.ID) - } + if input.FindFilter == nil && input.ObjectFilter == nil && input.UIOptions == nil { + // clearing + m.Delete("defaultFilters." + strings.ToLower(input.Mode.String())) + config.SetUIConfiguration(m) - return nil + if err := config.Write(); err != nil { + return false, err } - return qb.SetDefault(ctx, &models.SavedFilter{ - Mode: input.Mode, - FindFilter: input.FindFilter, - ObjectFilter: input.ObjectFilter, - UIOptions: input.UIOptions, - }) - }); err != nil { + return true, nil + } + + subMap := make(map[string]interface{}) + d, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{ + TagName: "json", + WeaklyTypedInput: true, + Result: &subMap, + }) + + if err != nil { + return false, err + } + + if err := d.Decode(input); err != nil { + return false, err + } + + m.Set("defaultFilters."+strings.ToLower(input.Mode.String()), subMap) + + config.SetUIConfiguration(m) + + if err := config.Write(); err != nil { return false, err } diff --git a/internal/api/resolver_query_find_saved_filter.go b/internal/api/resolver_query_find_saved_filter.go index 4f196fd65d8..1ba68e31d98 100644 --- a/internal/api/resolver_query_find_saved_filter.go +++ b/internal/api/resolver_query_find_saved_filter.go @@ -3,8 +3,12 @@ package api import ( "context" "strconv" + "strings" + "github.com/mitchellh/mapstructure" + "github.com/stashapp/stash/internal/manager/config" "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/utils" ) func (r *queryResolver) FindSavedFilter(ctx context.Context, id string) (ret *models.SavedFilter, err error) { @@ -37,11 +41,35 @@ 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.withReadTxn(ctx, func(ctx context.Context) error { - ret, err = r.repository.SavedFilter.FindDefault(ctx, mode) - return err - }); err != nil { + // deprecated - read from the config in the meantime + config := config.GetInstance() + + uiConfig := config.GetUIConfiguration() + if uiConfig == nil { + return nil, nil + } + + m := utils.NestedMap(uiConfig) + filterRaw, _ := m.Get("defaultFilters." + strings.ToLower(mode.String())) + + if filterRaw == nil { + return nil, nil + } + + ret = &models.SavedFilter{} + d, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{ + TagName: "json", + WeaklyTypedInput: true, + Result: ret, + }) + + if err != nil { return nil, err } - return ret, err + + if err := d.Decode(filterRaw); err != nil { + return nil, err + } + + return ret, nil } diff --git a/pkg/models/saved_filter.go b/pkg/models/saved_filter.go index a8e4f20c330..919f0a1a6c8 100644 --- a/pkg/models/saved_filter.go +++ b/pkg/models/saved_filter.go @@ -7,13 +7,11 @@ type SavedFilterReader interface { Find(ctx context.Context, id int) (*SavedFilter, error) FindMany(ctx context.Context, ids []int, ignoreNotFound bool) ([]*SavedFilter, error) FindByMode(ctx context.Context, mode FilterMode) ([]*SavedFilter, error) - FindDefault(ctx context.Context, mode FilterMode) (*SavedFilter, error) } type SavedFilterWriter interface { Create(ctx context.Context, obj *SavedFilter) error Update(ctx context.Context, obj *SavedFilter) error - SetDefault(ctx context.Context, obj *SavedFilter) error Destroy(ctx context.Context, id int) error } diff --git a/pkg/sqlite/database.go b/pkg/sqlite/database.go index 3475e955a72..7303400a3b6 100644 --- a/pkg/sqlite/database.go +++ b/pkg/sqlite/database.go @@ -30,7 +30,7 @@ const ( dbConnTimeout = 30 ) -var appSchemaVersion uint = 59 +var appSchemaVersion uint = 60 //go:embed migrations/*.sql var migrationsBox embed.FS diff --git a/pkg/sqlite/migrations/60_default_filter_move.up.sql b/pkg/sqlite/migrations/60_default_filter_move.up.sql new file mode 100644 index 00000000000..2c6f6e1fcb8 --- /dev/null +++ b/pkg/sqlite/migrations/60_default_filter_move.up.sql @@ -0,0 +1,2 @@ +-- no schema changes +-- default filters will be removed in post-migration \ No newline at end of file diff --git a/pkg/sqlite/migrations/60_postmigrate.go b/pkg/sqlite/migrations/60_postmigrate.go new file mode 100644 index 00000000000..dfed33f1886 --- /dev/null +++ b/pkg/sqlite/migrations/60_postmigrate.go @@ -0,0 +1,176 @@ +package migrations + +import ( + "context" + "encoding/json" + "fmt" + "os" + "strings" + "time" + + "github.com/jmoiron/sqlx" + "github.com/stashapp/stash/internal/manager/config" + "github.com/stashapp/stash/pkg/logger" + "github.com/stashapp/stash/pkg/sqlite" +) + +type schema60Migrator struct { + migrator +} + +func post60(ctx context.Context, db *sqlx.DB) error { + logger.Info("Running post-migration for schema version 60") + + m := schema60Migrator{ + migrator: migrator{ + db: db, + }, + } + + return m.migrate(ctx) +} + +func (m *schema60Migrator) decodeJSON(s string, v interface{}) { + if s == "" { + return + } + + if err := json.Unmarshal([]byte(s), v); err != nil { + logger.Errorf("error decoding json %q: %v", s, err) + } +} + +type schema60DefaultFilters map[string]interface{} + +func (m *schema60Migrator) migrate(ctx context.Context) error { + + // save default filters into the UI config + if err := m.withTxn(ctx, func(tx *sqlx.Tx) error { + query := "SELECT id, mode, find_filter, object_filter, ui_options FROM `saved_filters` WHERE `name` = ''" + + rows, err := m.db.Query(query) + if err != nil { + return err + } + defer rows.Close() + + defaultFilters := make(schema60DefaultFilters) + + for rows.Next() { + var ( + id int + mode string + findFilterStr string + objectFilterStr string + uiOptionsStr string + ) + + if err := rows.Scan(&id, &mode, &findFilterStr, &objectFilterStr, &uiOptionsStr); err != nil { + return err + } + + // convert the filters to the correct format + findFilter := make(map[string]interface{}) + objectFilter := make(map[string]interface{}) + uiOptions := make(map[string]interface{}) + + m.decodeJSON(findFilterStr, &findFilter) + m.decodeJSON(objectFilterStr, &objectFilter) + m.decodeJSON(uiOptionsStr, &uiOptions) + + o := map[string]interface{}{ + "mode": mode, + "find_filter": findFilter, + "object_filter": objectFilter, + "ui_options": uiOptions, + } + + defaultFilters[strings.ToLower(mode)] = o + } + + if err := rows.Err(); err != nil { + return err + } + + if err := m.saveDefaultFilters(defaultFilters); err != nil { + return fmt.Errorf("saving default filters: %w", err) + } + + // remove the default filters from the database + query = "DELETE FROM `saved_filters` WHERE `name` = ''" + if _, err := m.db.Exec(query); err != nil { + return fmt.Errorf("deleting default filters: %w", err) + } + + return nil + }); err != nil { + return err + } + + return nil +} + +func (m *schema60Migrator) saveDefaultFilters(defaultFilters schema60DefaultFilters) error { + if len(defaultFilters) == 0 { + logger.Debugf("no default filters to save") + return nil + } + + // save the default filters into the UI config + config := config.GetInstance() + + orgPath := config.GetConfigFile() + + if orgPath == "" { + // no config file to migrate (usually in a test or new system) + logger.Debugf("no config file to migrate") + return nil + } + + uiConfig := config.GetUIConfiguration() + if uiConfig == nil { + uiConfig = make(map[string]interface{}) + } + + // if the defaultFilters key already exists, don't overwrite them + if _, found := uiConfig["defaultFilters"]; found { + logger.Warn("defaultFilters already exists in the UI config, skipping migration") + return nil + } + + if err := m.backupConfig(orgPath); err != nil { + return fmt.Errorf("backing up config: %w", err) + } + + uiConfig["defaultFilters"] = map[string]interface{}(defaultFilters) + config.SetUIConfiguration(uiConfig) + + if err := config.Write(); err != nil { + return fmt.Errorf("failed to write config: %w", err) + } + + return nil +} + +func (m *schema60Migrator) backupConfig(orgPath string) error { + c := config.GetInstance() + + // save a backup of the original config file + backupPath := fmt.Sprintf("%s.59.%s", orgPath, time.Now().Format("20060102_150405")) + + data, err := c.Marshal() + if err != nil { + return fmt.Errorf("failed to marshal backup config: %w", err) + } + + logger.Infof("Backing up config to %s", backupPath) + if err := os.WriteFile(backupPath, data, 0644); err != nil { + return fmt.Errorf("failed to write backup config: %w", err) + } + + return nil +} + +func init() { + sqlite.RegisterPostMigration(60, post60) +} diff --git a/pkg/sqlite/saved_filter.go b/pkg/sqlite/saved_filter.go index e4369bda5c3..49b1f45ed58 100644 --- a/pkg/sqlite/saved_filter.go +++ b/pkg/sqlite/saved_filter.go @@ -141,23 +141,6 @@ func (qb *SavedFilterStore) Update(ctx context.Context, updatedObject *models.Sa return nil } -func (qb *SavedFilterStore) SetDefault(ctx context.Context, obj *models.SavedFilter) error { - // find the existing default - existing, err := qb.FindDefault(ctx, obj.Mode) - if err != nil { - return err - } - - obj.Name = savedFilterDefaultName - - if existing != nil { - obj.ID = existing.ID - return qb.Update(ctx, obj) - } - - return qb.Create(ctx, obj) -} - func (qb *SavedFilterStore) Destroy(ctx context.Context, id int) error { return qb.destroyExisting(ctx, []int{id}) } @@ -258,22 +241,6 @@ func (qb *SavedFilterStore) FindByMode(ctx context.Context, mode models.FilterMo return ret, nil } -func (qb *SavedFilterStore) FindDefault(ctx context.Context, mode models.FilterMode) (*models.SavedFilter, error) { - // SELECT * FROM saved_filters WHERE mode = ? AND name = ? - table := qb.table() - sq := qb.selectDataset().Prepared(true).Where( - table.Col("mode").Eq(mode), - table.Col("name").Eq(savedFilterDefaultName), - ) - - ret, err := qb.get(ctx, sq) - if err != nil && !errors.Is(err, sql.ErrNoRows) { - return nil, err - } - - return ret, nil -} - func (qb *SavedFilterStore) All(ctx context.Context) ([]*models.SavedFilter, error) { return qb.getMany(ctx, qb.selectDataset()) } diff --git a/pkg/sqlite/saved_filter_test.go b/pkg/sqlite/saved_filter_test.go index aa98121fd45..60592a923df 100644 --- a/pkg/sqlite/saved_filter_test.go +++ b/pkg/sqlite/saved_filter_test.go @@ -96,66 +96,6 @@ func TestSavedFilterDestroy(t *testing.T) { }) } -func TestSavedFilterFindDefault(t *testing.T) { - withTxn(func(ctx context.Context) error { - def, err := db.SavedFilter.FindDefault(ctx, models.FilterModeScenes) - if err == nil { - assert.Equal(t, savedFilterIDs[savedFilterIdxDefaultScene], def.ID) - } - - return err - }) -} - -func TestSavedFilterSetDefault(t *testing.T) { - filterQ := "" - filterPage := 1 - filterPerPage := 40 - filterSort := "date" - filterDirection := models.SortDirectionEnumAsc - findFilter := models.FindFilterType{ - Q: &filterQ, - Page: &filterPage, - PerPage: &filterPerPage, - Sort: &filterSort, - Direction: &filterDirection, - } - objectFilter := map[string]interface{}{ - "test": "foo", - } - uiOptions := map[string]interface{}{ - "display_mode": 1, - "zoom_index": 1, - } - - withTxn(func(ctx context.Context) error { - err := db.SavedFilter.SetDefault(ctx, &models.SavedFilter{ - Mode: models.FilterModeMovies, - FindFilter: &findFilter, - ObjectFilter: objectFilter, - UIOptions: uiOptions, - }) - - return err - }) - - var defID int - withTxn(func(ctx context.Context) error { - def, err := db.SavedFilter.FindDefault(ctx, models.FilterModeMovies) - if err == nil { - defID = def.ID - assert.Equal(t, &findFilter, def.FindFilter) - } - - return err - }) - - // destroy it again - withTxn(func(ctx context.Context) error { - return db.SavedFilter.Destroy(ctx, defID) - }) -} - // TODO Update // TODO Destroy // TODO Find diff --git a/ui/v2.5/graphql/client-schema.graphql b/ui/v2.5/graphql/client-schema.graphql index 92f53cc3a33..57eff3343a8 100644 --- a/ui/v2.5/graphql/client-schema.graphql +++ b/ui/v2.5/graphql/client-schema.graphql @@ -16,11 +16,6 @@ extend input SaveFilterInput { ui_options: SavedUIOptions } -extend input SetDefaultFilterInput { - object_filter: SavedObjectFilter - ui_options: SavedUIOptions -} - extend type Mutation { configureUI(input: Map, partial: Map): UIConfig! } diff --git a/ui/v2.5/graphql/mutations/filter.graphql b/ui/v2.5/graphql/mutations/filter.graphql index 5d801312379..68a6403a104 100644 --- a/ui/v2.5/graphql/mutations/filter.graphql +++ b/ui/v2.5/graphql/mutations/filter.graphql @@ -7,7 +7,3 @@ mutation SaveFilter($input: SaveFilterInput!) { mutation DestroySavedFilter($input: DestroyFilterInput!) { destroySavedFilter(input: $input) } - -mutation SetDefaultFilter($input: SetDefaultFilterInput!) { - setDefaultFilter(input: $input) -} diff --git a/ui/v2.5/graphql/queries/filter.graphql b/ui/v2.5/graphql/queries/filter.graphql index 67fbaf6cf12..276c22d757a 100644 --- a/ui/v2.5/graphql/queries/filter.graphql +++ b/ui/v2.5/graphql/queries/filter.graphql @@ -9,9 +9,3 @@ query FindSavedFilters($mode: FilterMode) { ...SavedFilterData } } - -query FindDefaultFilter($mode: FilterMode!) { - findDefaultFilter(mode: $mode) { - ...SavedFilterData - } -} diff --git a/ui/v2.5/src/components/Galleries/Galleries.tsx b/ui/v2.5/src/components/Galleries/Galleries.tsx index d61f124ac8c..cc2e84ff76c 100644 --- a/ui/v2.5/src/components/Galleries/Galleries.tsx +++ b/ui/v2.5/src/components/Galleries/Galleries.tsx @@ -2,16 +2,16 @@ import React from "react"; import { Route, Switch } from "react-router-dom"; import { Helmet } from "react-helmet"; import { useTitleProps } from "src/hooks/title"; -import { PersistanceLevel } from "../List/ItemList"; import Gallery from "./GalleryDetails/Gallery"; import GalleryCreate from "./GalleryDetails/GalleryCreate"; import { GalleryList } from "./GalleryList"; import { useScrollToTopOnMount } from "src/hooks/scrollToTop"; +import { View } from "../List/views"; const Galleries: React.FC = () => { useScrollToTopOnMount(); - return ; + return ; }; const GalleryRoutes: React.FC = () => { diff --git a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryImagesPanel.tsx b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryImagesPanel.tsx index c0c539fa952..ea28ffabf8c 100644 --- a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryImagesPanel.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryImagesPanel.tsx @@ -4,14 +4,12 @@ import { GalleriesCriterion } from "src/models/list-filter/criteria/galleries"; import { ListFilterModel } from "src/models/list-filter/filter"; import { ImageList } from "src/components/Images/ImageList"; import { mutateRemoveGalleryImages } from "src/core/StashService"; -import { - showWhenSelected, - PersistanceLevel, -} from "src/components/List/ItemList"; +import { showWhenSelected } from "src/components/List/ItemList"; import { useToast } from "src/hooks/Toast"; import { useIntl } from "react-intl"; import { faMinus } from "@fortawesome/free-solid-svg-icons"; import { galleryTitle } from "src/core/galleries"; +import { View } from "src/components/List/views"; interface IGalleryDetailsProps { active: boolean; @@ -102,8 +100,7 @@ export const GalleryImagesPanel: React.FC = ({ filterHook={filterHook} alterQuery={active} extraOperations={otherOperations} - persistState={PersistanceLevel.VIEW} - persistanceKey="galleryimages" + view={View.GalleryImages} chapters={gallery.chapters} /> ); diff --git a/ui/v2.5/src/components/Galleries/GalleryList.tsx b/ui/v2.5/src/components/Galleries/GalleryList.tsx index 3ef5acf1fb2..ba6334e3339 100644 --- a/ui/v2.5/src/components/Galleries/GalleryList.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryList.tsx @@ -4,11 +4,7 @@ import cloneDeep from "lodash-es/cloneDeep"; import { useHistory } from "react-router-dom"; import Mousetrap from "mousetrap"; import * as GQL from "src/core/generated-graphql"; -import { - makeItemList, - PersistanceLevel, - showWhenSelected, -} from "../List/ItemList"; +import { makeItemList, showWhenSelected } from "../List/ItemList"; import { ListFilterModel } from "src/models/list-filter/filter"; import { DisplayMode } from "src/models/list-filter/types"; import { queryFindGalleries, useFindGalleries } from "src/core/StashService"; @@ -18,6 +14,7 @@ import { DeleteGalleriesDialog } from "./DeleteGalleriesDialog"; import { ExportDialog } from "../Shared/ExportDialog"; import { GalleryListTable } from "./GalleryListTable"; import { GalleryCardGrid } from "./GalleryGridCard"; +import { View } from "../List/views"; const GalleryItemList = makeItemList({ filterMode: GQL.FilterMode.Galleries, @@ -32,13 +29,13 @@ const GalleryItemList = makeItemList({ interface IGalleryList { filterHook?: (filter: ListFilterModel) => ListFilterModel; - persistState?: PersistanceLevel; + view?: View; alterQuery?: boolean; } export const GalleryList: React.FC = ({ filterHook, - persistState, + view, alterQuery, }) => { const intl = useIntl(); @@ -192,7 +189,7 @@ export const GalleryList: React.FC = ({ zoomable selectable filterHook={filterHook} - persistState={persistState} + view={view} alterQuery={alterQuery} otherOperations={otherOperations} addKeybinds={addKeybinds} diff --git a/ui/v2.5/src/components/Images/ImageList.tsx b/ui/v2.5/src/components/Images/ImageList.tsx index c0339a44c7f..3000195d9da 100644 --- a/ui/v2.5/src/components/Images/ImageList.tsx +++ b/ui/v2.5/src/components/Images/ImageList.tsx @@ -14,7 +14,6 @@ import { queryFindImages, useFindImages } from "src/core/StashService"; import { makeItemList, IItemListOperation, - PersistanceLevel, showWhenSelected, } from "../List/ItemList"; import { useLightbox } from "src/hooks/Lightbox/hooks"; @@ -31,6 +30,7 @@ import { objectTitle } from "src/core/files"; import TextUtils from "src/utils/text"; import { ConfigurationContext } from "src/hooks/Config"; import { ImageGridCard } from "./ImageGridCard"; +import { View } from "../List/views"; interface IImageWallProps { images: GQL.SlimImageDataFragment[]; @@ -270,8 +270,7 @@ const ImageItemList = makeItemList({ interface IImageList { filterHook?: (filter: ListFilterModel) => ListFilterModel; - persistState?: PersistanceLevel; - persistanceKey?: string; + view?: View; alterQuery?: boolean; extraOperations?: IItemListOperation[]; chapters?: GQL.GalleryChapterDataFragment[]; @@ -279,8 +278,7 @@ interface IImageList { export const ImageList: React.FC = ({ filterHook, - persistState, - persistanceKey, + view, alterQuery, extraOperations, chapters = [], @@ -421,8 +419,7 @@ export const ImageList: React.FC = ({ zoomable selectable filterHook={filterHook} - persistState={persistState} - persistanceKey={persistanceKey} + view={view} alterQuery={alterQuery} otherOperations={otherOperations} addKeybinds={addKeybinds} diff --git a/ui/v2.5/src/components/Images/Images.tsx b/ui/v2.5/src/components/Images/Images.tsx index 90c6858b0e2..c0a6b67c814 100644 --- a/ui/v2.5/src/components/Images/Images.tsx +++ b/ui/v2.5/src/components/Images/Images.tsx @@ -2,15 +2,15 @@ import React from "react"; import { Route, Switch } from "react-router-dom"; import { Helmet } from "react-helmet"; import { useTitleProps } from "src/hooks/title"; -import { PersistanceLevel } from "../List/ItemList"; import Image from "./ImageDetails/Image"; import { ImageList } from "./ImageList"; import { useScrollToTopOnMount } from "src/hooks/scrollToTop"; +import { View } from "../List/views"; const Images: React.FC = () => { useScrollToTopOnMount(); - return ; + return ; }; const ImageRoutes: React.FC = () => { diff --git a/ui/v2.5/src/components/List/ItemList.tsx b/ui/v2.5/src/components/List/ItemList.tsx index 7b91844c8c1..5ffe97d4e6a 100644 --- a/ui/v2.5/src/components/List/ItemList.tsx +++ b/ui/v2.5/src/components/List/ItemList.tsx @@ -19,11 +19,9 @@ import { } from "src/models/list-filter/criteria/criterion"; import { ListFilterModel } from "src/models/list-filter/filter"; import { IconDefinition } from "@fortawesome/fontawesome-svg-core"; -import { useInterfaceLocalForage } from "src/hooks/LocalForage"; import { useHistory, useLocation } from "react-router-dom"; import { ConfigurationContext } from "src/hooks/Config"; import { getFilterOptions } from "src/models/list-filter/factory"; -import { useFindDefaultFilter } from "src/core/StashService"; import { Pagination, PaginationIndex } from "./Pagination"; import { EditFilterDialog } from "src/components/List/EditFilterDialog"; import { ListFilter } from "./ListFilter"; @@ -33,15 +31,8 @@ import { ListOperationButtons } from "./ListOperationButtons"; import { LoadingIndicator } from "../Shared/LoadingIndicator"; import { DisplayMode } from "src/models/list-filter/types"; import { ButtonToolbar } from "react-bootstrap"; - -export enum PersistanceLevel { - // do not load default query or persist display mode - NONE, - // load default query, don't load or persist display mode - ALL, - // load and persist display mode only - VIEW, -} +import { View } from "./views"; +import { useDefaultFilter } from "./util"; interface IDataItem { id: string; @@ -79,8 +70,7 @@ interface IRenderListProps { } interface IItemListProps { - persistState?: PersistanceLevel; - persistanceKey?: string; + view?: View; defaultSort?: string; filterHook?: (filter: ListFilterModel) => ListFilterModel; filterDialog?: ( @@ -140,7 +130,7 @@ export function makeItemList({ filterHook, onChangePage: _onChangePage, updateFilter, - persistState, + view, zoomable, selectable, otherOperations, @@ -480,7 +470,7 @@ export function makeItemList({ filter={filter} filterOptions={filterOptions} openFilterDialog={() => setShowEditFilter(true)} - persistState={persistState} + view={view} /> ({ const ItemList: React.FC> = (props) => { const { - persistState, - persistanceKey = filterMode, + view, defaultSort = filterOptions.defaultSortBy, defaultZoomIndex, alterQuery = true, @@ -540,7 +529,6 @@ export function makeItemList({ const history = useHistory(); const location = useLocation(); - const [interfaceState, setInterfaceState] = useInterfaceLocalForage(); const [filterInitialised, setFilterInitialised] = useState(false); const { configuration: config } = useContext(ConfigurationContext); @@ -550,35 +538,11 @@ export function makeItemList({ () => new ListFilterModel(filterMode) ); - const updateSavedFilter = useCallback( - (updatedFilter: ListFilterModel) => { - setInterfaceState((prevState) => { - if (!prevState.queryConfig) { - prevState.queryConfig = {}; - } - - const oldFilter = prevState.queryConfig[persistanceKey]?.filter ?? ""; - const newFilter = new URLSearchParams(oldFilter); - newFilter.set("disp", String(updatedFilter.displayMode)); - - return { - ...prevState, - queryConfig: { - ...prevState.queryConfig, - [persistanceKey]: { - ...prevState.queryConfig[persistanceKey], - filter: newFilter.toString(), - }, - }, - }; - }); - }, - [persistanceKey, setInterfaceState] + const { defaultFilter, loading: defaultFilterLoading } = useDefaultFilter( + filterMode, + view ); - const { data: defaultFilter, loading: defaultFilterLoading } = - useFindDefaultFilter(filterMode); - const updateQueryParams = useCallback( (newFilter: ListFilterModel) => { if (!alterQuery) return; @@ -593,11 +557,8 @@ export function makeItemList({ (newFilter: ListFilterModel) => { setFilter(newFilter); updateQueryParams(newFilter); - if (persistState === PersistanceLevel.VIEW) { - updateSavedFilter(newFilter); - } }, - [persistState, updateSavedFilter, updateQueryParams] + [updateQueryParams] ); // 'Startup' hook, initialises the filters @@ -605,53 +566,28 @@ export function makeItemList({ // Only run once if (filterInitialised) return; - let newFilter = new ListFilterModel( - filterMode, - config, - defaultSort, - defaultDisplayMode, - defaultZoomIndex - ); + let newFilter = new ListFilterModel(filterMode, config, defaultZoomIndex); let loadDefault = true; if (alterQuery && location.search) { loadDefault = false; newFilter.configureFromQueryString(location.search); } - if (persistState === PersistanceLevel.ALL) { + if (view) { // only set default filter if uninitialised if (loadDefault) { // wait until default filter is loaded if (defaultFilterLoading) return; - if (defaultFilter?.findDefaultFilter) { - newFilter.currentPage = 1; - try { - newFilter.configureFromSavedFilter( - defaultFilter.findDefaultFilter - ); - } catch (err) { - console.log(err); - // ignore - } + if (defaultFilter) { + newFilter = defaultFilter.clone(); + // #1507 - reset random seed when loaded newFilter.randomSeed = -1; } } - } else if (persistState === PersistanceLevel.VIEW) { - // wait until forage is initialised - if (interfaceState.loading) return; - - const storedQuery = interfaceState.data?.queryConfig?.[persistanceKey]; - if (persistState === PersistanceLevel.VIEW && storedQuery) { - const displayMode = new URLSearchParams(storedQuery.filter).get( - "disp" - ); - if (displayMode) { - newFilter.displayMode = Number.parseInt(displayMode, 10); - } - } } + setFilter(newFilter); updateQueryParams(newFilter); @@ -664,12 +600,10 @@ export function makeItemList({ defaultDisplayMode, defaultZoomIndex, alterQuery, - persistState, + view, updateQueryParams, defaultFilter, defaultFilterLoading, - interfaceState, - persistanceKey, ]); // This hook runs on every page location change (ie navigation), diff --git a/ui/v2.5/src/components/List/ListFilter.tsx b/ui/v2.5/src/components/List/ListFilter.tsx index b7023bfb906..ccfd3240954 100644 --- a/ui/v2.5/src/components/List/ListFilter.tsx +++ b/ui/v2.5/src/components/List/ListFilter.tsx @@ -1,11 +1,5 @@ import cloneDeep from "lodash-es/cloneDeep"; -import React, { - HTMLAttributes, - useCallback, - useEffect, - useRef, - useState, -} from "react"; +import React, { useCallback, useEffect, useRef, useState } from "react"; import cx from "classnames"; import Mousetrap from "mousetrap"; import { SortDirectionEnum } from "src/core/generated-graphql"; @@ -27,10 +21,8 @@ import { ListFilterModel } from "src/models/list-filter/filter"; import useFocus from "src/utils/focus"; import { ListFilterOptions } from "src/models/list-filter/filter-options"; import { FormattedMessage, useIntl } from "react-intl"; -import { PersistanceLevel } from "./ItemList"; -import { SavedFilterList } from "./SavedFilterList"; +import { SavedFilterDropdown } from "./SavedFilterList"; import { - faBookmark, faCaretDown, faCaretUp, faCheck, @@ -39,12 +31,13 @@ import { } from "@fortawesome/free-solid-svg-icons"; import { FilterButton } from "./Filters/FilterButton"; import { useDebounce } from "src/hooks/debounce"; +import { View } from "./views"; interface IListFilterProps { onFilterUpdate: (newFilter: ListFilterModel) => void; filter: ListFilterModel; filterOptions: ListFilterOptions; - persistState?: PersistanceLevel; + view?: View; openFilterDialog: () => void; } @@ -55,7 +48,7 @@ export const ListFilter: React.FC = ({ filter, filterOptions, openFilterDialog, - persistState, + view, }) => { const [customPageSizeShowing, setCustomPageSizeShowing] = useState(false); const [queryRef, setQueryFocus] = useFocus(); @@ -191,22 +184,6 @@ export const ListFilter: React.FC = ({ )); } - const SavedFilterDropdown = React.forwardRef< - HTMLDivElement, - HTMLAttributes - >(({ style, className }: HTMLAttributes, ref) => ( -
- { - onFilterUpdate(f); - }} - persistState={persistState} - /> -
- )); - SavedFilterDropdown.displayName = "SavedFilterDropdown"; - function render() { const currentSortBy = filterOptions.sortByOptions.find( (o) => o.value === filter.sortBy @@ -257,24 +234,13 @@ export const ListFilter: React.FC = ({
- - - - - } - > - - - - - - + { + onFilterUpdate(f); + }} + view={view} + /> void; - persistState?: PersistanceLevel; + view?: View; } export const SavedFilterList: React.FC = ({ filter, onSetFilter, - persistState, + view, }) => { const Toast = useToast(); const intl = useIntl(); @@ -51,7 +51,7 @@ export const SavedFilterList: React.FC = ({ const [saveFilter] = useSaveFilter(); const [destroyFilter] = useSavedFilterDestroy(); - const [setDefaultFilter] = useSetDefaultFilter(); + const [saveUI] = useConfigureUI(); const savedFilters = data?.findSavedFilters ?? []; @@ -127,18 +127,26 @@ export const SavedFilterList: React.FC = ({ } async function onSetDefaultFilter() { + if (!view) { + return; + } + const filterCopy = filter.clone(); try { setSaving(true); - await setDefaultFilter({ + await saveUI({ variables: { - input: { - mode: filter.mode, - find_filter: filterCopy.makeFindFilter(), - object_filter: filterCopy.makeSavedFilter(), - ui_options: filterCopy.makeSavedUIOptions(), + partial: { + defaultFilters: { + [view.toString()]: { + mode: filter.mode, + find_filter: filterCopy.makeFindFilter(), + object_filter: filterCopy.makeSavedFilter(), + ui_options: filterCopy.makeSavedUIOptions(), + }, + }, }, }, }); @@ -302,17 +310,19 @@ export const SavedFilterList: React.FC = ({ } function maybeRenderSetDefaultButton() { - if (persistState === PersistanceLevel.ALL) { + if (view) { return (
- +
); } @@ -357,3 +367,36 @@ export const SavedFilterList: React.FC = ({ ); }; + +export const SavedFilterDropdown: React.FC = (props) => { + const SavedFilterDropdownRef = React.forwardRef< + HTMLDivElement, + HTMLAttributes + >(({ style, className }: HTMLAttributes, ref) => ( +
+ +
+ )); + SavedFilterDropdownRef.displayName = "SavedFilterDropdown"; + + return ( + + + + + } + > + + + + + + + ); +}; diff --git a/ui/v2.5/src/components/List/styles.scss b/ui/v2.5/src/components/List/styles.scss index 1c8d993a309..5b1e3b845b6 100644 --- a/ui/v2.5/src/components/List/styles.scss +++ b/ui/v2.5/src/components/List/styles.scss @@ -86,6 +86,8 @@ input[type="range"].zoom-slider { .set-as-default-button { float: right; margin-right: 0.5rem; + padding: 0.25rem 0.5rem; + width: auto; } .LoadingIndicator { diff --git a/ui/v2.5/src/components/List/util.ts b/ui/v2.5/src/components/List/util.ts new file mode 100644 index 00000000000..7ebe679b9bb --- /dev/null +++ b/ui/v2.5/src/components/List/util.ts @@ -0,0 +1,32 @@ +import { useContext, useMemo } from "react"; +import { ListFilterModel } from "src/models/list-filter/filter"; +import * as GQL from "src/core/generated-graphql"; +import { ConfigurationContext } from "src/hooks/Config"; +import { View } from "./views"; + +export function useDefaultFilter(mode: GQL.FilterMode, view?: View) { + const emptyFilter = useMemo(() => new ListFilterModel(mode), [mode]); + const { configuration: config, loading } = useContext(ConfigurationContext); + + const defaultFilter = useMemo(() => { + if (view && config?.ui.defaultFilters?.[view]) { + const savedFilter = config.ui.defaultFilters[view]!; + const newFilter = emptyFilter.clone(); + + newFilter.currentPage = 1; + try { + newFilter.configureFromSavedFilter(savedFilter); + } catch (err) { + console.log(err); + // ignore + } + // #1507 - reset random seed when loaded + newFilter.randomSeed = -1; + return newFilter; + } + }, [view, config?.ui.defaultFilters, emptyFilter]); + + const retFilter = loading ? undefined : defaultFilter ?? emptyFilter; + + return { defaultFilter: retFilter, loading }; +} diff --git a/ui/v2.5/src/components/List/views.ts b/ui/v2.5/src/components/List/views.ts new file mode 100644 index 00000000000..7e8880f9d2e --- /dev/null +++ b/ui/v2.5/src/components/List/views.ts @@ -0,0 +1,34 @@ +export enum View { + Galleries = "galleries", + Images = "images", + Scenes = "scenes", + Movies = "movies", + Performers = "performers", + Tags = "tags", + SceneMarkers = "scene_markers", + Studios = "studios", + + TagMarkers = "tag_markers", + TagGalleries = "tag_galleries", + TagScenes = "tag_scenes", + TagImages = "tag_images", + TagPerformers = "tag_performers", + + PerformerScenes = "performer_scenes", + PerformerGalleries = "performer_galleries", + PerformerImages = "performer_images", + PerformerMovies = "performer_movies", + PerformerAppearsWith = "performer_appears_with", + + StudioGalleries = "studio_galleries", + StudioImages = "studio_images", + + GalleryImages = "gallery_images", + + StudioScenes = "studio_scenes", + StudioMovies = "studio_movies", + StudioPerformers = "studio_performers", + StudioChildren = "studio_children", + + MovieScenes = "movie_scenes", +} diff --git a/ui/v2.5/src/components/Movies/MovieDetails/MovieScenesPanel.tsx b/ui/v2.5/src/components/Movies/MovieDetails/MovieScenesPanel.tsx index 9bfbf8b55e2..0ceff9b479b 100644 --- a/ui/v2.5/src/components/Movies/MovieDetails/MovieScenesPanel.tsx +++ b/ui/v2.5/src/components/Movies/MovieDetails/MovieScenesPanel.tsx @@ -3,6 +3,7 @@ import * as GQL from "src/core/generated-graphql"; import { MoviesCriterion } from "src/models/list-filter/criteria/movies"; import { ListFilterModel } from "src/models/list-filter/filter"; import { SceneList } from "src/components/Scenes/SceneList"; +import { View } from "src/components/List/views"; interface IMovieScenesPanel { active: boolean; @@ -51,6 +52,7 @@ export const MovieScenesPanel: React.FC = ({ filterHook={filterHook} defaultSort="movie_scene_number" alterQuery={active} + view={View.MovieScenes} /> ); } diff --git a/ui/v2.5/src/components/Movies/MovieList.tsx b/ui/v2.5/src/components/Movies/MovieList.tsx index 55b34a783f2..8b42a3b73ec 100644 --- a/ui/v2.5/src/components/Movies/MovieList.tsx +++ b/ui/v2.5/src/components/Movies/MovieList.tsx @@ -11,15 +11,12 @@ import { useFindMovies, useMoviesDestroy, } from "src/core/StashService"; -import { - makeItemList, - PersistanceLevel, - showWhenSelected, -} from "../List/ItemList"; +import { makeItemList, showWhenSelected } from "../List/ItemList"; import { ExportDialog } from "../Shared/ExportDialog"; import { DeleteEntityDialog } from "../Shared/DeleteEntityDialog"; import { MovieCardGrid } from "./MovieCardGrid"; import { EditMoviesDialog } from "./EditMoviesDialog"; +import { View } from "../List/views"; const MovieItemList = makeItemList({ filterMode: GQL.FilterMode.Movies, @@ -34,10 +31,15 @@ const MovieItemList = makeItemList({ interface IMovieList { filterHook?: (filter: ListFilterModel) => ListFilterModel; + view?: View; alterQuery?: boolean; } -export const MovieList: React.FC = ({ filterHook, alterQuery }) => { +export const MovieList: React.FC = ({ + filterHook, + alterQuery, + view, +}) => { const intl = useIntl(); const history = useHistory(); const [isExportDialogOpen, setIsExportDialogOpen] = useState(false); @@ -175,7 +177,7 @@ export const MovieList: React.FC = ({ filterHook, alterQuery }) => { { useScrollToTopOnMount(); - return ; + return ; }; const MovieRoutes: React.FC = () => { diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerGalleriesPanel.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerGalleriesPanel.tsx index f1ea3db2c1a..4424ff740ce 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerGalleriesPanel.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerGalleriesPanel.tsx @@ -2,6 +2,7 @@ import React from "react"; import * as GQL from "src/core/generated-graphql"; import { GalleryList } from "src/components/Galleries/GalleryList"; import { usePerformerFilterHook } from "src/core/performers"; +import { View } from "src/components/List/views"; interface IPerformerDetailsProps { active: boolean; @@ -13,5 +14,11 @@ export const PerformerGalleriesPanel: React.FC = ({ performer, }) => { const filterHook = usePerformerFilterHook(performer); - return ; + return ( + + ); }; diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerImagesPanel.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerImagesPanel.tsx index 478f7027f6d..40c2d88b8c5 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerImagesPanel.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerImagesPanel.tsx @@ -2,6 +2,7 @@ import React from "react"; import * as GQL from "src/core/generated-graphql"; import { ImageList } from "src/components/Images/ImageList"; import { usePerformerFilterHook } from "src/core/performers"; +import { View } from "src/components/List/views"; interface IPerformerImagesPanel { active: boolean; @@ -13,5 +14,11 @@ export const PerformerImagesPanel: React.FC = ({ performer, }) => { const filterHook = usePerformerFilterHook(performer); - return ; + return ( + + ); }; diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerMoviesPanel.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerMoviesPanel.tsx index 4c417bac8cb..0f1c8b7d583 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerMoviesPanel.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerMoviesPanel.tsx @@ -2,6 +2,7 @@ import React from "react"; import * as GQL from "src/core/generated-graphql"; import { MovieList } from "src/components/Movies/MovieList"; import { usePerformerFilterHook } from "src/core/performers"; +import { View } from "src/components/List/views"; interface IPerformerDetailsProps { active: boolean; @@ -13,5 +14,11 @@ export const PerformerMoviesPanel: React.FC = ({ performer, }) => { const filterHook = usePerformerFilterHook(performer); - return ; + return ( + + ); }; diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScenesPanel.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScenesPanel.tsx index d05db77c481..5eca04f6c36 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScenesPanel.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScenesPanel.tsx @@ -2,6 +2,7 @@ import React from "react"; import * as GQL from "src/core/generated-graphql"; import { SceneList } from "src/components/Scenes/SceneList"; import { usePerformerFilterHook } from "src/core/performers"; +import { View } from "src/components/List/views"; interface IPerformerDetailsProps { active: boolean; @@ -13,5 +14,11 @@ export const PerformerScenesPanel: React.FC = ({ performer, }) => { const filterHook = usePerformerFilterHook(performer); - return ; + return ( + + ); }; diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/performerAppearsWithPanel.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/performerAppearsWithPanel.tsx index a05ec5e9f68..8806bd3ab3f 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/performerAppearsWithPanel.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/performerAppearsWithPanel.tsx @@ -2,6 +2,7 @@ import React from "react"; import * as GQL from "src/core/generated-graphql"; import { PerformerList } from "src/components/Performers/PerformerList"; import { usePerformerFilterHook } from "src/core/performers"; +import { View } from "src/components/List/views"; interface IPerformerDetailsProps { active: boolean; @@ -28,6 +29,7 @@ export const PerformerAppearsWithPanel: React.FC = ({ filterHook={filterHook} extraCriteria={extraCriteria} alterQuery={active} + view={View.PerformerAppearsWith} /> ); }; diff --git a/ui/v2.5/src/components/Performers/PerformerList.tsx b/ui/v2.5/src/components/Performers/PerformerList.tsx index 257079bce9b..9dd0ff277ab 100644 --- a/ui/v2.5/src/components/Performers/PerformerList.tsx +++ b/ui/v2.5/src/components/Performers/PerformerList.tsx @@ -9,11 +9,7 @@ import { useFindPerformers, usePerformersDestroy, } from "src/core/StashService"; -import { - makeItemList, - PersistanceLevel, - showWhenSelected, -} from "../List/ItemList"; +import { makeItemList, showWhenSelected } from "../List/ItemList"; import { ListFilterModel } from "src/models/list-filter/filter"; import { DisplayMode } from "src/models/list-filter/types"; import { PerformerTagger } from "../Tagger/performers/PerformerTagger"; @@ -25,6 +21,7 @@ import { EditPerformersDialog } from "./EditPerformersDialog"; import { cmToImperial, cmToInches, kgToLbs } from "src/utils/units"; import TextUtils from "src/utils/text"; import { PerformerCardGrid } from "./PerformerCardGrid"; +import { View } from "../List/views"; const PerformerItemList = makeItemList({ filterMode: GQL.FilterMode.Performers, @@ -162,14 +159,14 @@ export const FormatPenisLength = (penis_length?: number | null) => { interface IPerformerList { filterHook?: (filter: ListFilterModel) => ListFilterModel; - persistState?: PersistanceLevel; + view?: View; alterQuery?: boolean; extraCriteria?: IPerformerCardExtraCriteria; } export const PerformerList: React.FC = ({ filterHook, - persistState, + view, alterQuery, extraCriteria, }) => { @@ -325,7 +322,7 @@ export const PerformerList: React.FC = ({ { useScrollToTopOnMount(); - return ; + return ; }; const PerformerRoutes: React.FC = () => { diff --git a/ui/v2.5/src/components/Scenes/SceneList.tsx b/ui/v2.5/src/components/Scenes/SceneList.tsx index 89c52f429c3..4ffd2504079 100644 --- a/ui/v2.5/src/components/Scenes/SceneList.tsx +++ b/ui/v2.5/src/components/Scenes/SceneList.tsx @@ -5,11 +5,7 @@ import { useHistory } from "react-router-dom"; import Mousetrap from "mousetrap"; import * as GQL from "src/core/generated-graphql"; import { queryFindScenes, useFindScenes } from "src/core/StashService"; -import { - makeItemList, - PersistanceLevel, - showWhenSelected, -} from "../List/ItemList"; +import { makeItemList, showWhenSelected } from "../List/ItemList"; import { ListFilterModel } from "src/models/list-filter/filter"; import { DisplayMode } from "src/models/list-filter/types"; import { Tagger } from "../Tagger/scenes/SceneTagger"; @@ -28,6 +24,7 @@ import { faPlay } from "@fortawesome/free-solid-svg-icons"; import { SceneMergeModal } from "./SceneMergeDialog"; import { objectTitle } from "src/core/files"; import TextUtils from "src/utils/text"; +import { View } from "../List/views"; const SceneItemList = makeItemList({ filterMode: GQL.FilterMode.Scenes, @@ -78,14 +75,14 @@ const SceneItemList = makeItemList({ interface ISceneList { filterHook?: (filter: ListFilterModel) => ListFilterModel; defaultSort?: string; - persistState?: PersistanceLevel; + view?: View; alterQuery?: boolean; } export const SceneList: React.FC = ({ filterHook, defaultSort, - persistState, + view, alterQuery, }) => { const intl = useIntl(); @@ -357,7 +354,7 @@ export const SceneList: React.FC = ({ zoomable selectable filterHook={filterHook} - persistState={persistState} + view={view} alterQuery={alterQuery} otherOperations={otherOperations} addKeybinds={addKeybinds} diff --git a/ui/v2.5/src/components/Scenes/SceneMarkerList.tsx b/ui/v2.5/src/components/Scenes/SceneMarkerList.tsx index 6de661671c2..b81a4aecf3e 100644 --- a/ui/v2.5/src/components/Scenes/SceneMarkerList.tsx +++ b/ui/v2.5/src/components/Scenes/SceneMarkerList.tsx @@ -9,10 +9,11 @@ import { useFindSceneMarkers, } from "src/core/StashService"; import NavUtils from "src/utils/navigation"; -import { makeItemList, PersistanceLevel } from "../List/ItemList"; +import { makeItemList } from "../List/ItemList"; import { ListFilterModel } from "src/models/list-filter/filter"; import { DisplayMode } from "src/models/list-filter/types"; import { MarkerWallPanel } from "../Wall/WallPanel"; +import { View } from "../List/views"; const SceneMarkerItemList = makeItemList({ filterMode: GQL.FilterMode.SceneMarkers, @@ -27,11 +28,13 @@ const SceneMarkerItemList = makeItemList({ interface ISceneMarkerList { filterHook?: (filter: ListFilterModel) => ListFilterModel; + view?: View; alterQuery?: boolean; } export const SceneMarkerList: React.FC = ({ filterHook, + view, alterQuery, }) => { const intl = useIntl(); @@ -96,7 +99,7 @@ export const SceneMarkerList: React.FC = ({ return ( import("./SceneList")); const SceneMarkerList = lazyComponent(() => import("./SceneMarkerList")); @@ -14,7 +14,7 @@ const SceneCreate = lazyComponent(() => import("./SceneDetails/SceneCreate")); const Scenes: React.FC = () => { useScrollToTopOnMount(); - return ; + return ; }; const SceneMarkers: React.FC = () => { @@ -24,7 +24,7 @@ const SceneMarkers: React.FC = () => { return ( <> - + ); }; diff --git a/ui/v2.5/src/components/Studios/StudioDetails/StudioChildrenPanel.tsx b/ui/v2.5/src/components/Studios/StudioDetails/StudioChildrenPanel.tsx index 8048de66a48..62d773a51f0 100644 --- a/ui/v2.5/src/components/Studios/StudioDetails/StudioChildrenPanel.tsx +++ b/ui/v2.5/src/components/Studios/StudioDetails/StudioChildrenPanel.tsx @@ -3,6 +3,7 @@ import * as GQL from "src/core/generated-graphql"; import { ParentStudiosCriterion } from "src/models/list-filter/criteria/studios"; import { ListFilterModel } from "src/models/list-filter/filter"; import { StudioList } from "../StudioList"; +import { View } from "src/components/List/views"; interface IStudioChildrenPanel { active: boolean; @@ -45,5 +46,12 @@ export const StudioChildrenPanel: React.FC = ({ return filter; } - return ; + return ( + + ); }; diff --git a/ui/v2.5/src/components/Studios/StudioDetails/StudioGalleriesPanel.tsx b/ui/v2.5/src/components/Studios/StudioDetails/StudioGalleriesPanel.tsx index 42aca05dc9c..2519d41a3d9 100644 --- a/ui/v2.5/src/components/Studios/StudioDetails/StudioGalleriesPanel.tsx +++ b/ui/v2.5/src/components/Studios/StudioDetails/StudioGalleriesPanel.tsx @@ -2,6 +2,7 @@ import React from "react"; import * as GQL from "src/core/generated-graphql"; import { GalleryList } from "src/components/Galleries/GalleryList"; import { useStudioFilterHook } from "src/core/studios"; +import { View } from "src/components/List/views"; interface IStudioGalleriesPanel { active: boolean; @@ -13,5 +14,11 @@ export const StudioGalleriesPanel: React.FC = ({ studio, }) => { const filterHook = useStudioFilterHook(studio); - return ; + return ( + + ); }; diff --git a/ui/v2.5/src/components/Studios/StudioDetails/StudioImagesPanel.tsx b/ui/v2.5/src/components/Studios/StudioDetails/StudioImagesPanel.tsx index c2ecb4e0f10..3e6f66f25d8 100644 --- a/ui/v2.5/src/components/Studios/StudioDetails/StudioImagesPanel.tsx +++ b/ui/v2.5/src/components/Studios/StudioDetails/StudioImagesPanel.tsx @@ -2,6 +2,7 @@ import React from "react"; import * as GQL from "src/core/generated-graphql"; import { useStudioFilterHook } from "src/core/studios"; import { ImageList } from "src/components/Images/ImageList"; +import { View } from "src/components/List/views"; interface IStudioImagesPanel { active: boolean; @@ -13,5 +14,11 @@ export const StudioImagesPanel: React.FC = ({ studio, }) => { const filterHook = useStudioFilterHook(studio); - return ; + return ( + + ); }; diff --git a/ui/v2.5/src/components/Studios/StudioDetails/StudioMoviesPanel.tsx b/ui/v2.5/src/components/Studios/StudioDetails/StudioMoviesPanel.tsx index 93a820242b4..3127f525145 100644 --- a/ui/v2.5/src/components/Studios/StudioDetails/StudioMoviesPanel.tsx +++ b/ui/v2.5/src/components/Studios/StudioDetails/StudioMoviesPanel.tsx @@ -2,6 +2,7 @@ import React from "react"; import * as GQL from "src/core/generated-graphql"; import { MovieList } from "src/components/Movies/MovieList"; import { useStudioFilterHook } from "src/core/studios"; +import { View } from "src/components/List/views"; interface IStudioMoviesPanel { active: boolean; @@ -13,5 +14,11 @@ export const StudioMoviesPanel: React.FC = ({ studio, }) => { const filterHook = useStudioFilterHook(studio); - return ; + return ( + + ); }; diff --git a/ui/v2.5/src/components/Studios/StudioDetails/StudioPerformersPanel.tsx b/ui/v2.5/src/components/Studios/StudioDetails/StudioPerformersPanel.tsx index e13c8b2ecf9..0eb146a1b7d 100644 --- a/ui/v2.5/src/components/Studios/StudioDetails/StudioPerformersPanel.tsx +++ b/ui/v2.5/src/components/Studios/StudioDetails/StudioPerformersPanel.tsx @@ -3,6 +3,7 @@ import * as GQL from "src/core/generated-graphql"; import { useStudioFilterHook } from "src/core/studios"; import { PerformerList } from "src/components/Performers/PerformerList"; import { StudiosCriterion } from "src/models/list-filter/criteria/studios"; +import { View } from "src/components/List/views"; interface IStudioPerformersPanel { active: boolean; @@ -34,6 +35,7 @@ export const StudioPerformersPanel: React.FC = ({ filterHook={filterHook} extraCriteria={extraCriteria} alterQuery={active} + view={View.StudioPerformers} /> ); }; diff --git a/ui/v2.5/src/components/Studios/StudioDetails/StudioScenesPanel.tsx b/ui/v2.5/src/components/Studios/StudioDetails/StudioScenesPanel.tsx index 84ba8751ddb..47663f696dc 100644 --- a/ui/v2.5/src/components/Studios/StudioDetails/StudioScenesPanel.tsx +++ b/ui/v2.5/src/components/Studios/StudioDetails/StudioScenesPanel.tsx @@ -2,6 +2,7 @@ import React from "react"; import * as GQL from "src/core/generated-graphql"; import { SceneList } from "src/components/Scenes/SceneList"; import { useStudioFilterHook } from "src/core/studios"; +import { View } from "src/components/List/views"; interface IStudioScenesPanel { active: boolean; @@ -13,5 +14,11 @@ export const StudioScenesPanel: React.FC = ({ studio, }) => { const filterHook = useStudioFilterHook(studio); - return ; + return ( + + ); }; diff --git a/ui/v2.5/src/components/Studios/StudioList.tsx b/ui/v2.5/src/components/Studios/StudioList.tsx index 4e75c6405ed..952a6a6ee22 100644 --- a/ui/v2.5/src/components/Studios/StudioList.tsx +++ b/ui/v2.5/src/components/Studios/StudioList.tsx @@ -9,17 +9,14 @@ import { useFindStudios, useStudiosDestroy, } from "src/core/StashService"; -import { - makeItemList, - PersistanceLevel, - showWhenSelected, -} from "../List/ItemList"; +import { makeItemList, showWhenSelected } from "../List/ItemList"; import { ListFilterModel } from "src/models/list-filter/filter"; import { DisplayMode } from "src/models/list-filter/types"; import { ExportDialog } from "../Shared/ExportDialog"; import { DeleteEntityDialog } from "../Shared/DeleteEntityDialog"; import { StudioTagger } from "../Tagger/studios/StudioTagger"; import { StudioCardGrid } from "./StudioCardGrid"; +import { View } from "../List/views"; const StudioItemList = makeItemList({ filterMode: GQL.FilterMode.Studios, @@ -35,12 +32,14 @@ const StudioItemList = makeItemList({ interface IStudioList { fromParent?: boolean; filterHook?: (filter: ListFilterModel) => ListFilterModel; + view?: View; alterQuery?: boolean; } export const StudioList: React.FC = ({ fromParent, filterHook, + view, alterQuery, }) => { const intl = useIntl(); @@ -181,7 +180,7 @@ export const StudioList: React.FC = ({ { useScrollToTopOnMount(); - return ; + return ; }; const StudioRoutes: React.FC = () => { diff --git a/ui/v2.5/src/components/Tags/TagDetails/TagGalleriesPanel.tsx b/ui/v2.5/src/components/Tags/TagDetails/TagGalleriesPanel.tsx index 203715bfb4a..7d46c4e3133 100644 --- a/ui/v2.5/src/components/Tags/TagDetails/TagGalleriesPanel.tsx +++ b/ui/v2.5/src/components/Tags/TagDetails/TagGalleriesPanel.tsx @@ -2,6 +2,7 @@ import React from "react"; import * as GQL from "src/core/generated-graphql"; import { useTagFilterHook } from "src/core/tags"; import { GalleryList } from "src/components/Galleries/GalleryList"; +import { View } from "src/components/List/views"; interface ITagGalleriesPanel { active: boolean; @@ -13,5 +14,11 @@ export const TagGalleriesPanel: React.FC = ({ tag, }) => { const filterHook = useTagFilterHook(tag); - return ; + return ( + + ); }; diff --git a/ui/v2.5/src/components/Tags/TagDetails/TagImagesPanel.tsx b/ui/v2.5/src/components/Tags/TagDetails/TagImagesPanel.tsx index 1c6ea2ec9a7..61e235499bb 100644 --- a/ui/v2.5/src/components/Tags/TagDetails/TagImagesPanel.tsx +++ b/ui/v2.5/src/components/Tags/TagDetails/TagImagesPanel.tsx @@ -2,6 +2,7 @@ import React from "react"; import * as GQL from "src/core/generated-graphql"; import { useTagFilterHook } from "src/core/tags"; import { ImageList } from "src/components/Images/ImageList"; +import { View } from "src/components/List/views"; interface ITagImagesPanel { active: boolean; @@ -10,5 +11,11 @@ interface ITagImagesPanel { export const TagImagesPanel: React.FC = ({ active, tag }) => { const filterHook = useTagFilterHook(tag); - return ; + return ( + + ); }; diff --git a/ui/v2.5/src/components/Tags/TagDetails/TagMarkersPanel.tsx b/ui/v2.5/src/components/Tags/TagDetails/TagMarkersPanel.tsx index 95d2d56074b..2bd4658d548 100644 --- a/ui/v2.5/src/components/Tags/TagDetails/TagMarkersPanel.tsx +++ b/ui/v2.5/src/components/Tags/TagDetails/TagMarkersPanel.tsx @@ -6,6 +6,7 @@ import { TagsCriterionOption, } from "src/models/list-filter/criteria/tags"; import { SceneMarkerList } from "src/components/Scenes/SceneMarkerList"; +import { View } from "src/components/List/views"; interface ITagMarkersPanel { active: boolean; @@ -52,5 +53,11 @@ export const TagMarkersPanel: React.FC = ({ return filter; } - return ; + return ( + + ); }; diff --git a/ui/v2.5/src/components/Tags/TagDetails/TagPerformersPanel.tsx b/ui/v2.5/src/components/Tags/TagDetails/TagPerformersPanel.tsx index 255acaf643f..192b89dcc4c 100644 --- a/ui/v2.5/src/components/Tags/TagDetails/TagPerformersPanel.tsx +++ b/ui/v2.5/src/components/Tags/TagDetails/TagPerformersPanel.tsx @@ -2,6 +2,7 @@ import React from "react"; import * as GQL from "src/core/generated-graphql"; import { useTagFilterHook } from "src/core/tags"; import { PerformerList } from "src/components/Performers/PerformerList"; +import { View } from "src/components/List/views"; interface ITagPerformersPanel { active: boolean; @@ -13,5 +14,11 @@ export const TagPerformersPanel: React.FC = ({ tag, }) => { const filterHook = useTagFilterHook(tag); - return ; + return ( + + ); }; diff --git a/ui/v2.5/src/components/Tags/TagDetails/TagScenesPanel.tsx b/ui/v2.5/src/components/Tags/TagDetails/TagScenesPanel.tsx index 5de9ed9163e..39d850d35e4 100644 --- a/ui/v2.5/src/components/Tags/TagDetails/TagScenesPanel.tsx +++ b/ui/v2.5/src/components/Tags/TagDetails/TagScenesPanel.tsx @@ -2,6 +2,7 @@ import React from "react"; import * as GQL from "src/core/generated-graphql"; import { SceneList } from "src/components/Scenes/SceneList"; import { useTagFilterHook } from "src/core/tags"; +import { View } from "src/components/List/views"; interface ITagScenesPanel { active: boolean; @@ -10,5 +11,11 @@ interface ITagScenesPanel { export const TagScenesPanel: React.FC = ({ active, tag }) => { const filterHook = useTagFilterHook(tag); - return ; + return ( + + ); }; diff --git a/ui/v2.5/src/components/Tags/TagList.tsx b/ui/v2.5/src/components/Tags/TagList.tsx index 2458a273ba8..42a6316f9c0 100644 --- a/ui/v2.5/src/components/Tags/TagList.tsx +++ b/ui/v2.5/src/components/Tags/TagList.tsx @@ -3,11 +3,7 @@ import cloneDeep from "lodash-es/cloneDeep"; import Mousetrap from "mousetrap"; import { ListFilterModel } from "src/models/list-filter/filter"; import { DisplayMode } from "src/models/list-filter/types"; -import { - makeItemList, - PersistanceLevel, - showWhenSelected, -} from "../List/ItemList"; +import { makeItemList, showWhenSelected } from "../List/ItemList"; import { Button } from "react-bootstrap"; import { Link, useHistory } from "react-router-dom"; import * as GQL from "src/core/generated-graphql"; @@ -29,6 +25,7 @@ import { tagRelationHook } from "../../core/tags"; import { faTrashAlt } from "@fortawesome/free-solid-svg-icons"; import { TagCardGrid } from "./TagCardGrid"; import { EditTagsDialog } from "./EditTagsDialog"; +import { View } from "../List/views"; interface ITagList { filterHook?: (filter: ListFilterModel) => ListFilterModel; @@ -363,7 +360,7 @@ export const TagList: React.FC = ({ filterHook, alterQuery }) => { zoomable defaultZoomIndex={0} filterHook={filterHook} - persistState={PersistanceLevel.ALL} + view={View.Tags} alterQuery={alterQuery} otherOperations={otherOperations} addKeybinds={addKeybinds} diff --git a/ui/v2.5/src/core/StashService.ts b/ui/v2.5/src/core/StashService.ts index 251df72f57a..d7518b6c77c 100644 --- a/ui/v2.5/src/core/StashService.ts +++ b/ui/v2.5/src/core/StashService.ts @@ -452,11 +452,6 @@ export const useFindSavedFilters = (mode?: GQL.FilterMode) => variables: { mode }, }); -export const useFindDefaultFilter = (mode: GQL.FilterMode) => - GQL.useFindDefaultFilterQuery({ - variables: { mode }, - }); - /// Object Mutations // Increases/decreases the given field of the Stats query by diff @@ -1956,15 +1951,6 @@ export const useSaveFilter = () => }, }); -export const useSetDefaultFilter = () => - GQL.useSetDefaultFilterMutation({ - update(cache, result) { - if (!result.data?.setDefaultFilter) return; - - evictQueries(cache, [GQL.FindDefaultFilterDocument]); - }, - }); - export const useSavedFilterDestroy = () => GQL.useDestroySavedFilterMutation({ update(cache, result, { variables }) { @@ -1972,8 +1958,6 @@ export const useSavedFilterDestroy = () => const obj = { __typename: "SavedFilter", id: variables.input.id }; deleteObject(cache, obj, GQL.FindSavedFilterDocument); - - evictQueries(cache, [GQL.FindDefaultFilterDocument]); }, }); diff --git a/ui/v2.5/src/core/config.ts b/ui/v2.5/src/core/config.ts index 1e0d1030a72..3e7585df76d 100644 --- a/ui/v2.5/src/core/config.ts +++ b/ui/v2.5/src/core/config.ts @@ -2,7 +2,12 @@ import { IntlShape } from "react-intl"; import { ITypename } from "src/utils/data"; import { ImageWallOptions } from "src/utils/imageWall"; import { RatingSystemOptions } from "src/utils/rating"; -import { FilterMode, SortDirectionEnum } from "./generated-graphql"; +import { + FilterMode, + SavedFilterDataFragment, + SortDirectionEnum, +} from "./generated-graphql"; +import { View } from "src/components/List/views"; // NOTE: double capitals aren't converted correctly in the backend @@ -25,6 +30,10 @@ export interface ICustomFilter extends ITypename { direction: SortDirectionEnum; } +export type DefaultFilters = { + [P in View]?: SavedFilterDataFragment; +}; + export type FrontPageContent = ISavedFilterRow | ICustomFilter; export const defaultMaxOptionsShown = 200; @@ -86,6 +95,8 @@ export interface IUIConfig { advancedMode?: boolean; taskDefaults?: Record; + + defaultFilters?: DefaultFilters; } export function getFrontPageContent( diff --git a/ui/v2.5/src/core/createClient.ts b/ui/v2.5/src/core/createClient.ts index 045f276760c..e5f502cca9e 100644 --- a/ui/v2.5/src/core/createClient.ts +++ b/ui/v2.5/src/core/createClient.ts @@ -61,9 +61,6 @@ const typePolicies: TypePolicies = { findSavedFilter: { read: readReference("SavedFilter"), }, - findDefaultFilter: { - read: readDanglingNull, - }, }, }, Scene: { diff --git a/ui/v2.5/src/docs/en/MigrationNotes/60.md b/ui/v2.5/src/docs/en/MigrationNotes/60.md new file mode 100644 index 00000000000..5d2feaf0ec7 --- /dev/null +++ b/ui/v2.5/src/docs/en/MigrationNotes/60.md @@ -0,0 +1 @@ +This migration moves default filters from the database into the configuration file. A backup of the current `config.yml` will be created in the same directory with the name `config.yml.59.`. The exact filename is written to the log. \ No newline at end of file diff --git a/ui/v2.5/src/docs/en/MigrationNotes/index.ts b/ui/v2.5/src/docs/en/MigrationNotes/index.ts index dd4f3fcd1e4..5a201a091c2 100644 --- a/ui/v2.5/src/docs/en/MigrationNotes/index.ts +++ b/ui/v2.5/src/docs/en/MigrationNotes/index.ts @@ -2,10 +2,12 @@ import migration32 from "./32.md"; import migration39 from "./39.md"; import migration48 from "./48.md"; import migration58 from "./58.md"; +import migration60 from "./60.md"; export const migrationNotes: Record = { 32: migration32, 39: migration39, 48: migration48, 58: migration58, + 60: migration60, }; diff --git a/ui/v2.5/src/models/list-filter/filter.ts b/ui/v2.5/src/models/list-filter/filter.ts index cd84eaadaf9..794fd2a7e90 100644 --- a/ui/v2.5/src/models/list-filter/filter.ts +++ b/ui/v2.5/src/models/list-filter/filter.ts @@ -17,6 +17,7 @@ import { SavedObjectFilter, SavedUIOptions, } from "./types"; +import { ListFilterOptions } from "./filter-options"; interface IDecodedParams { perPage?: number; @@ -49,7 +50,8 @@ const DEFAULT_PARAMS = { // TODO: handle customCriteria export class ListFilterModel { - public mode: FilterMode; + public readonly mode: FilterMode; + public readonly options: ListFilterOptions; private config?: ConfigDataFragment; public searchTerm: string = ""; public currentPage = DEFAULT_PARAMS.currentPage; @@ -65,19 +67,18 @@ export class ListFilterModel { public constructor( mode: FilterMode, config?: ConfigDataFragment, - defaultSort?: string, - defaultDisplayMode?: DisplayMode, defaultZoomIndex?: number ) { this.mode = mode; this.config = config; - this.sortBy = defaultSort; + this.options = getFilterOptions(mode); + const { defaultSortBy, displayModeOptions } = this.options; + + this.sortBy = defaultSortBy; if (this.sortBy === "date") { this.sortDirection = SortDirectionEnum.Desc; } - if (defaultDisplayMode !== undefined) { - this.displayMode = defaultDisplayMode; - } + this.displayMode = displayModeOptions[0]; if (defaultZoomIndex !== undefined) { this.defaultZoomIndex = defaultZoomIndex; this.zoomIndex = defaultZoomIndex; diff --git a/ui/v2.5/src/models/sceneQueue.ts b/ui/v2.5/src/models/sceneQueue.ts index 83b7e7820db..ceec6909031 100644 --- a/ui/v2.5/src/models/sceneQueue.ts +++ b/ui/v2.5/src/models/sceneQueue.ts @@ -1,6 +1,5 @@ import { FilterMode, Scene } from "src/core/generated-graphql"; import { ListFilterModel } from "./list-filter/filter"; -import { SceneListFilterOptions } from "./list-filter/scenes"; import { INamedObject } from "src/utils/navigation"; export type QueuedScene = Pick & { @@ -97,11 +96,7 @@ export class SceneQueue { c: params.getAll("qfc"), }; const decoded = ListFilterModel.decodeParams(translated); - const query = new ListFilterModel( - FilterMode.Scenes, - undefined, - SceneListFilterOptions.defaultSortBy - ); + const query = new ListFilterModel(FilterMode.Scenes); query.configureFromDecodedParams(decoded); ret.query = query; } else if (params.has("qs")) { diff --git a/ui/v2.5/src/pluginApi.d.ts b/ui/v2.5/src/pluginApi.d.ts index 4967eedbfa9..ae292ceeeb3 100644 --- a/ui/v2.5/src/pluginApi.d.ts +++ b/ui/v2.5/src/pluginApi.d.ts @@ -39,7 +39,6 @@ declare namespace PluginApi { const EnableDlnaDocument: { [key: string]: any }; const ExportObjectsDocument: { [key: string]: any }; const FilterMode: { [key: string]: any }; - const FindDefaultFilterDocument: { [key: string]: any }; const FindDuplicateScenesDocument: { [key: string]: any }; const FindGalleriesDocument: { [key: string]: any }; const FindGalleriesForSelectDocument: { [key: string]: any }; @@ -208,7 +207,6 @@ declare namespace PluginApi { const SelectPerformerDataFragmentDoc: { [key: string]: any }; const SelectStudioDataFragmentDoc: { [key: string]: any }; const SelectTagDataFragmentDoc: { [key: string]: any }; - const SetDefaultFilterDocument: { [key: string]: any }; const SetPluginsEnabledDocument: { [key: string]: any }; const SetupDocument: { [key: string]: any }; const SlimGalleryDataFragmentDoc: { [key: string]: any }; @@ -254,7 +252,6 @@ declare namespace PluginApi { function refetchConfigurationQuery(...args: any[]): any; function refetchDirectoryQuery(...args: any[]): any; function refetchDlnaStatusQuery(...args: any[]): any; - function refetchFindDefaultFilterQuery(...args: any[]): any; function refetchFindDuplicateScenesQuery(...args: any[]): any; function refetchFindGalleriesForSelectQuery(...args: any[]): any; function refetchFindGalleriesQuery(...args: any[]): any; @@ -349,9 +346,6 @@ declare namespace PluginApi { function useDlnaStatusSuspenseQuery(...args: any[]): any; function useEnableDlnaMutation(...args: any[]): any; function useExportObjectsMutation(...args: any[]): any; - function useFindDefaultFilterLazyQuery(...args: any[]): any; - function useFindDefaultFilterQuery(...args: any[]): any; - function useFindDefaultFilterSuspenseQuery(...args: any[]): any; function useFindDuplicateScenesLazyQuery(...args: any[]): any; function useFindDuplicateScenesQuery(...args: any[]): any; function useFindDuplicateScenesSuspenseQuery(...args: any[]): any; From fda4776d30f2b97c736bbe4b83f6d8340f60942d Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Tue, 18 Jun 2024 11:24:15 +1000 Subject: [PATCH 017/103] Movie/Group tags (#4969) * Combine common tag control code into hook * Combine common scraped tag row code into hook --- graphql/schema/types/filters.graphql | 7 + graphql/schema/types/movie.graphql | 4 + graphql/schema/types/scraped-movie.graphql | 2 + graphql/schema/types/tag.graphql | 1 + internal/api/resolver_model_movie.go | 14 + internal/api/resolver_model_tag.go | 12 + internal/api/resolver_mutation_movie.go | 16 + internal/api/resolver_query_scraper.go | 26 +- internal/manager/task_export.go | 9 + internal/manager/task_import.go | 1 + pkg/models/jsonschema/movie.go | 1 + pkg/models/mocks/MovieReaderWriter.go | 23 + pkg/models/mocks/TagReaderWriter.go | 23 + pkg/models/model_movie.go | 16 +- pkg/models/model_scraped_item.go | 1 + pkg/models/movie.go | 4 + pkg/models/repository_movie.go | 1 + pkg/models/repository_tag.go | 1 + pkg/models/tag.go | 2 + pkg/movie/import.go | 77 +++ pkg/movie/import_test.go | 98 ++++ pkg/movie/query.go | 12 + pkg/scraper/mapped.go | 21 +- pkg/scraper/postprocessing.go | 23 +- pkg/sqlite/database.go | 2 +- pkg/sqlite/migrations/61_movie_tags.up.sql | 10 + pkg/sqlite/movies.go | 33 ++ pkg/sqlite/movies_filter.go | 27 ++ pkg/sqlite/movies_test.go | 449 +++++++++++++++++- pkg/sqlite/relationships.go | 41 ++ pkg/sqlite/setup_test.go | 28 +- pkg/sqlite/table.go | 11 + pkg/sqlite/tables.go | 11 + pkg/sqlite/tag.go | 29 ++ pkg/sqlite/tag_filter.go | 12 + pkg/sqlite/tag_test.go | 31 ++ ui/v2.5/graphql/data/movie.graphql | 4 + ui/v2.5/graphql/data/scrapers.graphql | 6 + ui/v2.5/graphql/data/tag.graphql | 2 + .../GalleryDetails/GalleryEditPanel.tsx | 49 +- .../GalleryDetails/GalleryScrapeDialog.tsx | 34 +- .../Images/ImageDetails/ImageEditPanel.tsx | 30 +- .../components/Movies/EditMoviesDialog.tsx | 34 ++ ui/v2.5/src/components/Movies/MovieCard.tsx | 69 ++- .../components/Movies/MovieDetails/Movie.tsx | 1 + .../Movies/MovieDetails/MovieDetailsPanel.tsx | 37 +- .../Movies/MovieDetails/MovieEditPanel.tsx | 16 + .../Movies/MovieDetails/MovieScrapeDialog.tsx | 72 +-- .../PerformerDetails/PerformerEditPanel.tsx | 130 +---- .../PerformerScrapeDialog.tsx | 41 +- .../Scenes/SceneDetails/SceneEditPanel.tsx | 49 +- .../Scenes/SceneDetails/SceneScrapeDialog.tsx | 34 +- .../Shared/ScrapeDialog/scrapedTags.tsx | 53 +++ ui/v2.5/src/components/Shared/TagLink.tsx | 4 +- ui/v2.5/src/components/Tags/TagCard.tsx | 14 + .../src/components/Tags/TagDetails/Tag.tsx | 30 +- .../Tags/TagDetails/TagMoviesPanel.tsx | 12 + ui/v2.5/src/hooks/tagsEdit.tsx | 148 ++++++ ui/v2.5/src/locales/en-GB.json | 1 + ui/v2.5/src/models/list-filter/movies.ts | 13 +- ui/v2.5/src/models/list-filter/tags.ts | 5 + ui/v2.5/src/models/list-filter/types.ts | 1 + ui/v2.5/src/utils/navigation.ts | 68 +-- 63 files changed, 1586 insertions(+), 450 deletions(-) create mode 100644 pkg/sqlite/migrations/61_movie_tags.up.sql create mode 100644 pkg/sqlite/relationships.go create mode 100644 ui/v2.5/src/components/Shared/ScrapeDialog/scrapedTags.tsx create mode 100644 ui/v2.5/src/components/Tags/TagDetails/TagMoviesPanel.tsx create mode 100644 ui/v2.5/src/hooks/tagsEdit.tsx diff --git a/graphql/schema/types/filters.graphql b/graphql/schema/types/filters.graphql index 92127416fd1..1df9d2fba1b 100644 --- a/graphql/schema/types/filters.graphql +++ b/graphql/schema/types/filters.graphql @@ -334,6 +334,10 @@ input MovieFilterType { url: StringCriterionInput "Filter to only include movies where performer appears in a scene" performers: MultiCriterionInput + "Filter to only include movies with these tags" + tags: HierarchicalMultiCriterionInput + "Filter by tag count" + tag_count: IntCriterionInput "Filter by date" date: DateCriterionInput "Filter by creation time" @@ -494,6 +498,9 @@ input TagFilterType { "Filter by number of performers with this tag" performer_count: IntCriterionInput + "Filter by number of movies with this tag" + movie_count: IntCriterionInput + "Filter by number of markers with this tag" marker_count: IntCriterionInput diff --git a/graphql/schema/types/movie.graphql b/graphql/schema/types/movie.graphql index 8501d88334a..0723bcc4f28 100644 --- a/graphql/schema/types/movie.graphql +++ b/graphql/schema/types/movie.graphql @@ -12,6 +12,7 @@ type Movie { synopsis: String url: String @deprecated(reason: "Use urls") urls: [String!]! + tags: [Tag!]! created_at: Time! updated_at: Time! @@ -34,6 +35,7 @@ input MovieCreateInput { synopsis: String url: String @deprecated(reason: "Use urls") urls: [String!] + tag_ids: [ID!] "This should be a URL or a base64 encoded data URL" front_image: String "This should be a URL or a base64 encoded data URL" @@ -53,6 +55,7 @@ input MovieUpdateInput { synopsis: String url: String @deprecated(reason: "Use urls") urls: [String!] + tag_ids: [ID!] "This should be a URL or a base64 encoded data URL" front_image: String "This should be a URL or a base64 encoded data URL" @@ -67,6 +70,7 @@ input BulkMovieUpdateInput { studio_id: ID director: String urls: BulkUpdateStrings + tag_ids: BulkUpdateIds } input MovieDestroyInput { diff --git a/graphql/schema/types/scraped-movie.graphql b/graphql/schema/types/scraped-movie.graphql index f45903ccef1..5b07a222c93 100644 --- a/graphql/schema/types/scraped-movie.graphql +++ b/graphql/schema/types/scraped-movie.graphql @@ -11,6 +11,7 @@ type ScrapedMovie { urls: [String!] synopsis: String studio: ScrapedStudio + tags: [ScrapedTag!] "This should be a base64 encoded data URL" front_image: String @@ -28,4 +29,5 @@ input ScrapedMovieInput { url: String @deprecated(reason: "use urls") urls: [String!] synopsis: String + # not including tags for the input } diff --git a/graphql/schema/types/tag.graphql b/graphql/schema/types/tag.graphql index 6438b52e1fa..35229c5cb81 100644 --- a/graphql/schema/types/tag.graphql +++ b/graphql/schema/types/tag.graphql @@ -13,6 +13,7 @@ type Tag { image_count(depth: Int): Int! # Resolver gallery_count(depth: Int): Int! # Resolver performer_count(depth: Int): Int! # Resolver + movie_count(depth: Int): Int! # Resolver parents: [Tag!]! children: [Tag!]! diff --git a/internal/api/resolver_model_movie.go b/internal/api/resolver_model_movie.go index 630b7d2a0ea..d1509c7a18a 100644 --- a/internal/api/resolver_model_movie.go +++ b/internal/api/resolver_model_movie.go @@ -57,6 +57,20 @@ func (r *movieResolver) Studio(ctx context.Context, obj *models.Movie) (ret *mod return loaders.From(ctx).StudioByID.Load(*obj.StudioID) } +func (r movieResolver) Tags(ctx context.Context, obj *models.Movie) (ret []*models.Tag, err error) { + if !obj.TagIDs.Loaded() { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { + return obj.LoadTagIDs(ctx, r.repository.Movie) + }); err != nil { + return nil, err + } + } + + var errs []error + ret, errs = loaders.From(ctx).TagByID.LoadAll(obj.TagIDs.List()) + return ret, firstError(errs) +} + func (r *movieResolver) FrontImagePath(ctx context.Context, obj *models.Movie) (*string, error) { var hasImage bool if err := r.withReadTxn(ctx, func(ctx context.Context) error { diff --git a/internal/api/resolver_model_tag.go b/internal/api/resolver_model_tag.go index d219fcc66d7..7c32667d24f 100644 --- a/internal/api/resolver_model_tag.go +++ b/internal/api/resolver_model_tag.go @@ -8,6 +8,7 @@ import ( "github.com/stashapp/stash/pkg/gallery" "github.com/stashapp/stash/pkg/image" "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/movie" "github.com/stashapp/stash/pkg/performer" "github.com/stashapp/stash/pkg/scene" ) @@ -107,6 +108,17 @@ func (r *tagResolver) PerformerCount(ctx context.Context, obj *models.Tag, depth return ret, nil } +func (r *tagResolver) MovieCount(ctx context.Context, obj *models.Tag, depth *int) (ret int, err error) { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { + ret, err = movie.CountByTagID(ctx, r.repository.Movie, obj.ID, depth) + return err + }); err != nil { + return 0, err + } + + return ret, nil +} + func (r *tagResolver) ImagePath(ctx context.Context, obj *models.Tag) (*string, error) { var hasImage bool if err := r.withReadTxn(ctx, func(ctx context.Context) error { diff --git a/internal/api/resolver_mutation_movie.go b/internal/api/resolver_mutation_movie.go index 82198c125d4..c3fce71a601 100644 --- a/internal/api/resolver_mutation_movie.go +++ b/internal/api/resolver_mutation_movie.go @@ -50,6 +50,11 @@ func (r *mutationResolver) MovieCreate(ctx context.Context, input MovieCreateInp return nil, fmt.Errorf("converting studio id: %w", err) } + newMovie.TagIDs, err = translator.relatedIds(input.TagIds) + if err != nil { + return nil, fmt.Errorf("converting tag ids: %w", err) + } + if input.Urls != nil { newMovie.URLs = models.NewRelatedStrings(input.Urls) } else if input.URL != nil { @@ -140,6 +145,11 @@ func (r *mutationResolver) MovieUpdate(ctx context.Context, input MovieUpdateInp return nil, fmt.Errorf("converting studio id: %w", err) } + updatedMovie.TagIDs, err = translator.updateIds(input.TagIds, "tag_ids") + if err != nil { + return nil, fmt.Errorf("converting tag ids: %w", err) + } + updatedMovie.URLs = translator.optionalURLs(input.Urls, input.URL) var frontimageData []byte @@ -211,6 +221,12 @@ func (r *mutationResolver) BulkMovieUpdate(ctx context.Context, input BulkMovieU if err != nil { return nil, fmt.Errorf("converting studio id: %w", err) } + + updatedMovie.TagIDs, err = translator.updateIdsBulk(input.TagIds, "tag_ids") + if err != nil { + return nil, fmt.Errorf("converting tag ids: %w", err) + } + updatedMovie.URLs = translator.optionalURLsBulk(input.Urls, nil) ret := []*models.Movie{} diff --git a/internal/api/resolver_query_scraper.go b/internal/api/resolver_query_scraper.go index 4a65c52f5c6..503f73b7e80 100644 --- a/internal/api/resolver_query_scraper.go +++ b/internal/api/resolver_query_scraper.go @@ -144,6 +144,23 @@ func filterPerformerTags(p []*models.ScrapedPerformer) { } } +// filterMovieTags removes tags matching excluded tag patterns from the provided scraped movies +func filterMovieTags(p []*models.ScrapedMovie) { + excludeRegexps := compileRegexps(manager.GetInstance().Config.GetScraperExcludeTagPatterns()) + + var ignoredTags []string + + for _, s := range p { + var ignored []string + s.Tags, ignored = filterTags(excludeRegexps, s.Tags) + ignoredTags = sliceutil.AppendUniques(ignoredTags, ignored) + } + + if len(ignoredTags) > 0 { + logger.Debugf("Scraping ignored tags: %s", strings.Join(ignoredTags, ", ")) + } +} + func (r *queryResolver) ScrapeSceneURL(ctx context.Context, url string) (*scraper.ScrapedScene, error) { content, err := r.scraperCache().ScrapeURL(ctx, url, scraper.ScrapeContentTypeScene) if err != nil { @@ -186,7 +203,14 @@ func (r *queryResolver) ScrapeMovieURL(ctx context.Context, url string) (*models return nil, err } - return marshalScrapedMovie(content) + ret, err := marshalScrapedMovie(content) + if err != nil { + return nil, err + } + + filterMovieTags([]*models.ScrapedMovie{ret}) + + return ret, nil } func (r *queryResolver) ScrapeSingleScene(ctx context.Context, source scraper.Source, input ScrapeSingleSceneInput) ([]*scraper.ScrapedScene, error) { diff --git a/internal/manager/task_export.go b/internal/manager/task_export.go index 555502dc5b0..2daac200815 100644 --- a/internal/manager/task_export.go +++ b/internal/manager/task_export.go @@ -1107,6 +1107,7 @@ func (t *ExportTask) exportMovie(ctx context.Context, wg *sync.WaitGroup, jobCha r := t.repository movieReader := r.Movie studioReader := r.Studio + tagReader := r.Tag for m := range jobChan { if err := m.LoadURLs(ctx, r.Movie); err != nil { @@ -1121,6 +1122,14 @@ func (t *ExportTask) exportMovie(ctx context.Context, wg *sync.WaitGroup, jobCha continue } + tags, err := tagReader.FindByMovieID(ctx, m.ID) + if err != nil { + logger.Errorf("[movies] <%s> error getting image tag names: %v", m.Name, err) + continue + } + + newMovieJSON.Tags = tag.GetNames(tags) + if t.includeDependencies { if m.StudioID != nil { t.studios.IDs = sliceutil.AppendUnique(t.studios.IDs, *m.StudioID) diff --git a/internal/manager/task_import.go b/internal/manager/task_import.go index 9b5de7354fb..c9d5b54ba72 100644 --- a/internal/manager/task_import.go +++ b/internal/manager/task_import.go @@ -351,6 +351,7 @@ func (t *ImportTask) ImportMovies(ctx context.Context) { movieImporter := &movie.Importer{ ReaderWriter: r.Movie, StudioWriter: r.Studio, + TagWriter: r.Tag, Input: *movieJSON, MissingRefBehaviour: t.MissingRefBehaviour, } diff --git a/pkg/models/jsonschema/movie.go b/pkg/models/jsonschema/movie.go index 33ce10c1d4a..eeefe1ed17d 100644 --- a/pkg/models/jsonschema/movie.go +++ b/pkg/models/jsonschema/movie.go @@ -23,6 +23,7 @@ type Movie struct { BackImage string `json:"back_image,omitempty"` URLs []string `json:"urls,omitempty"` Studio string `json:"studio,omitempty"` + Tags []string `json:"tags,omitempty"` CreatedAt json.JSONTime `json:"created_at,omitempty"` UpdatedAt json.JSONTime `json:"updated_at,omitempty"` diff --git a/pkg/models/mocks/MovieReaderWriter.go b/pkg/models/mocks/MovieReaderWriter.go index 3f693be94ed..0da8c8a196f 100644 --- a/pkg/models/mocks/MovieReaderWriter.go +++ b/pkg/models/mocks/MovieReaderWriter.go @@ -312,6 +312,29 @@ func (_m *MovieReaderWriter) GetFrontImage(ctx context.Context, movieID int) ([] return r0, r1 } +// GetTagIDs provides a mock function with given fields: ctx, relatedID +func (_m *MovieReaderWriter) GetTagIDs(ctx context.Context, relatedID int) ([]int, error) { + ret := _m.Called(ctx, relatedID) + + var r0 []int + if rf, ok := ret.Get(0).(func(context.Context, int) []int); ok { + r0 = rf(ctx, relatedID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]int) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { + r1 = rf(ctx, relatedID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // GetURLs provides a mock function with given fields: ctx, relatedID func (_m *MovieReaderWriter) GetURLs(ctx context.Context, relatedID int) ([]string, error) { ret := _m.Called(ctx, relatedID) diff --git a/pkg/models/mocks/TagReaderWriter.go b/pkg/models/mocks/TagReaderWriter.go index f4c494016f3..d18f6a66b6c 100644 --- a/pkg/models/mocks/TagReaderWriter.go +++ b/pkg/models/mocks/TagReaderWriter.go @@ -266,6 +266,29 @@ func (_m *TagReaderWriter) FindByImageID(ctx context.Context, imageID int) ([]*m return r0, r1 } +// FindByMovieID provides a mock function with given fields: ctx, movieID +func (_m *TagReaderWriter) FindByMovieID(ctx context.Context, movieID int) ([]*models.Tag, error) { + ret := _m.Called(ctx, movieID) + + var r0 []*models.Tag + if rf, ok := ret.Get(0).(func(context.Context, int) []*models.Tag); ok { + r0 = rf(ctx, movieID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*models.Tag) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { + r1 = rf(ctx, movieID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // FindByName provides a mock function with given fields: ctx, name, nocase func (_m *TagReaderWriter) FindByName(ctx context.Context, name string, nocase bool) (*models.Tag, error) { ret := _m.Called(ctx, name, nocase) diff --git a/pkg/models/model_movie.go b/pkg/models/model_movie.go index d1ce0d8dcbf..cd8bb848c80 100644 --- a/pkg/models/model_movie.go +++ b/pkg/models/model_movie.go @@ -19,7 +19,8 @@ type Movie struct { CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` - URLs RelatedStrings `json:"urls"` + URLs RelatedStrings `json:"urls"` + TagIDs RelatedIDs `json:"tag_ids"` } func NewMovie() Movie { @@ -30,9 +31,15 @@ func NewMovie() Movie { } } -func (g *Movie) LoadURLs(ctx context.Context, l URLLoader) error { - return g.URLs.load(func() ([]string, error) { - return l.GetURLs(ctx, g.ID) +func (m *Movie) LoadURLs(ctx context.Context, l URLLoader) error { + return m.URLs.load(func() ([]string, error) { + return l.GetURLs(ctx, m.ID) + }) +} + +func (m *Movie) LoadTagIDs(ctx context.Context, l TagIDLoader) error { + return m.TagIDs.load(func() ([]int, error) { + return l.GetTagIDs(ctx, m.ID) }) } @@ -47,6 +54,7 @@ type MoviePartial struct { Director OptionalString Synopsis OptionalString URLs *UpdateStrings + TagIDs *UpdateIDs CreatedAt OptionalTime UpdatedAt OptionalTime } diff --git a/pkg/models/model_scraped_item.go b/pkg/models/model_scraped_item.go index 5a9f2acb036..5cc5c679cb3 100644 --- a/pkg/models/model_scraped_item.go +++ b/pkg/models/model_scraped_item.go @@ -371,6 +371,7 @@ type ScrapedMovie struct { URLs []string `json:"urls"` Synopsis *string `json:"synopsis"` Studio *ScrapedStudio `json:"studio"` + Tags []*ScrapedTag `json:"tags"` // This should be a base64 encoded data URL FrontImage *string `json:"front_image"` // This should be a base64 encoded data URL diff --git a/pkg/models/movie.go b/pkg/models/movie.go index 95c6efdd1a2..5fb98190dbd 100644 --- a/pkg/models/movie.go +++ b/pkg/models/movie.go @@ -17,6 +17,10 @@ type MovieFilterType struct { URL *StringCriterionInput `json:"url"` // Filter to only include movies where performer appears in a scene Performers *MultiCriterionInput `json:"performers"` + // Filter to only include performers with these tags + Tags *HierarchicalMultiCriterionInput `json:"tags"` + // Filter by tag count + TagCount *IntCriterionInput `json:"tag_count"` // Filter by date Date *DateCriterionInput `json:"date"` // Filter by related scenes that meet this criteria diff --git a/pkg/models/repository_movie.go b/pkg/models/repository_movie.go index 2518e21b529..dec0e042127 100644 --- a/pkg/models/repository_movie.go +++ b/pkg/models/repository_movie.go @@ -65,6 +65,7 @@ type MovieReader interface { MovieQueryer MovieCounter URLLoader + TagIDLoader All(ctx context.Context) ([]*Movie, error) GetFrontImage(ctx context.Context, movieID int) ([]byte, error) diff --git a/pkg/models/repository_tag.go b/pkg/models/repository_tag.go index 6d38785e6d0..287aeb211b8 100644 --- a/pkg/models/repository_tag.go +++ b/pkg/models/repository_tag.go @@ -20,6 +20,7 @@ type TagFinder interface { FindByImageID(ctx context.Context, imageID int) ([]*Tag, error) FindByGalleryID(ctx context.Context, galleryID int) ([]*Tag, error) FindByPerformerID(ctx context.Context, performerID int) ([]*Tag, error) + FindByMovieID(ctx context.Context, movieID int) ([]*Tag, error) FindBySceneMarkerID(ctx context.Context, sceneMarkerID int) ([]*Tag, error) FindByName(ctx context.Context, name string, nocase bool) (*Tag, error) FindByNames(ctx context.Context, names []string, nocase bool) ([]*Tag, error) diff --git a/pkg/models/tag.go b/pkg/models/tag.go index d51ec9787b0..7ee0705a432 100644 --- a/pkg/models/tag.go +++ b/pkg/models/tag.go @@ -20,6 +20,8 @@ type TagFilterType struct { GalleryCount *IntCriterionInput `json:"gallery_count"` // Filter by number of performers with this tag PerformerCount *IntCriterionInput `json:"performer_count"` + // Filter by number of movies with this tag + MovieCount *IntCriterionInput `json:"movie_count"` // Filter by number of markers with this tag MarkerCount *IntCriterionInput `json:"marker_count"` // Filter by parent tags diff --git a/pkg/movie/import.go b/pkg/movie/import.go index 00e56d4e137..27c25316de0 100644 --- a/pkg/movie/import.go +++ b/pkg/movie/import.go @@ -3,9 +3,11 @@ package movie import ( "context" "fmt" + "strings" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models/jsonschema" + "github.com/stashapp/stash/pkg/sliceutil" "github.com/stashapp/stash/pkg/utils" ) @@ -17,6 +19,7 @@ type ImporterReaderWriter interface { type Importer struct { ReaderWriter ImporterReaderWriter StudioWriter models.StudioFinderCreator + TagWriter models.TagFinderCreator Input jsonschema.Movie MissingRefBehaviour models.ImportMissingRefEnum @@ -32,6 +35,10 @@ func (i *Importer) PreImport(ctx context.Context) error { return err } + if err := i.populateTags(ctx); err != nil { + return err + } + var err error if len(i.Input.FrontImage) > 0 { i.frontImageData, err = utils.ProcessBase64Image(i.Input.FrontImage) @@ -49,6 +56,74 @@ func (i *Importer) PreImport(ctx context.Context) error { return nil } +func (i *Importer) populateTags(ctx context.Context) error { + if len(i.Input.Tags) > 0 { + + tags, err := importTags(ctx, i.TagWriter, i.Input.Tags, i.MissingRefBehaviour) + if err != nil { + return err + } + + for _, p := range tags { + i.movie.TagIDs.Add(p.ID) + } + } + + return nil +} + +func importTags(ctx context.Context, tagWriter models.TagFinderCreator, names []string, missingRefBehaviour models.ImportMissingRefEnum) ([]*models.Tag, error) { + tags, err := tagWriter.FindByNames(ctx, names, false) + if err != nil { + return nil, err + } + + var pluckedNames []string + for _, tag := range tags { + pluckedNames = append(pluckedNames, tag.Name) + } + + missingTags := sliceutil.Filter(names, func(name string) bool { + return !sliceutil.Contains(pluckedNames, name) + }) + + if len(missingTags) > 0 { + if missingRefBehaviour == models.ImportMissingRefEnumFail { + return nil, fmt.Errorf("tags [%s] not found", strings.Join(missingTags, ", ")) + } + + if missingRefBehaviour == models.ImportMissingRefEnumCreate { + createdTags, err := createTags(ctx, tagWriter, missingTags) + if err != nil { + return nil, fmt.Errorf("error creating tags: %v", err) + } + + tags = append(tags, createdTags...) + } + + // ignore if MissingRefBehaviour set to Ignore + } + + return tags, nil +} + +func createTags(ctx context.Context, tagWriter models.TagFinderCreator, names []string) ([]*models.Tag, error) { + var ret []*models.Tag + for _, name := range names { + newTag := models.NewTag() + newTag.Name = name + + err := tagWriter.Create(ctx, &newTag) + if err != nil { + return nil, err + } + + ret = append(ret, &newTag) + } + + return ret, nil +} + func (i *Importer) movieJSONToMovie(movieJSON jsonschema.Movie) models.Movie { newMovie := models.Movie{ Name: movieJSON.Name, @@ -57,6 +132,8 @@ func (i *Importer) movieJSONToMovie(movieJSON jsonschema.Movie) models.Movie { Synopsis: movieJSON.Synopsis, CreatedAt: movieJSON.CreatedAt.GetTime(), UpdatedAt: movieJSON.UpdatedAt.GetTime(), + + TagIDs: models.NewRelatedIDs([]int{}), } if len(movieJSON.URLs) > 0 { diff --git a/pkg/movie/import_test.go b/pkg/movie/import_test.go index d62f5a89004..2cf35319c1a 100644 --- a/pkg/movie/import_test.go +++ b/pkg/movie/import_test.go @@ -26,6 +26,13 @@ const ( missingStudioName = "existingStudioName" errImageID = 3 + + existingTagID = 105 + errTagsID = 106 + + existingTagName = "existingTagName" + existingTagErr = "existingTagErr" + missingTagName = "missingTagName" ) var testCtx = context.Background() @@ -157,6 +164,97 @@ func TestImporterPreImportWithMissingStudioCreateErr(t *testing.T) { db.AssertExpectations(t) } +func TestImporterPreImportWithTag(t *testing.T) { + db := mocks.NewDatabase() + + i := Importer{ + ReaderWriter: db.Movie, + TagWriter: db.Tag, + MissingRefBehaviour: models.ImportMissingRefEnumFail, + Input: jsonschema.Movie{ + Tags: []string{ + existingTagName, + }, + }, + } + + db.Tag.On("FindByNames", testCtx, []string{existingTagName}, false).Return([]*models.Tag{ + { + ID: existingTagID, + Name: existingTagName, + }, + }, nil).Once() + db.Tag.On("FindByNames", testCtx, []string{existingTagErr}, false).Return(nil, errors.New("FindByNames error")).Once() + + err := i.PreImport(testCtx) + assert.Nil(t, err) + assert.Equal(t, existingTagID, i.movie.TagIDs.List()[0]) + + i.Input.Tags = []string{existingTagErr} + err = i.PreImport(testCtx) + assert.NotNil(t, err) + + db.AssertExpectations(t) +} + +func TestImporterPreImportWithMissingTag(t *testing.T) { + db := mocks.NewDatabase() + + i := Importer{ + ReaderWriter: db.Movie, + TagWriter: db.Tag, + Input: jsonschema.Movie{ + Tags: []string{ + missingTagName, + }, + }, + MissingRefBehaviour: models.ImportMissingRefEnumFail, + } + + db.Tag.On("FindByNames", testCtx, []string{missingTagName}, false).Return(nil, nil).Times(3) + db.Tag.On("Create", testCtx, mock.AnythingOfType("*models.Tag")).Run(func(args mock.Arguments) { + t := args.Get(1).(*models.Tag) + t.ID = existingTagID + }).Return(nil) + + err := i.PreImport(testCtx) + assert.NotNil(t, err) + + i.MissingRefBehaviour = models.ImportMissingRefEnumIgnore + err = i.PreImport(testCtx) + assert.Nil(t, err) + + i.MissingRefBehaviour = models.ImportMissingRefEnumCreate + err = i.PreImport(testCtx) + assert.Nil(t, err) + assert.Equal(t, existingTagID, i.movie.TagIDs.List()[0]) + + db.AssertExpectations(t) +} + +func TestImporterPreImportWithMissingTagCreateErr(t *testing.T) { + db := mocks.NewDatabase() + + i := Importer{ + ReaderWriter: db.Movie, + TagWriter: db.Tag, + Input: jsonschema.Movie{ + Tags: []string{ + missingTagName, + }, + }, + MissingRefBehaviour: models.ImportMissingRefEnumCreate, + } + + db.Tag.On("FindByNames", testCtx, []string{missingTagName}, false).Return(nil, nil).Once() + db.Tag.On("Create", testCtx, mock.AnythingOfType("*models.Tag")).Return(errors.New("Create error")) + + err := i.PreImport(testCtx) + assert.NotNil(t, err) + + db.AssertExpectations(t) +} + func TestImporterPostImport(t *testing.T) { db := mocks.NewDatabase() diff --git a/pkg/movie/query.go b/pkg/movie/query.go index 3fac932a03d..72764b8ddea 100644 --- a/pkg/movie/query.go +++ b/pkg/movie/query.go @@ -18,3 +18,15 @@ func CountByStudioID(ctx context.Context, r models.MovieQueryer, id int, depth * return r.QueryCount(ctx, filter, nil) } + +func CountByTagID(ctx context.Context, r models.MovieQueryer, id int, depth *int) (int, error) { + filter := &models.MovieFilterType{ + Tags: &models.HierarchicalMultiCriterionInput{ + Value: []string{strconv.Itoa(id)}, + Modifier: models.CriterionModifierIncludes, + Depth: depth, + }, + } + + return r.QueryCount(ctx, filter, nil) +} diff --git a/pkg/scraper/mapped.go b/pkg/scraper/mapped.go index 1b24379cab0..7b0d6dc7e79 100644 --- a/pkg/scraper/mapped.go +++ b/pkg/scraper/mapped.go @@ -284,11 +284,13 @@ type mappedMovieScraperConfig struct { mappedConfig Studio mappedConfig `yaml:"Studio"` + Tags mappedConfig `yaml:"Tags"` } type _mappedMovieScraperConfig mappedMovieScraperConfig const ( mappedScraperConfigMovieStudio = "Studio" + mappedScraperConfigMovieTags = "Tags" ) func (s *mappedMovieScraperConfig) UnmarshalYAML(unmarshal func(interface{}) error) error { @@ -303,9 +305,11 @@ func (s *mappedMovieScraperConfig) UnmarshalYAML(unmarshal func(interface{}) err thisMap := make(map[string]interface{}) thisMap[mappedScraperConfigMovieStudio] = parentMap[mappedScraperConfigMovieStudio] - delete(parentMap, mappedScraperConfigMovieStudio) + thisMap[mappedScraperConfigMovieTags] = parentMap[mappedScraperConfigMovieTags] + delete(parentMap, mappedScraperConfigMovieTags) + // re-unmarshal the sub-fields yml, err := yaml.Marshal(thisMap) if err != nil { @@ -1086,6 +1090,7 @@ func (s mappedScraper) scrapeMovie(ctx context.Context, q mappedQuery) (*models. movieMap := movieScraperConfig.mappedConfig movieStudioMap := movieScraperConfig.Studio + movieTagsMap := movieScraperConfig.Tags results := movieMap.process(ctx, q, s.Common) @@ -1100,7 +1105,19 @@ func (s mappedScraper) scrapeMovie(ctx context.Context, q mappedQuery) (*models. } } - if len(results) == 0 && ret.Studio == nil { + // now apply the tags + if movieTagsMap != nil { + logger.Debug(`Processing movie tags:`) + tagResults := movieTagsMap.process(ctx, q, s.Common) + + for _, p := range tagResults { + tag := &models.ScrapedTag{} + p.apply(tag) + ret.Tags = append(ret.Tags, tag) + } + } + + if len(results) == 0 && ret.Studio == nil && len(ret.Tags) == 0 { return nil, nil } diff --git a/pkg/scraper/postprocessing.go b/pkg/scraper/postprocessing.go index 0cf9b5a17fb..a375b50582f 100644 --- a/pkg/scraper/postprocessing.go +++ b/pkg/scraper/postprocessing.go @@ -71,13 +71,24 @@ func (c Cache) postScrapePerformer(ctx context.Context, p models.ScrapedPerforme } func (c Cache) postScrapeMovie(ctx context.Context, m models.ScrapedMovie) (ScrapedContent, error) { - if m.Studio != nil { - r := c.repository - if err := r.WithReadTxn(ctx, func(ctx context.Context) error { - return match.ScrapedStudio(ctx, r.StudioFinder, m.Studio, nil) - }); err != nil { - return nil, err + r := c.repository + if err := r.WithReadTxn(ctx, func(ctx context.Context) error { + tqb := r.TagFinder + tags, err := postProcessTags(ctx, tqb, m.Tags) + if err != nil { + return err + } + m.Tags = tags + + if m.Studio != nil { + if err := match.ScrapedStudio(ctx, r.StudioFinder, m.Studio, nil); err != nil { + return err + } } + + return nil + }); err != nil { + return nil, err } // post-process - set the image if applicable diff --git a/pkg/sqlite/database.go b/pkg/sqlite/database.go index 7303400a3b6..7cfcd200384 100644 --- a/pkg/sqlite/database.go +++ b/pkg/sqlite/database.go @@ -30,7 +30,7 @@ const ( dbConnTimeout = 30 ) -var appSchemaVersion uint = 60 +var appSchemaVersion uint = 61 //go:embed migrations/*.sql var migrationsBox embed.FS diff --git a/pkg/sqlite/migrations/61_movie_tags.up.sql b/pkg/sqlite/migrations/61_movie_tags.up.sql new file mode 100644 index 00000000000..cf898e2c590 --- /dev/null +++ b/pkg/sqlite/migrations/61_movie_tags.up.sql @@ -0,0 +1,10 @@ +CREATE TABLE `movies_tags` ( + `movie_id` integer NOT NULL, + `tag_id` integer NOT NULL, + foreign key(`movie_id`) references `movies`(`id`) on delete CASCADE, + foreign key(`tag_id`) references `tags`(`id`) on delete CASCADE, + PRIMARY KEY(`movie_id`, `tag_id`) +); + +CREATE INDEX `index_movies_tags_on_tag_id` on `movies_tags` (`tag_id`); +CREATE INDEX `index_movies_tags_on_movie_id` on `movies_tags` (`movie_id`); diff --git a/pkg/sqlite/movies.go b/pkg/sqlite/movies.go index 6fc4ce5f09e..e5c08c31fbc 100644 --- a/pkg/sqlite/movies.go +++ b/pkg/sqlite/movies.go @@ -23,6 +23,8 @@ const ( movieFrontImageBlobColumn = "front_image_blob" movieBackImageBlobColumn = "back_image_blob" + moviesTagsTable = "movies_tags" + movieURLsTable = "movie_urls" movieURLColumn = "url" ) @@ -98,6 +100,7 @@ func (r *movieRowRecord) fromPartial(o models.MoviePartial) { type movieRepositoryType struct { repository scenes repository + tags joinRepository } var ( @@ -110,11 +113,21 @@ var ( tableName: moviesScenesTable, idColumn: movieIDColumn, }, + tags: joinRepository{ + repository: repository{ + tableName: moviesTagsTable, + idColumn: movieIDColumn, + }, + fkColumn: tagIDColumn, + foreignTable: tagTable, + orderBy: "tags.name ASC", + }, } ) type MovieStore struct { blobJoinQueryBuilder + tagRelationshipStore tableMgr *table } @@ -125,6 +138,11 @@ func NewMovieStore(blobStore *BlobStore) *MovieStore { blobStore: blobStore, joinTable: movieTable, }, + tagRelationshipStore: tagRelationshipStore{ + idRelationshipStore: idRelationshipStore{ + joinTable: moviesTagsTableMgr, + }, + }, tableMgr: movieTableMgr, } @@ -154,6 +172,10 @@ func (qb *MovieStore) Create(ctx context.Context, newObject *models.Movie) error } } + if err := qb.tagRelationshipStore.createRelationships(ctx, id, newObject.TagIDs); err != nil { + return err + } + updated, err := qb.find(ctx, id) if err != nil { return fmt.Errorf("finding after create: %w", err) @@ -185,6 +207,10 @@ func (qb *MovieStore) UpdatePartial(ctx context.Context, id int, partial models. } } + if err := qb.tagRelationshipStore.modifyRelationships(ctx, id, partial.TagIDs); err != nil { + return nil, err + } + return qb.find(ctx, id) } @@ -202,6 +228,10 @@ func (qb *MovieStore) Update(ctx context.Context, updatedObject *models.Movie) e } } + if err := qb.tagRelationshipStore.replaceRelationships(ctx, updatedObject.ID, updatedObject.TagIDs); err != nil { + return err + } + return nil } @@ -430,6 +460,7 @@ var movieSortOptions = sortOptions{ "random", "rating", "scenes_count", + "tag_count", "updated_at", } @@ -451,6 +482,8 @@ func (qb *MovieStore) getMovieSort(findFilter *models.FindFilterType) (string, e sortQuery := "" switch sort { + case "tag_count": + sortQuery += getCountSort(movieTable, moviesTagsTable, movieIDColumn, direction) case "scenes_count": // generic getSort won't work for this sortQuery += getCountSort(movieTable, moviesScenesTable, movieIDColumn, direction) default: diff --git a/pkg/sqlite/movies_filter.go b/pkg/sqlite/movies_filter.go index 8ef939592c7..0a2b3d67448 100644 --- a/pkg/sqlite/movies_filter.go +++ b/pkg/sqlite/movies_filter.go @@ -63,6 +63,8 @@ func (qb *movieFilterHandler) criterionHandler() criterionHandler { qb.urlsCriterionHandler(movieFilter.URL), studioCriterionHandler(movieTable, movieFilter.Studios), qb.performersCriterionHandler(movieFilter.Performers), + qb.tagsCriterionHandler(movieFilter.Tags), + qb.tagCountCriterionHandler(movieFilter.TagCount), &dateCriterionHandler{movieFilter.Date, "movies.date", nil}, ×tampCriterionHandler{movieFilter.CreatedAt, "movies.created_at", nil}, ×tampCriterionHandler{movieFilter.UpdatedAt, "movies.updated_at", nil}, @@ -162,3 +164,28 @@ func (qb *movieFilterHandler) performersCriterionHandler(performers *models.Mult } } } + +func (qb *movieFilterHandler) tagsCriterionHandler(tags *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { + h := joinedHierarchicalMultiCriterionHandlerBuilder{ + primaryTable: movieTable, + foreignTable: tagTable, + foreignFK: "tag_id", + + relationsTable: "tags_relations", + joinAs: "movie_tag", + joinTable: moviesTagsTable, + primaryFK: movieIDColumn, + } + + return h.handler(tags) +} + +func (qb *movieFilterHandler) tagCountCriterionHandler(count *models.IntCriterionInput) criterionHandlerFunc { + h := countCriterionHandlerBuilder{ + primaryTable: movieTable, + joinTable: moviesTagsTable, + primaryFK: movieIDColumn, + } + + return h.handler(count) +} diff --git a/pkg/sqlite/movies_test.go b/pkg/sqlite/movies_test.go index 9c4e0135fa1..3cfe05fe803 100644 --- a/pkg/sqlite/movies_test.go +++ b/pkg/sqlite/movies_test.go @@ -9,6 +9,7 @@ import ( "strconv" "strings" "testing" + "time" "github.com/stretchr/testify/assert" @@ -17,7 +18,12 @@ import ( func loadMovieRelationships(ctx context.Context, expected models.Movie, actual *models.Movie) error { if expected.URLs.Loaded() { - if err := actual.LoadURLs(ctx, db.Gallery); err != nil { + if err := actual.LoadURLs(ctx, db.Movie); err != nil { + return err + } + } + if expected.TagIDs.Loaded() { + if err := actual.LoadTagIDs(ctx, db.Movie); err != nil { return err } } @@ -25,6 +31,337 @@ func loadMovieRelationships(ctx context.Context, expected models.Movie, actual * return nil } +func Test_MovieStore_Create(t *testing.T) { + var ( + name = "name" + url = "url" + aliases = "alias1, alias2" + director = "director" + rating = 60 + duration = 34 + synopsis = "synopsis" + date, _ = models.ParseDate("2003-02-01") + createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) + updatedAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) + ) + + tests := []struct { + name string + newObject models.Movie + wantErr bool + }{ + { + "full", + models.Movie{ + Name: name, + Duration: &duration, + Date: &date, + Rating: &rating, + StudioID: &studioIDs[studioIdxWithMovie], + Director: director, + Synopsis: synopsis, + URLs: models.NewRelatedStrings([]string{url}), + TagIDs: models.NewRelatedIDs([]int{tagIDs[tagIdx1WithDupName], tagIDs[tagIdx1WithMovie]}), + Aliases: aliases, + CreatedAt: createdAt, + UpdatedAt: updatedAt, + }, + false, + }, + { + "invalid tag id", + models.Movie{ + Name: name, + TagIDs: models.NewRelatedIDs([]int{invalidID}), + }, + true, + }, + } + + qb := db.Movie + + for _, tt := range tests { + runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { + assert := assert.New(t) + + p := tt.newObject + if err := qb.Create(ctx, &p); (err != nil) != tt.wantErr { + t.Errorf("MovieStore.Create() error = %v, wantErr = %v", err, tt.wantErr) + } + + if tt.wantErr { + assert.Zero(p.ID) + return + } + + assert.NotZero(p.ID) + + copy := tt.newObject + copy.ID = p.ID + + // load relationships + if err := loadMovieRelationships(ctx, copy, &p); err != nil { + t.Errorf("loadMovieRelationships() error = %v", err) + return + } + + assert.Equal(copy, p) + + // ensure can find the movie + found, err := qb.Find(ctx, p.ID) + if err != nil { + t.Errorf("MovieStore.Find() error = %v", err) + } + + if !assert.NotNil(found) { + return + } + + // load relationships + if err := loadMovieRelationships(ctx, copy, found); err != nil { + t.Errorf("loadMovieRelationships() error = %v", err) + return + } + assert.Equal(copy, *found) + + return + }) + } +} + +func Test_movieQueryBuilder_Update(t *testing.T) { + var ( + name = "name" + url = "url" + aliases = "alias1, alias2" + director = "director" + rating = 60 + duration = 34 + synopsis = "synopsis" + date, _ = models.ParseDate("2003-02-01") + createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) + updatedAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) + ) + + tests := []struct { + name string + updatedObject *models.Movie + wantErr bool + }{ + { + "full", + &models.Movie{ + ID: movieIDs[movieIdxWithTag], + Name: name, + Duration: &duration, + Date: &date, + Rating: &rating, + StudioID: &studioIDs[studioIdxWithMovie], + Director: director, + Synopsis: synopsis, + URLs: models.NewRelatedStrings([]string{url}), + TagIDs: models.NewRelatedIDs([]int{tagIDs[tagIdx1WithDupName], tagIDs[tagIdx1WithMovie]}), + Aliases: aliases, + CreatedAt: createdAt, + UpdatedAt: updatedAt, + }, + false, + }, + { + "clear tag ids", + &models.Movie{ + ID: movieIDs[movieIdxWithTag], + Name: name, + TagIDs: models.NewRelatedIDs([]int{}), + }, + false, + }, + { + "invalid studio id", + &models.Movie{ + ID: movieIDs[movieIdxWithScene], + Name: name, + StudioID: &invalidID, + }, + true, + }, + { + "invalid tag id", + &models.Movie{ + ID: movieIDs[movieIdxWithScene], + Name: name, + TagIDs: models.NewRelatedIDs([]int{invalidID}), + }, + true, + }, + } + + qb := db.Movie + 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("movieQueryBuilder.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("movieQueryBuilder.Find() error = %v", err) + } + + // load relationships + if err := loadMovieRelationships(ctx, copy, s); err != nil { + t.Errorf("loadMovieRelationships() error = %v", err) + return + } + + assert.Equal(copy, *s) + }) + } +} + +func clearMoviePartial() models.MoviePartial { + // leave mandatory fields + return models.MoviePartial{ + Aliases: models.OptionalString{Set: true, Null: true}, + Synopsis: models.OptionalString{Set: true, Null: true}, + Director: models.OptionalString{Set: true, Null: true}, + Duration: models.OptionalInt{Set: true, Null: true}, + URLs: &models.UpdateStrings{Mode: models.RelationshipUpdateModeSet}, + Date: models.OptionalDate{Set: true, Null: true}, + Rating: models.OptionalInt{Set: true, Null: true}, + StudioID: models.OptionalInt{Set: true, Null: true}, + TagIDs: &models.UpdateIDs{Mode: models.RelationshipUpdateModeSet}, + } +} + +func Test_movieQueryBuilder_UpdatePartial(t *testing.T) { + var ( + name = "name" + url = "url" + aliases = "alias1, alias2" + director = "director" + rating = 60 + duration = 34 + synopsis = "synopsis" + date, _ = models.ParseDate("2003-02-01") + createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) + updatedAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) + ) + + tests := []struct { + name string + id int + partial models.MoviePartial + want models.Movie + wantErr bool + }{ + { + "full", + movieIDs[movieIdxWithScene], + models.MoviePartial{ + Name: models.NewOptionalString(name), + Director: models.NewOptionalString(director), + Synopsis: models.NewOptionalString(synopsis), + Aliases: models.NewOptionalString(aliases), + URLs: &models.UpdateStrings{ + Values: []string{url}, + Mode: models.RelationshipUpdateModeSet, + }, + Date: models.NewOptionalDate(date), + Duration: models.NewOptionalInt(duration), + Rating: models.NewOptionalInt(rating), + StudioID: models.NewOptionalInt(studioIDs[studioIdxWithMovie]), + CreatedAt: models.NewOptionalTime(createdAt), + UpdatedAt: models.NewOptionalTime(updatedAt), + TagIDs: &models.UpdateIDs{ + IDs: []int{tagIDs[tagIdx1WithMovie], tagIDs[tagIdx1WithDupName]}, + Mode: models.RelationshipUpdateModeSet, + }, + }, + models.Movie{ + ID: movieIDs[movieIdxWithScene], + Name: name, + Director: director, + Synopsis: synopsis, + Aliases: aliases, + URLs: models.NewRelatedStrings([]string{url}), + Date: &date, + Duration: &duration, + Rating: &rating, + StudioID: &studioIDs[studioIdxWithMovie], + CreatedAt: createdAt, + UpdatedAt: updatedAt, + TagIDs: models.NewRelatedIDs([]int{tagIDs[tagIdx1WithDupName], tagIDs[tagIdx1WithMovie]}), + }, + false, + }, + { + "clear all", + movieIDs[movieIdxWithScene], + clearMoviePartial(), + models.Movie{ + ID: movieIDs[movieIdxWithScene], + Name: movieNames[movieIdxWithScene], + TagIDs: models.NewRelatedIDs([]int{}), + }, + false, + }, + { + "invalid id", + invalidID, + models.MoviePartial{}, + models.Movie{}, + true, + }, + } + for _, tt := range tests { + qb := db.Movie + + 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("movieQueryBuilder.UpdatePartial() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if tt.wantErr { + return + } + + // load relationships + if err := loadMovieRelationships(ctx, tt.want, got); err != nil { + t.Errorf("loadMovieRelationships() error = %v", err) + return + } + + assert.Equal(tt.want, *got) + + s, err := qb.Find(ctx, tt.id) + if err != nil { + t.Errorf("movieQueryBuilder.Find() error = %v", err) + } + + // load relationships + if err := loadMovieRelationships(ctx, tt.want, s); err != nil { + t.Errorf("loadMovieRelationships() error = %v", err) + return + } + + assert.Equal(tt.want, *s) + }) + } +} + func TestMovieFindByName(t *testing.T) { withTxn(func(ctx context.Context) error { mqb := db.Movie @@ -280,12 +617,12 @@ func TestMovieQueryURLExcludes(t *testing.T) { Name: &nameCriterion, } - movies := queryMovie(ctx, t, mqb, &filter, nil) + movies := queryMovies(ctx, t, &filter, nil) assert.Len(t, movies, 0, "Expected no movies to be found") // query for movies that exclude the URL "ccc" urlCriterion.Value = "ccc" - movies = queryMovie(ctx, t, mqb, &filter, nil) + movies = queryMovies(ctx, t, &filter, nil) if assert.Len(t, movies, 1, "Expected one movie to be found") { assert.Equal(t, movie.Name, movies[0].Name) @@ -300,7 +637,7 @@ func verifyMovieQuery(t *testing.T, filter models.MovieFilterType, verifyFn func t.Helper() sqb := db.Movie - movies := queryMovie(ctx, t, sqb, &filter, nil) + movies := queryMovies(ctx, t, &filter, nil) for _, movie := range movies { if err := movie.LoadURLs(ctx, sqb); err != nil { @@ -319,7 +656,8 @@ func verifyMovieQuery(t *testing.T, filter models.MovieFilterType, verifyFn func }) } -func queryMovie(ctx context.Context, t *testing.T, sqb models.MovieReader, movieFilter *models.MovieFilterType, findFilter *models.FindFilterType) []*models.Movie { +func queryMovies(ctx context.Context, t *testing.T, movieFilter *models.MovieFilterType, findFilter *models.FindFilterType) []*models.Movie { + sqb := db.Movie movies, _, err := sqb.Query(ctx, movieFilter, findFilter) if err != nil { t.Errorf("Error querying movie: %s", err.Error()) @@ -328,6 +666,102 @@ func queryMovie(ctx context.Context, t *testing.T, sqb models.MovieReader, movie return movies } +func TestMovieQueryTags(t *testing.T) { + withTxn(func(ctx context.Context) error { + tagCriterion := models.HierarchicalMultiCriterionInput{ + Value: []string{ + strconv.Itoa(tagIDs[tagIdxWithMovie]), + strconv.Itoa(tagIDs[tagIdx1WithMovie]), + }, + Modifier: models.CriterionModifierIncludes, + } + + movieFilter := models.MovieFilterType{ + Tags: &tagCriterion, + } + + // ensure ids are correct + movies := queryMovies(ctx, t, &movieFilter, nil) + assert.Len(t, movies, 3) + for _, movie := range movies { + assert.True(t, movie.ID == movieIDs[movieIdxWithTag] || movie.ID == movieIDs[movieIdxWithTwoTags] || movie.ID == movieIDs[movieIdxWithThreeTags]) + } + + tagCriterion = models.HierarchicalMultiCriterionInput{ + Value: []string{ + strconv.Itoa(tagIDs[tagIdx1WithMovie]), + strconv.Itoa(tagIDs[tagIdx2WithMovie]), + }, + Modifier: models.CriterionModifierIncludesAll, + } + + movies = queryMovies(ctx, t, &movieFilter, nil) + + if assert.Len(t, movies, 2) { + assert.Equal(t, sceneIDs[movieIdxWithTwoTags], movies[0].ID) + assert.Equal(t, sceneIDs[movieIdxWithThreeTags], movies[1].ID) + } + + tagCriterion = models.HierarchicalMultiCriterionInput{ + Value: []string{ + strconv.Itoa(tagIDs[tagIdx1WithMovie]), + }, + Modifier: models.CriterionModifierExcludes, + } + + q := getSceneStringValue(movieIdxWithTwoTags, titleField) + findFilter := models.FindFilterType{ + Q: &q, + } + + movies = queryMovies(ctx, t, &movieFilter, &findFilter) + assert.Len(t, movies, 0) + + return nil + }) +} + +func TestMovieQueryTagCount(t *testing.T) { + const tagCount = 1 + tagCountCriterion := models.IntCriterionInput{ + Value: tagCount, + Modifier: models.CriterionModifierEquals, + } + + verifyMoviesTagCount(t, tagCountCriterion) + + tagCountCriterion.Modifier = models.CriterionModifierNotEquals + verifyMoviesTagCount(t, tagCountCriterion) + + tagCountCriterion.Modifier = models.CriterionModifierGreaterThan + verifyMoviesTagCount(t, tagCountCriterion) + + tagCountCriterion.Modifier = models.CriterionModifierLessThan + verifyMoviesTagCount(t, tagCountCriterion) +} + +func verifyMoviesTagCount(t *testing.T, tagCountCriterion models.IntCriterionInput) { + withTxn(func(ctx context.Context) error { + sqb := db.Movie + movieFilter := models.MovieFilterType{ + TagCount: &tagCountCriterion, + } + + movies := queryMovies(ctx, t, &movieFilter, nil) + assert.Greater(t, len(movies), 0) + + for _, movie := range movies { + ids, err := sqb.GetTagIDs(ctx, movie.ID) + if err != nil { + return err + } + verifyInt(t, len(ids), tagCountCriterion) + } + + return nil + }) +} + func TestMovieQuerySorting(t *testing.T) { sort := "scenes_count" direction := models.SortDirectionEnumDesc @@ -337,8 +771,7 @@ func TestMovieQuerySorting(t *testing.T) { } withTxn(func(ctx context.Context) error { - sqb := db.Movie - movies := queryMovie(ctx, t, sqb, nil, &findFilter) + movies := queryMovies(ctx, t, nil, &findFilter) // scenes should be in same order as indexes firstMovie := movies[0] @@ -348,7 +781,7 @@ func TestMovieQuerySorting(t *testing.T) { // sort in descending order direction = models.SortDirectionEnumAsc - movies = queryMovie(ctx, t, sqb, nil, &findFilter) + movies = queryMovies(ctx, t, nil, &findFilter) lastMovie := movies[len(movies)-1] assert.Equal(t, movieIDs[movieIdxWithScene], lastMovie.ID) diff --git a/pkg/sqlite/relationships.go b/pkg/sqlite/relationships.go new file mode 100644 index 00000000000..32c8fda649c --- /dev/null +++ b/pkg/sqlite/relationships.go @@ -0,0 +1,41 @@ +package sqlite + +import ( + "context" + + "github.com/stashapp/stash/pkg/models" +) + +type idRelationshipStore struct { + joinTable *joinTable +} + +func (s *idRelationshipStore) createRelationships(ctx context.Context, id int, fkIDs models.RelatedIDs) error { + if fkIDs.Loaded() { + if err := s.joinTable.insertJoins(ctx, id, fkIDs.List()); err != nil { + return err + } + } + + return nil +} + +func (s *idRelationshipStore) modifyRelationships(ctx context.Context, id int, fkIDs *models.UpdateIDs) error { + if fkIDs != nil { + if err := s.joinTable.modifyJoins(ctx, id, fkIDs.IDs, fkIDs.Mode); err != nil { + return err + } + } + + return nil +} + +func (s *idRelationshipStore) replaceRelationships(ctx context.Context, id int, fkIDs models.RelatedIDs) error { + if fkIDs.Loaded() { + if err := s.joinTable.replaceJoins(ctx, id, fkIDs.List()); err != nil { + return err + } + } + + return nil +} diff --git a/pkg/sqlite/setup_test.go b/pkg/sqlite/setup_test.go index 1ccab4574f7..736eae6a68b 100644 --- a/pkg/sqlite/setup_test.go +++ b/pkg/sqlite/setup_test.go @@ -150,9 +150,12 @@ const ( const ( movieIdxWithScene = iota movieIdxWithStudio + movieIdxWithTag + movieIdxWithTwoTags + movieIdxWithThreeTags // movies with dup names start from the end - // create 10 more basic movies (can remove this if we add more indexes) - movieIdxWithDupName = movieIdxWithStudio + 10 + // create 7 more basic movies (can remove this if we add more indexes) + movieIdxWithDupName = movieIdxWithStudio + 7 moviesNameCase = movieIdxWithDupName moviesNameNoCase = 1 @@ -214,6 +217,10 @@ const ( tagIdxWithParentAndChild tagIdxWithGrandParent tagIdx2WithMarkers + tagIdxWithMovie + tagIdx1WithMovie + tagIdx2WithMovie + tagIdx3WithMovie // new indexes above // tags with dup names start from the end tagIdx1WithDupName @@ -487,6 +494,12 @@ var ( movieStudioLinks = [][2]int{ {movieIdxWithStudio, studioIdxWithMovie}, } + + movieTags = linkMap{ + movieIdxWithTag: {tagIdxWithMovie}, + movieIdxWithTwoTags: {tagIdx1WithMovie, tagIdx2WithMovie}, + movieIdxWithThreeTags: {tagIdx1WithMovie, tagIdx2WithMovie, tagIdx3WithMovie}, + } ) var ( @@ -622,14 +635,14 @@ func populateDB() error { // TODO - link folders to zip files - if err := createMovies(ctx, db.Movie, moviesNameCase, moviesNameNoCase); err != nil { - return fmt.Errorf("error creating movies: %s", err.Error()) - } - if err := createTags(ctx, db.Tag, tagsNameCase, tagsNameNoCase); err != nil { return fmt.Errorf("error creating tags: %s", err.Error()) } + if err := createMovies(ctx, db.Movie, moviesNameCase, moviesNameNoCase); err != nil { + return fmt.Errorf("error creating movies: %s", err.Error()) + } + if err := createPerformers(ctx, performersNameCase, performersNameNoCase); err != nil { return fmt.Errorf("error creating performers: %s", err.Error()) } @@ -1321,6 +1334,8 @@ func createMovies(ctx context.Context, mqb models.MovieReaderWriter, n int, o in index := i name := namePlain + tids := indexesToIDs(tagIDs, movieTags[i]) + if i >= n { // i=n movies get dup names if case is not checked index = n + o - (i + 1) // for the name to be the same the number (index) must be the same also @@ -1333,6 +1348,7 @@ func createMovies(ctx context.Context, mqb models.MovieReaderWriter, n int, o in URLs: models.NewRelatedStrings([]string{ getMovieEmptyString(i, urlField), }), + TagIDs: models.NewRelatedIDs(tids), } err := mqb.Create(ctx, &movie) diff --git a/pkg/sqlite/table.go b/pkg/sqlite/table.go index 2aa5b77b6c3..6b6ed9417af 100644 --- a/pkg/sqlite/table.go +++ b/pkg/sqlite/table.go @@ -155,6 +155,10 @@ func (t *table) join(j joiner, as string, parentIDCol string) { type joinTable struct { table fkColumn exp.IdentifierExpression + + // required for ordering + foreignTable *table + orderBy exp.OrderedExpression } func (t *joinTable) invert() *joinTable { @@ -170,6 +174,13 @@ func (t *joinTable) invert() *joinTable { func (t *joinTable) get(ctx context.Context, id int) ([]int, error) { q := dialect.Select(t.fkColumn).From(t.table.table).Where(t.idColumn.Eq(id)) + if t.orderBy != nil { + if t.foreignTable != nil { + q = q.InnerJoin(t.foreignTable.table, goqu.On(t.foreignTable.idColumn.Eq(t.fkColumn))) + } + q = q.Order(t.orderBy) + } + const single = false var ret []int if err := queryFunc(ctx, q, single, func(rows *sqlx.Rows) error { diff --git a/pkg/sqlite/tables.go b/pkg/sqlite/tables.go index 701c503305d..d4425cfe3e9 100644 --- a/pkg/sqlite/tables.go +++ b/pkg/sqlite/tables.go @@ -36,6 +36,7 @@ var ( studiosStashIDsJoinTable = goqu.T("studio_stash_ids") moviesURLsJoinTable = goqu.T(movieURLsTable) + moviesTagsJoinTable = goqu.T(moviesTagsTable) tagsAliasesJoinTable = goqu.T(tagAliasesTable) tagRelationsJoinTable = goqu.T(tagRelationsTable) @@ -330,6 +331,16 @@ var ( }, valueColumn: moviesURLsJoinTable.Col(movieURLColumn), } + + moviesTagsTableMgr = &joinTable{ + table: table{ + table: moviesTagsJoinTable, + idColumn: moviesTagsJoinTable.Col(movieIDColumn), + }, + fkColumn: moviesTagsJoinTable.Col(tagIDColumn), + foreignTable: tagTableMgr, + orderBy: tagTableMgr.table.Col("name").Asc(), + } ) var ( diff --git a/pkg/sqlite/tag.go b/pkg/sqlite/tag.go index 127ad3310e1..a4bf3793aa1 100644 --- a/pkg/sqlite/tag.go +++ b/pkg/sqlite/tag.go @@ -424,6 +424,18 @@ func (qb *TagStore) FindByGalleryID(ctx context.Context, galleryID int) ([]*mode return qb.queryTags(ctx, query, args) } +func (qb *TagStore) FindByMovieID(ctx context.Context, movieID int) ([]*models.Tag, error) { + query := ` + SELECT tags.* FROM tags + LEFT JOIN movies_tags as movies_join on movies_join.tag_id = tags.id + WHERE movies_join.movie_id = ? + GROUP BY tags.id + ` + query += qb.getDefaultTagSort() + args := []interface{}{movieID} + return qb.queryTags(ctx, query, args) +} + func (qb *TagStore) FindBySceneMarkerID(ctx context.Context, sceneMarkerID int) ([]*models.Tag, error) { query := ` SELECT tags.* FROM tags @@ -615,6 +627,7 @@ var tagSortOptions = sortOptions{ "galleries_count", "id", "images_count", + "movies_count", "name", "performers_count", "random", @@ -655,6 +668,8 @@ func (qb *TagStore) getTagSort(query *queryBuilder, findFilter *models.FindFilte sortQuery += getCountSort(tagTable, galleriesTagsTable, tagIDColumn, direction) case "performers_count": sortQuery += getCountSort(tagTable, performersTagsTable, tagIDColumn, direction) + case "movies_count": + sortQuery += getCountSort(tagTable, moviesTagsTable, tagIDColumn, direction) default: sortQuery += getSort(sort, direction, "tags") } @@ -888,3 +903,17 @@ SELECT t.*, c.path FROM tags t INNER JOIN children c ON t.id = c.child_id return qb.queryTagPaths(ctx, query, args) } + +type tagRelationshipStore struct { + idRelationshipStore +} + +func (s *tagRelationshipStore) CountByTagID(ctx context.Context, tagID int) (int, error) { + joinTable := s.joinTable.table.table + q := dialect.Select(goqu.COUNT("*")).From(joinTable).Where(joinTable.Col(tagIDColumn).Eq(tagID)) + return count(ctx, q) +} + +func (s *tagRelationshipStore) GetTagIDs(ctx context.Context, id int) ([]int, error) { + return s.joinTable.get(ctx, id) +} diff --git a/pkg/sqlite/tag_filter.go b/pkg/sqlite/tag_filter.go index 55321dbbabf..776a49fc4f3 100644 --- a/pkg/sqlite/tag_filter.go +++ b/pkg/sqlite/tag_filter.go @@ -66,6 +66,7 @@ func (qb *tagFilterHandler) criterionHandler() criterionHandler { qb.imageCountCriterionHandler(tagFilter.ImageCount), qb.galleryCountCriterionHandler(tagFilter.GalleryCount), qb.performerCountCriterionHandler(tagFilter.PerformerCount), + qb.movieCountCriterionHandler(tagFilter.MovieCount), qb.markerCountCriterionHandler(tagFilter.MarkerCount), qb.parentsCriterionHandler(tagFilter.Parents), qb.childrenCriterionHandler(tagFilter.Children), @@ -174,6 +175,17 @@ func (qb *tagFilterHandler) performerCountCriterionHandler(performerCount *model } } +func (qb *tagFilterHandler) movieCountCriterionHandler(movieCount *models.IntCriterionInput) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if movieCount != nil { + f.addLeftJoin("movies_tags", "", "movies_tags.tag_id = tags.id") + clause, args := getIntCriterionWhereClause("count(distinct movies_tags.movie_id)", *movieCount) + + f.addHaving(clause, args...) + } + } +} + func (qb *tagFilterHandler) markerCountCriterionHandler(markerCount *models.IntCriterionInput) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if markerCount != nil { diff --git a/pkg/sqlite/tag_test.go b/pkg/sqlite/tag_test.go index a44232720b7..d71316413e4 100644 --- a/pkg/sqlite/tag_test.go +++ b/pkg/sqlite/tag_test.go @@ -42,6 +42,33 @@ func TestMarkerFindBySceneMarkerID(t *testing.T) { }) } +func TestTagFindByMovieID(t *testing.T) { + withTxn(func(ctx context.Context) error { + tqb := db.Tag + + movieID := movieIDs[movieIdxWithTag] + + tags, err := tqb.FindByMovieID(ctx, movieID) + + if err != nil { + t.Errorf("Error finding tags: %s", err.Error()) + } + + assert.Len(t, tags, 1) + assert.Equal(t, tagIDs[tagIdxWithMovie], tags[0].ID) + + tags, err = tqb.FindByMovieID(ctx, 0) + + if err != nil { + t.Errorf("Error finding tags: %s", err.Error()) + } + + assert.Len(t, tags, 0) + + return nil + }) +} + func TestTagFindByName(t *testing.T) { withTxn(func(ctx context.Context) error { tqb := db.Tag @@ -203,6 +230,10 @@ func TestTagQuerySort(t *testing.T) { tags = queryTags(ctx, t, sqb, nil, findFilter) assert.Equal(tagIDs[tagIdx2WithPerformer], tags[0].ID) + sortBy = "movies_count" + tags = queryTags(ctx, t, sqb, nil, findFilter) + assert.Equal(tagIDs[tagIdx1WithMovie], tags[0].ID) + return nil }) } diff --git a/ui/v2.5/graphql/data/movie.graphql b/ui/v2.5/graphql/data/movie.graphql index a0ed1f67f32..b94450f281e 100644 --- a/ui/v2.5/graphql/data/movie.graphql +++ b/ui/v2.5/graphql/data/movie.graphql @@ -11,6 +11,10 @@ fragment MovieData on Movie { ...SlimStudioData } + tags { + ...SlimTagData + } + synopsis urls front_image_path diff --git a/ui/v2.5/graphql/data/scrapers.graphql b/ui/v2.5/graphql/data/scrapers.graphql index a59d74b096e..087ba2efbc0 100644 --- a/ui/v2.5/graphql/data/scrapers.graphql +++ b/ui/v2.5/graphql/data/scrapers.graphql @@ -98,6 +98,9 @@ fragment ScrapedMovieData on ScrapedMovie { studio { ...ScrapedMovieStudioData } + tags { + ...ScrapedSceneTagData + } } fragment ScrapedSceneMovieData on ScrapedMovie { @@ -116,6 +119,9 @@ fragment ScrapedSceneMovieData on ScrapedMovie { studio { ...ScrapedMovieStudioData } + tags { + ...ScrapedSceneTagData + } } fragment ScrapedSceneStudioData on ScrapedStudio { diff --git a/ui/v2.5/graphql/data/tag.graphql b/ui/v2.5/graphql/data/tag.graphql index b71f487abaa..d473bf8c6d4 100644 --- a/ui/v2.5/graphql/data/tag.graphql +++ b/ui/v2.5/graphql/data/tag.graphql @@ -16,6 +16,8 @@ fragment TagData on Tag { gallery_count_all: gallery_count(depth: -1) performer_count performer_count_all: performer_count(depth: -1) + movie_count + movie_count_all: movie_count(depth: -1) parents { ...SlimTagData diff --git a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryEditPanel.tsx b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryEditPanel.tsx index 4c12b0232d9..9f018e0d140 100644 --- a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryEditPanel.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryEditPanel.tsx @@ -36,9 +36,9 @@ import { yupUniqueStringList, } from "src/utils/yup"; import { formikUtils } from "src/utils/form"; -import { Tag, TagSelect } from "src/components/Tags/TagSelect"; import { Studio, StudioSelect } from "src/components/Studios/StudioSelect"; import { Scene, SceneSelect } from "src/components/Scenes/SceneSelect"; +import { useTagsEdit } from "src/hooks/tagsEdit"; interface IProps { gallery: Partial; @@ -58,7 +58,6 @@ export const GalleryEditPanel: React.FC = ({ const [scenes, setScenes] = useState([]); const [performers, setPerformers] = useState([]); - const [tags, setTags] = useState([]); const [studio, setStudio] = useState(null); const isNew = gallery.id === undefined; @@ -110,6 +109,11 @@ export const GalleryEditPanel: React.FC = ({ onSubmit: (values) => onSave(schema.cast(values)), }); + const { tags, updateTagsStateFromScraper, tagsControl } = useTagsEdit( + gallery.tags, + (ids) => formik.setFieldValue("tag_ids", ids) + ); + function onSetScenes(items: Scene[]) { setScenes(items); formik.setFieldValue( @@ -126,14 +130,6 @@ export const GalleryEditPanel: React.FC = ({ ); } - function onSetTags(items: Tag[]) { - setTags(items); - formik.setFieldValue( - "tag_ids", - items.map((item) => item.id) - ); - } - function onSetStudio(item: Studio | null) { setStudio(item); formik.setFieldValue("studio_id", item ? item.id : null); @@ -143,10 +139,6 @@ export const GalleryEditPanel: React.FC = ({ setPerformers(gallery.performers ?? []); }, [gallery.performers]); - useEffect(() => { - setTags(gallery.tags ?? []); - }, [gallery.tags]); - useEffect(() => { setStudio(gallery.studio ?? null); }, [gallery.studio]); @@ -339,23 +331,7 @@ export const GalleryEditPanel: React.FC = ({ } } - if (galleryData?.tags?.length) { - const idTags = galleryData.tags.filter((t) => { - return t.stored_id !== undefined && t.stored_id !== null; - }); - - if (idTags.length > 0) { - onSetTags( - idTags.map((p) => { - return { - id: p.stored_id!, - name: p.name ?? "", - aliases: [], - }; - }) - ); - } - } + updateTagsStateFromScraper(galleryData.tags ?? undefined); } async function onScrapeGalleryURL(url: string) { @@ -437,16 +413,7 @@ export const GalleryEditPanel: React.FC = ({ function renderTagsField() { const title = intl.formatMessage({ id: "tags" }); - const control = ( - - ); - - return renderField("tag_ids", title, control, fullWidthProps); + return renderField("tag_ids", title, tagsControl(), fullWidthProps); } function renderDetailsField() { diff --git a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryScrapeDialog.tsx b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryScrapeDialog.tsx index 1daa2f5e756..dd3357fec83 100644 --- a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryScrapeDialog.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryScrapeDialog.tsx @@ -15,18 +15,17 @@ import { import { ScrapedPerformersRow, ScrapedStudioRow, - ScrapedTagsRow, } from "src/components/Shared/ScrapeDialog/ScrapedObjectsRow"; import { sortStoredIdObjects } from "src/utils/data"; import { Performer } from "src/components/Performers/PerformerSelect"; import { useCreateScrapedPerformer, useCreateScrapedStudio, - useCreateScrapedTag, } from "src/components/Shared/ScrapeDialog/createObjects"; import { uniq } from "lodash-es"; import { Tag } from "src/components/Tags/TagSelect"; import { Studio } from "src/components/Studios/StudioSelect"; +import { useScrapedTags } from "src/components/Shared/ScrapeDialog/scrapedTags"; interface IGalleryScrapeDialogProps { gallery: Partial; @@ -99,19 +98,9 @@ export const GalleryScrapeDialog: React.FC = ({ scraped.performers?.filter((t) => !t.stored_id) ?? [] ); - const [tags, setTags] = useState>( - new ObjectListScrapeResult( - sortStoredIdObjects( - galleryTags.map((t) => ({ - stored_id: t.id, - name: t.name, - })) - ), - sortStoredIdObjects(scraped.tags ?? undefined) - ) - ); - const [newTags, setNewTags] = useState( - scraped.tags?.filter((t) => !t.stored_id) ?? [] + const { tags, newTags, scrapedTagsRow } = useScrapedTags( + galleryTags, + scraped.tags ); const [details, setDetails] = useState>( @@ -131,13 +120,6 @@ export const GalleryScrapeDialog: React.FC = ({ setNewObjects: setNewPerformers, }); - const createNewTag = useCreateScrapedTag({ - scrapeResult: tags, - setScrapeResult: setTags, - newObjects: newTags, - setNewObjects: setNewTags, - }); - // don't show the dialog if nothing was scraped if ( [ @@ -218,13 +200,7 @@ export const GalleryScrapeDialog: React.FC = ({ newObjects={newPerformers} onCreateNew={createNewPerformer} /> - setTags(value)} - newObjects={newTags} - onCreateNew={createNewTag} - /> + {scrapedTagsRow} = ({ const [galleries, setGalleries] = useState([]); const [performers, setPerformers] = useState([]); - const [tags, setTags] = useState([]); const [studio, setStudio] = useState(null); useEffect(() => { @@ -98,6 +97,10 @@ export const ImageEditPanel: React.FC = ({ onSubmit: (values) => onSave(schema.cast(values)), }); + const { tagsControl } = useTagsEdit(image.tags, (ids) => + formik.setFieldValue("tag_ids", ids) + ); + function onSetGalleries(items: Gallery[]) { setGalleries(items); formik.setFieldValue( @@ -114,14 +117,6 @@ export const ImageEditPanel: React.FC = ({ ); } - function onSetTags(items: Tag[]) { - setTags(items); - formik.setFieldValue( - "tag_ids", - items.map((item) => item.id) - ); - } - function onSetStudio(item: Studio | null) { setStudio(item); formik.setFieldValue("studio_id", item ? item.id : null); @@ -131,10 +126,6 @@ export const ImageEditPanel: React.FC = ({ setPerformers(image.performers ?? []); }, [image.performers]); - useEffect(() => { - setTags(image.tags ?? []); - }, [image.tags]); - useEffect(() => { setStudio(image.studio ?? null); }, [image.studio]); @@ -233,16 +224,7 @@ export const ImageEditPanel: React.FC = ({ function renderTagsField() { const title = intl.formatMessage({ id: "tags" }); - const control = ( - - ); - - return renderField("tag_ids", title, control, fullWidthProps); + return renderField("tag_ids", title, tagsControl(), fullWidthProps); } function renderDetailsField() { diff --git a/ui/v2.5/src/components/Movies/EditMoviesDialog.tsx b/ui/v2.5/src/components/Movies/EditMoviesDialog.tsx index ba46166c8c2..af48cbeaf35 100644 --- a/ui/v2.5/src/components/Movies/EditMoviesDialog.tsx +++ b/ui/v2.5/src/components/Movies/EditMoviesDialog.tsx @@ -9,11 +9,15 @@ import { useToast } from "src/hooks/Toast"; import * as FormUtils from "src/utils/form"; import { RatingSystem } from "../Shared/Rating/RatingSystem"; import { + getAggregateInputIDs, getAggregateInputValue, getAggregateRating, getAggregateStudioId, + getAggregateTagIds, } from "src/utils/bulkUpdate"; import { faPencilAlt } from "@fortawesome/free-solid-svg-icons"; +import { isEqual } from "lodash-es"; +import { MultiSet } from "../Shared/MultiSet"; interface IListOperationProps { selected: GQL.MovieDataFragment[]; @@ -29,6 +33,12 @@ export const EditMoviesDialog: React.FC = ( const [studioId, setStudioId] = useState(); const [director, setDirector] = useState(); + const [tagMode, setTagMode] = React.useState( + GQL.BulkUpdateIdMode.Add + ); + const [tagIds, setTagIds] = useState(); + const [existingTagIds, setExistingTagIds] = useState(); + const [updateMovies] = useBulkMovieUpdate(getMovieInput()); const [isUpdating, setIsUpdating] = useState(false); @@ -36,6 +46,7 @@ export const EditMoviesDialog: React.FC = ( function getMovieInput(): GQL.BulkMovieUpdateInput { const aggregateRating = getAggregateRating(props.selected); const aggregateStudioId = getAggregateStudioId(props.selected); + const aggregateTagIds = getAggregateTagIds(props.selected); const movieInput: GQL.BulkMovieUpdateInput = { ids: props.selected.map((movie) => movie.id), @@ -45,6 +56,7 @@ export const EditMoviesDialog: React.FC = ( // if rating is undefined movieInput.rating100 = getAggregateInputValue(rating100, aggregateRating); movieInput.studio_id = getAggregateInputValue(studioId, aggregateStudioId); + movieInput.tag_ids = getAggregateInputIDs(tagMode, tagIds, aggregateTagIds); return movieInput; } @@ -72,14 +84,18 @@ export const EditMoviesDialog: React.FC = ( const state = props.selected; let updateRating: number | undefined; let updateStudioId: string | undefined; + let updateTagIds: string[] = []; let updateDirector: string | undefined; let first = true; state.forEach((movie: GQL.MovieDataFragment) => { + const movieTagIDs = (movie.tags ?? []).map((p) => p.id).sort(); + if (first) { first = false; updateRating = movie.rating100 ?? undefined; updateStudioId = movie.studio?.id ?? undefined; + updateTagIds = movieTagIDs; updateDirector = movie.director ?? undefined; } else { if (movie.rating100 !== updateRating) { @@ -91,11 +107,15 @@ export const EditMoviesDialog: React.FC = ( if (movie.director !== updateDirector) { updateDirector = undefined; } + if (!isEqual(movieTagIDs, updateTagIds)) { + updateTagIds = []; + } } }); setRating(updateRating); setStudioId(updateStudioId); + setExistingTagIds(updateTagIds); setDirector(updateDirector); }, [props.selected]); @@ -158,6 +178,20 @@ export const EditMoviesDialog: React.FC = ( placeholder={intl.formatMessage({ id: "director" })} /> + + + + + setTagIds(itemIDs)} + onSetMode={(newMode) => setTagMode(newMode)} + existingIds={existingTagIds ?? []} + ids={tagIds ?? []} + mode={tagMode} + /> + ); diff --git a/ui/v2.5/src/components/Movies/MovieCard.tsx b/ui/v2.5/src/components/Movies/MovieCard.tsx index d517359265e..1f763649e2d 100644 --- a/ui/v2.5/src/components/Movies/MovieCard.tsx +++ b/ui/v2.5/src/components/Movies/MovieCard.tsx @@ -4,11 +4,11 @@ import * as GQL from "src/core/generated-graphql"; import { GridCard, calculateCardWidth } from "../Shared/GridCard/GridCard"; import { HoverPopover } from "../Shared/HoverPopover"; import { Icon } from "../Shared/Icon"; -import { SceneLink } from "../Shared/TagLink"; +import { SceneLink, TagLink } from "../Shared/TagLink"; import { TruncatedText } from "../Shared/TruncatedText"; import { FormattedMessage } from "react-intl"; import { RatingBanner } from "../Shared/RatingBanner"; -import { faPlayCircle } from "@fortawesome/free-solid-svg-icons"; +import { faPlayCircle, faTag } from "@fortawesome/free-solid-svg-icons"; import ScreenUtils from "src/utils/screen"; interface IProps { @@ -20,37 +20,44 @@ interface IProps { onSelectedChanged?: (selected: boolean, shiftKey: boolean) => void; } -export const MovieCard: React.FC = (props: IProps) => { +export const MovieCard: React.FC = ({ + movie, + sceneIndex, + containerWidth, + selecting, + selected, + onSelectedChanged, +}) => { const [cardWidth, setCardWidth] = useState(); useEffect(() => { - if (!props.containerWidth || ScreenUtils.isMobile()) return; + if (!containerWidth || ScreenUtils.isMobile()) return; let preferredCardWidth = 250; let fittedCardWidth = calculateCardWidth( - props.containerWidth, + containerWidth, preferredCardWidth! ); setCardWidth(fittedCardWidth); - }, [props, props.containerWidth]); + }, [containerWidth]); function maybeRenderSceneNumber() { - if (!props.sceneIndex) return; + if (!sceneIndex) return; return ( <>
- #{props.sceneIndex} + #{sceneIndex} ); } function maybeRenderScenesPopoverButton() { - if (props.movie.scenes.length === 0) return; + if (movie.scenes.length === 0) return; - const popoverContent = props.movie.scenes.map((scene) => ( + const popoverContent = movie.scenes.map((scene) => ( )); @@ -62,20 +69,38 @@ export const MovieCard: React.FC = (props: IProps) => { > + + ); + } + + function maybeRenderTagPopoverButton() { + if (movie.tags.length <= 0) return; + + const popoverContent = movie.tags.map((tag) => ( + + )); + + return ( + + ); } function maybeRenderPopoverButtonGroup() { - if (props.sceneIndex || props.movie.scenes.length > 0) { + if (sceneIndex || movie.scenes.length > 0 || movie.tags.length > 0) { return ( <> {maybeRenderSceneNumber()}
{maybeRenderScenesPopoverButton()} + {maybeRenderTagPopoverButton()} ); @@ -85,34 +110,34 @@ export const MovieCard: React.FC = (props: IProps) => { return ( {props.movie.name - + } details={
- {props.movie.date} + {movie.date}
} - selected={props.selected} - selecting={props.selecting} - onSelectedChanged={props.onSelectedChanged} + selected={selected} + selecting={selecting} + onSelectedChanged={onSelectedChanged} popovers={maybeRenderPopoverButtonGroup()} /> ); diff --git a/ui/v2.5/src/components/Movies/MovieDetails/Movie.tsx b/ui/v2.5/src/components/Movies/MovieDetails/Movie.tsx index 723b1a7ac58..69aecd20df3 100644 --- a/ui/v2.5/src/components/Movies/MovieDetails/Movie.tsx +++ b/ui/v2.5/src/components/Movies/MovieDetails/Movie.tsx @@ -305,6 +305,7 @@ const MoviePage: React.FC = ({ movie }) => { return ( ); diff --git a/ui/v2.5/src/components/Movies/MovieDetails/MovieDetailsPanel.tsx b/ui/v2.5/src/components/Movies/MovieDetails/MovieDetailsPanel.tsx index 97957f7f85c..7c5a9cf3a72 100644 --- a/ui/v2.5/src/components/Movies/MovieDetails/MovieDetailsPanel.tsx +++ b/ui/v2.5/src/components/Movies/MovieDetails/MovieDetailsPanel.tsx @@ -5,19 +5,54 @@ import TextUtils from "src/utils/text"; import { DetailItem } from "src/components/Shared/DetailItem"; import { Link } from "react-router-dom"; import { DirectorLink } from "src/components/Shared/Link"; +import { TagLink } from "src/components/Shared/TagLink"; interface IMovieDetailsPanel { movie: GQL.MovieDataFragment; + collapsed?: boolean; fullWidth?: boolean; } export const MovieDetailsPanel: React.FC = ({ movie, + collapsed, fullWidth, }) => { // Network state const intl = useIntl(); + function renderTagsField() { + if (!movie.tags.length) { + return; + } + return ( +
    + {(movie.tags ?? []).map((tag) => ( + + ))} +
+ ); + } + + function maybeRenderExtraDetails() { + if (!collapsed) { + return ( + <> + + + + ); + } + } + return (
= ({ } fullWidth={fullWidth} /> - + {maybeRenderExtraDetails()}
); }; diff --git a/ui/v2.5/src/components/Movies/MovieDetails/MovieEditPanel.tsx b/ui/v2.5/src/components/Movies/MovieDetails/MovieEditPanel.tsx index 5b9bac5f8d9..5cd4cda7908 100644 --- a/ui/v2.5/src/components/Movies/MovieDetails/MovieEditPanel.tsx +++ b/ui/v2.5/src/components/Movies/MovieDetails/MovieEditPanel.tsx @@ -25,6 +25,7 @@ import { yupUniqueStringList, } from "src/utils/yup"; import { Studio, StudioSelect } from "src/components/Studios/StudioSelect"; +import { useTagsEdit } from "src/hooks/tagsEdit"; interface IMovieEditPanel { movie: Partial; @@ -66,6 +67,7 @@ export const MovieEditPanel: React.FC = ({ duration: yup.number().integer().min(0).nullable().defined(), date: yupDateString(intl), studio_id: yup.string().required().nullable(), + tag_ids: yup.array(yup.string().required()).defined(), director: yup.string().ensure(), urls: yupUniqueStringList(intl), synopsis: yup.string().ensure(), @@ -79,6 +81,7 @@ export const MovieEditPanel: React.FC = ({ duration: movie?.duration ?? null, date: movie?.date ?? "", studio_id: movie?.studio?.id ?? null, + tag_ids: (movie?.tags ?? []).map((t) => t.id), director: movie?.director ?? "", urls: movie?.urls ?? [], synopsis: movie?.synopsis ?? "", @@ -93,6 +96,11 @@ export const MovieEditPanel: React.FC = ({ onSubmit: (values) => onSave(schema.cast(values)), }); + const { tags, updateTagsStateFromScraper, tagsControl } = useTagsEdit( + movie.tags, + (ids) => formik.setFieldValue("tag_ids", ids) + ); + function onSetStudio(item: Studio | null) { setStudio(item); formik.setFieldValue("studio_id", item ? item.id : null); @@ -159,6 +167,7 @@ export const MovieEditPanel: React.FC = ({ if (state.urls) { formik.setFieldValue("urls", state.urls); } + updateTagsStateFromScraper(state.tags ?? undefined); if (state.front_image) { // image is a base64 string @@ -231,6 +240,7 @@ export const MovieEditPanel: React.FC = ({ { onScrapeDialogClosed(m); @@ -351,6 +361,11 @@ export const MovieEditPanel: React.FC = ({ return renderField("studio_id", title, control); } + function renderTagsField() { + const title = intl.formatMessage({ id: "tags" }); + return renderField("tag_ids", title, tagsControl()); + } + // TODO: CSS class return (
@@ -383,6 +398,7 @@ export const MovieEditPanel: React.FC = ({ {renderInputField("director")} {renderURLListField("urls", onScrapeMovieURL, urlScrapable)} {renderInputField("synopsis", "textarea")} + {renderTagsField()} ; movieStudio: Studio | null; + movieTags: Tag[]; scraped: GQL.ScrapedMovie; onClose: (scrapedMovie?: GQL.ScrapedMovie) => void; } -export const MovieScrapeDialog: React.FC = ( - props: IMovieScrapeDialogProps -) => { +export const MovieScrapeDialog: React.FC = ({ + movie, + movieStudio, + movieTags, + scraped, + onClose, +}) => { const intl = useIntl(); const [name, setName] = useState>( - new ScrapeResult(props.movie.name, props.scraped.name) + new ScrapeResult(movie.name, scraped.name) ); const [aliases, setAliases] = useState>( - new ScrapeResult(props.movie.aliases, props.scraped.aliases) + new ScrapeResult(movie.aliases, scraped.aliases) ); const [duration, setDuration] = useState>( new ScrapeResult( - TextUtils.secondsToTimestamp(props.movie.duration || 0), + TextUtils.secondsToTimestamp(movie.duration || 0), // convert seconds to string if it's a number - props.scraped.duration && !isNaN(+props.scraped.duration) - ? TextUtils.secondsToTimestamp(parseInt(props.scraped.duration, 10)) - : props.scraped.duration + scraped.duration && !isNaN(+scraped.duration) + ? TextUtils.secondsToTimestamp(parseInt(scraped.duration, 10)) + : scraped.duration ) ); const [date, setDate] = useState>( - new ScrapeResult(props.movie.date, props.scraped.date) + new ScrapeResult(movie.date, scraped.date) ); const [director, setDirector] = useState>( - new ScrapeResult(props.movie.director, props.scraped.director) + new ScrapeResult(movie.director, scraped.director) ); const [synopsis, setSynopsis] = useState>( - new ScrapeResult(props.movie.synopsis, props.scraped.synopsis) + new ScrapeResult(movie.synopsis, scraped.synopsis) ); const [studio, setStudio] = useState>( new ObjectScrapeResult( - props.movieStudio + movieStudio ? { - stored_id: props.movieStudio.id, - name: props.movieStudio.name, + stored_id: movieStudio.id, + name: movieStudio.name, } : undefined, - props.scraped.studio?.stored_id ? props.scraped.studio : undefined + scraped.studio?.stored_id ? scraped.studio : undefined ) ); const [urls, setURLs] = useState>( new ScrapeResult( - props.movie.urls, - props.scraped.urls - ? uniq((props.movie.urls ?? []).concat(props.scraped.urls ?? [])) + movie.urls, + scraped.urls + ? uniq((movie.urls ?? []).concat(scraped.urls ?? [])) : undefined ) ); const [frontImage, setFrontImage] = useState>( - new ScrapeResult(props.movie.front_image, props.scraped.front_image) + new ScrapeResult(movie.front_image, scraped.front_image) ); const [backImage, setBackImage] = useState>( - new ScrapeResult(props.movie.back_image, props.scraped.back_image) + new ScrapeResult(movie.back_image, scraped.back_image) ); const [newStudio, setNewStudio] = useState( - props.scraped.studio && !props.scraped.studio.stored_id - ? props.scraped.studio - : undefined + scraped.studio && !scraped.studio.stored_id ? scraped.studio : undefined ); const createNewStudio = useCreateScrapedStudio({ @@ -93,6 +98,11 @@ export const MovieScrapeDialog: React.FC = ( setNewObject: setNewStudio, }); + const { tags, newTags, scrapedTagsRow } = useScrapedTags( + movieTags, + scraped.tags + ); + const allFields = [ name, aliases, @@ -101,17 +111,21 @@ export const MovieScrapeDialog: React.FC = ( director, synopsis, studio, + tags, urls, frontImage, backImage, ]; // don't show the dialog if nothing was scraped - if (allFields.every((r) => !r.scraped) && !newStudio) { - props.onClose(); + if ( + allFields.every((r) => !r.scraped) && + !newStudio && + newTags.length === 0 + ) { + onClose(); return <>; } - // todo: reenable function makeNewScrapedItem(): GQL.ScrapedMovie { const newStudioValue = studio.getNewValue(); const durationString = duration.getNewValue(); @@ -124,6 +138,7 @@ export const MovieScrapeDialog: React.FC = ( director: director.getNewValue(), synopsis: synopsis.getNewValue(), studio: newStudioValue, + tags: tags.getNewValue(), urls: urls.getNewValue(), front_image: frontImage.getNewValue(), back_image: backImage.getNewValue(), @@ -176,6 +191,7 @@ export const MovieScrapeDialog: React.FC = ( result={urls} onChange={(value) => setURLs(value)} /> + {scrapedTagsRow} = ( )} renderScrapeRows={renderScrapeRows} onClose={(apply) => { - props.onClose(apply ? makeNewScrapedItem() : undefined); + onClose(apply ? makeNewScrapedItem() : undefined); }} /> ); diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx index cef86ecd5d0..dc38e53ea02 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useState } from "react"; -import { Button, Form, Badge, Dropdown } from "react-bootstrap"; +import { Button, Form, Dropdown } from "react-bootstrap"; import { FormattedMessage, useIntl } from "react-intl"; import Mousetrap from "mousetrap"; import * as GQL from "src/core/generated-graphql"; @@ -8,13 +8,11 @@ import { useListPerformerScrapers, queryScrapePerformer, mutateReloadScrapers, - useTagCreate, queryScrapePerformerURL, } from "src/core/StashService"; import { Icon } from "src/components/Shared/Icon"; import { ImageInput } from "src/components/Shared/ImageInput"; import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; -import { CollapseButton } from "src/components/Shared/CollapseButton"; import { CountrySelect } from "src/components/Shared/CountrySelect"; import { URLField } from "src/components/Shared/URLField"; import ImageUtils from "src/utils/image"; @@ -38,7 +36,7 @@ import { PerformerScrapeDialog } from "./PerformerScrapeDialog"; import PerformerScrapeModal from "./PerformerScrapeModal"; import PerformerStashBoxModal, { IStashBox } from "./PerformerStashBoxModal"; import cx from "classnames"; -import { faPlus, faSyncAlt } from "@fortawesome/free-solid-svg-icons"; +import { faSyncAlt } from "@fortawesome/free-solid-svg-icons"; import isEqual from "lodash-es/isEqual"; import { formikUtils } from "src/utils/form"; import { @@ -48,7 +46,7 @@ import { yupDateString, yupUniqueAliases, } from "src/utils/yup"; -import { Tag, TagSelect } from "src/components/Tags/TagSelect"; +import { useTagsEdit } from "src/hooks/tagsEdit"; const isScraper = ( scraper: GQL.Scraper | GQL.StashBox @@ -77,14 +75,11 @@ export const PerformerEditPanel: React.FC = ({ // Editing state const [scraper, setScraper] = useState(); - const [newTags, setNewTags] = useState(); const [isScraperModalOpen, setIsScraperModalOpen] = useState(false); // Network state const [isLoading, setIsLoading] = useState(false); - const [tags, setTags] = useState([]); - const Scrapers = useListPerformerScrapers(); const [queryableScrapers, setQueryableScrapers] = useState([]); @@ -92,7 +87,6 @@ export const PerformerEditPanel: React.FC = ({ useState(); const { configuration: stashConfig } = React.useContext(ConfigurationContext); - const [createTag] = useTagCreate(); const intl = useIntl(); const schema = yup.object({ @@ -163,17 +157,10 @@ export const PerformerEditPanel: React.FC = ({ onSubmit: (values) => onSave(schema.cast(values)), }); - function onSetTags(items: Tag[]) { - setTags(items); - formik.setFieldValue( - "tag_ids", - items.map((item) => item.id) - ); - } - - useEffect(() => { - setTags(performer.tags ?? []); - }, [performer.tags]); + const { tags, updateTagsStateFromScraper, tagsControl } = useTagsEdit( + performer.tags, + (ids) => formik.setFieldValue("tag_ids", ids) + ); function translateScrapedGender(scrapedGender?: string) { if (!scrapedGender) { @@ -207,43 +194,6 @@ export const PerformerEditPanel: React.FC = ({ } } - async function createNewTag(toCreate: GQL.ScrapedTag) { - const tagInput: GQL.TagCreateInput = { name: toCreate.name ?? "" }; - try { - const result = await createTag({ - variables: { - input: tagInput, - }, - }); - - if (!result.data?.tagCreate) { - Toast.error(new Error("Failed to create tag")); - return; - } - - // add the new tag to the new tags value - const newTagIds = formik.values.tag_ids.concat([ - result.data.tagCreate.id, - ]); - formik.setFieldValue("tag_ids", newTagIds); - - // remove the tag from the list - const newTagsClone = newTags!.concat(); - const pIndex = newTagsClone.indexOf(toCreate); - newTagsClone.splice(pIndex, 1); - - setNewTags(newTagsClone); - - Toast.success( - - Created tag: {toCreate.name} - - ); - } catch (e) { - Toast.error(e); - } - } - function updatePerformerEditStateFromScraper( state: Partial ) { @@ -312,20 +262,7 @@ export const PerformerEditPanel: React.FC = ({ formik.setFieldValue("circumcised", newCircumcised); } } - if (state.tags) { - // map tags to their ids and filter out those not found - onSetTags( - state.tags.map((p) => { - return { - id: p.stored_id!, - name: p.name ?? "", - aliases: [], - }; - }) - ); - - setNewTags(state.tags.filter((t) => !t.stored_id)); - } + updateTagsStateFromScraper(state.tags ?? undefined); // image is a base64 string // #404: don't overwrite image if it has been modified by the user @@ -702,59 +639,10 @@ export const PerformerEditPanel: React.FC = ({ return renderField("url", title, control); } - - function renderNewTags() { - if (!newTags || newTags.length === 0) { - return; - } - - const ret = ( - <> - {newTags.map((t) => ( - createNewTag(t)} - > - {t.name} - - - ))} - - ); - - const minCollapseLength = 10; - - if (newTags.length >= minCollapseLength) { - return ( - - {ret} - - ); - } - - return ret; - } - function renderTagsField() { const title = intl.formatMessage({ id: "tags" }); - const control = ( - <> - - {renderNewTags()} - - ); - - return renderField("tag_ids", title, control); + return renderField("tag_ids", title, tagsControl()); } return ( diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScrapeDialog.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScrapeDialog.tsx index 00d1d2a0e28..dbc4c5108e9 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScrapeDialog.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScrapeDialog.tsx @@ -21,14 +21,9 @@ import { stringToCircumcised, } from "src/utils/circumcised"; import { IStashBox } from "./PerformerStashBoxModal"; -import { - ObjectListScrapeResult, - ScrapeResult, -} from "src/components/Shared/ScrapeDialog/scrapeResult"; -import { ScrapedTagsRow } from "src/components/Shared/ScrapeDialog/ScrapedObjectsRow"; -import { sortStoredIdObjects } from "src/utils/data"; +import { ScrapeResult } from "src/components/Shared/ScrapeDialog/scrapeResult"; import { Tag } from "src/components/Tags/TagSelect"; -import { useCreateScrapedTag } from "src/components/Shared/ScrapeDialog/createObjects"; +import { useScrapedTags } from "src/components/Shared/ScrapeDialog/scrapedTags"; function renderScrapedGender( result: ScrapeResult, @@ -304,29 +299,11 @@ export const PerformerScrapeDialog: React.FC = ( ) ); - const [tags, setTags] = useState>( - new ObjectListScrapeResult( - sortStoredIdObjects( - props.performerTags.map((t) => ({ - stored_id: t.id, - name: t.name, - })) - ), - sortStoredIdObjects(props.scraped.tags ?? undefined) - ) - ); - - const [newTags, setNewTags] = useState( - props.scraped.tags?.filter((t) => !t.stored_id) ?? [] + const { tags, newTags, scrapedTagsRow } = useScrapedTags( + props.performerTags, + props.scraped.tags ); - const createNewTag = useCreateScrapedTag({ - scrapeResult: tags, - setScrapeResult: setTags, - newObjects: newTags, - setNewObjects: setNewTags, - }); - const [image, setImage] = useState>( new ScrapeResult( props.performer.image, @@ -525,13 +502,7 @@ export const PerformerScrapeDialog: React.FC = ( result={details} onChange={(value) => setDetails(value)} /> - setTags(value)} - newObjects={newTags} - onCreateNew={createNewTag} - /> + {scrapedTagsRow} import("./SceneScrapeDialog")); const SceneQueryModal = lazyComponent(() => import("./SceneQueryModal")); @@ -76,7 +76,6 @@ export const SceneEditPanel: React.FC = ({ const [galleries, setGalleries] = useState([]); const [performers, setPerformers] = useState([]); const [movies, setMovies] = useState([]); - const [tags, setTags] = useState([]); const [studio, setStudio] = useState(null); const Scrapers = useListSceneScrapers(); @@ -108,10 +107,6 @@ export const SceneEditPanel: React.FC = ({ setMovies(scene.movies?.map((m) => m.movie) ?? []); }, [scene.movies]); - useEffect(() => { - setTags(scene.tags ?? []); - }, [scene.tags]); - useEffect(() => { setStudio(scene.studio ?? null); }, [scene.studio]); @@ -174,6 +169,11 @@ export const SceneEditPanel: React.FC = ({ onSubmit: (values) => onSave(schema.cast(values)), }); + const { tags, updateTagsStateFromScraper, tagsControl } = useTagsEdit( + scene.tags, + (ids) => formik.setFieldValue("tag_ids", ids) + ); + const coverImagePreview = useMemo(() => { const sceneImage = scene.paths?.screenshot; const formImage = formik.values.cover_image; @@ -214,14 +214,6 @@ export const SceneEditPanel: React.FC = ({ ); } - function onSetTags(items: Tag[]) { - setTags(items); - formik.setFieldValue( - "tag_ids", - items.map((item) => item.id) - ); - } - function onSetStudio(item: Studio | null) { setStudio(item); formik.setFieldValue("studio_id", item ? item.id : null); @@ -593,23 +585,7 @@ export const SceneEditPanel: React.FC = ({ } } - if (updatedScene?.tags?.length) { - const idTags = updatedScene.tags.filter((p) => { - return p.stored_id !== undefined && p.stored_id !== null; - }); - - if (idTags.length > 0) { - onSetTags( - idTags.map((p) => { - return { - id: p.stored_id!, - name: p.name ?? "", - aliases: [], - }; - }) - ); - } - } + updateTagsStateFromScraper(updatedScene.tags ?? undefined); if (updatedScene.image) { // image is a base64 string @@ -771,16 +747,7 @@ export const SceneEditPanel: React.FC = ({ function renderTagsField() { const title = intl.formatMessage({ id: "tags" }); - const control = ( - - ); - - return renderField("tag_ids", title, control, fullWidthProps); + return renderField("tag_ids", title, tagsControl(), fullWidthProps); } function renderDetailsField() { diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneScrapeDialog.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneScrapeDialog.tsx index 91bc9457c47..80ad9850f9f 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneScrapeDialog.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneScrapeDialog.tsx @@ -20,17 +20,16 @@ import { ScrapedMoviesRow, ScrapedPerformersRow, ScrapedStudioRow, - ScrapedTagsRow, } from "src/components/Shared/ScrapeDialog/ScrapedObjectsRow"; import { useCreateScrapedMovie, useCreateScrapedPerformer, useCreateScrapedStudio, - useCreateScrapedTag, } from "src/components/Shared/ScrapeDialog/createObjects"; import { Tag } from "src/components/Tags/TagSelect"; import { Studio } from "src/components/Studios/StudioSelect"; import { Movie } from "src/components/Movies/MovieSelect"; +import { useScrapedTags } from "src/components/Shared/ScrapeDialog/scrapedTags"; interface ISceneScrapeDialogProps { scene: Partial; @@ -132,19 +131,9 @@ export const SceneScrapeDialog: React.FC = ({ scraped.movies?.filter((t) => !t.stored_id) ?? [] ); - const [tags, setTags] = useState>( - new ObjectListScrapeResult( - sortStoredIdObjects( - sceneTags.map((t) => ({ - stored_id: t.id, - name: t.name, - })) - ), - sortStoredIdObjects(scraped.tags ?? undefined) - ) - ); - const [newTags, setNewTags] = useState( - scraped.tags?.filter((t) => !t.stored_id) ?? [] + const { tags, newTags, scrapedTagsRow } = useScrapedTags( + sceneTags, + scraped.tags ); const [details, setDetails] = useState>( @@ -175,13 +164,6 @@ export const SceneScrapeDialog: React.FC = ({ setNewObjects: setNewMovies, }); - const createNewTag = useCreateScrapedTag({ - scrapeResult: tags, - setScrapeResult: setTags, - newObjects: newTags, - setNewObjects: setNewTags, - }); - const intl = useIntl(); // don't show the dialog if nothing was scraped @@ -278,13 +260,7 @@ export const SceneScrapeDialog: React.FC = ({ newObjects={newMovies} onCreateNew={createNewMovie} /> - setTags(value)} - newObjects={newTags} - onCreateNew={createNewTag} - /> + {scrapedTagsRow} +) { + const intl = useIntl(); + const [tags, setTags] = useState>( + new ObjectListScrapeResult( + sortStoredIdObjects( + existingTags.map((t) => ({ + stored_id: t.id, + name: t.name, + })) + ), + sortStoredIdObjects(scrapedTags ?? undefined) + ) + ); + + const [newTags, setNewTags] = useState( + scrapedTags?.filter((t) => !t.stored_id) ?? [] + ); + + const createNewTag = useCreateScrapedTag({ + scrapeResult: tags, + setScrapeResult: setTags, + newObjects: newTags, + setNewObjects: setNewTags, + }); + + const scrapedTagsRow = ( + setTags(value)} + newObjects={newTags} + onCreateNew={createNewTag} + /> + ); + + return { + tags, + newTags, + scrapedTagsRow, + }; +} diff --git a/ui/v2.5/src/components/Shared/TagLink.tsx b/ui/v2.5/src/components/Shared/TagLink.tsx index e97d0a957b3..9c2ed1cb340 100644 --- a/ui/v2.5/src/components/Shared/TagLink.tsx +++ b/ui/v2.5/src/components/Shared/TagLink.tsx @@ -191,7 +191,7 @@ export const GalleryLink: React.FC = ({ interface ITagLinkProps { tag: INamedObject; - linkType?: "scene" | "gallery" | "image" | "details" | "performer"; + linkType?: "scene" | "gallery" | "image" | "details" | "performer" | "movie"; className?: string; hoverPlacement?: Placement; showHierarchyIcon?: boolean; @@ -216,6 +216,8 @@ export const TagLink: React.FC = ({ return NavUtils.makeTagGalleriesUrl(tag); case "image": return NavUtils.makeTagImagesUrl(tag); + case "movie": + return NavUtils.makeTagMoviesUrl(tag); case "details": return NavUtils.makeTagUrl(tag.id ?? ""); } diff --git a/ui/v2.5/src/components/Tags/TagCard.tsx b/ui/v2.5/src/components/Tags/TagCard.tsx index cff2326b68e..51444f99949 100644 --- a/ui/v2.5/src/components/Tags/TagCard.tsx +++ b/ui/v2.5/src/components/Tags/TagCard.tsx @@ -223,6 +223,19 @@ export const TagCard: React.FC = ({ ); } + function maybeRenderMoviesPopoverButton() { + if (!tag.movie_count) return; + + return ( + + ); + } + function maybeRenderPopoverButtonGroup() { if (tag) { return ( @@ -232,6 +245,7 @@ export const TagCard: React.FC = ({ {maybeRenderScenesPopoverButton()} {maybeRenderImagesPopoverButton()} {maybeRenderGalleriesPopoverButton()} + {maybeRenderMoviesPopoverButton()} {maybeRenderSceneMarkersPopoverButton()} {maybeRenderPerformersPopoverButton()} diff --git a/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx b/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx index 81a60c0f2d3..aa10275b6cb 100644 --- a/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx +++ b/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx @@ -41,6 +41,7 @@ import { import { DetailImage } from "src/components/Shared/DetailImage"; import { useLoadStickyHeader } from "src/hooks/detailsPanel"; import { useScrollToTopOnMount } from "src/hooks/scrollToTop"; +import { TagMoviesPanel } from "./TagMoviesPanel"; interface IProps { tag: GQL.TagDataFragment; @@ -57,6 +58,7 @@ const validTabs = [ "scenes", "images", "galleries", + "movies", "markers", "performers", ] as const; @@ -101,6 +103,8 @@ const TagPage: React.FC = ({ tag, tabKey }) => { (showAllCounts ? tag.image_count_all : tag.image_count) ?? 0; const galleryCount = (showAllCounts ? tag.gallery_count_all : tag.gallery_count) ?? 0; + const movieCount = + (showAllCounts ? tag.movie_count_all : tag.movie_count) ?? 0; const sceneMarkerCount = (showAllCounts ? tag.scene_marker_count_all : tag.scene_marker_count) ?? 0; const performerCount = @@ -113,6 +117,8 @@ const TagPage: React.FC = ({ tag, tabKey }) => { ret = "images"; } else if (galleryCount != 0) { ret = "galleries"; + } else if (movieCount != 0) { + ret = "movies"; } else if (sceneMarkerCount != 0) { ret = "markers"; } else if (performerCount != 0) { @@ -121,7 +127,14 @@ const TagPage: React.FC = ({ tag, tabKey }) => { } return ret; - }, [sceneCount, imageCount, galleryCount, sceneMarkerCount, performerCount]); + }, [ + sceneCount, + imageCount, + galleryCount, + sceneMarkerCount, + performerCount, + movieCount, + ]); const setTabKey = useCallback( (newTabKey: string | null) => { @@ -463,6 +476,21 @@ const TagPage: React.FC = ({ tag, tabKey }) => { > + + {intl.formatMessage({ id: "movies" })} + + + } + > + + = ({ active, tag }) => { + const filterHook = useTagFilterHook(tag); + return ; +}; diff --git a/ui/v2.5/src/hooks/tagsEdit.tsx b/ui/v2.5/src/hooks/tagsEdit.tsx new file mode 100644 index 00000000000..e4458291180 --- /dev/null +++ b/ui/v2.5/src/hooks/tagsEdit.tsx @@ -0,0 +1,148 @@ +import * as GQL from "src/core/generated-graphql"; +import { useTagCreate } from "src/core/StashService"; +import { useEffect, useState } from "react"; +import { Tag, TagSelect } from "src/components/Tags/TagSelect"; +import { useToast } from "src/hooks/Toast"; +import { useIntl } from "react-intl"; +import { Badge, Button } from "react-bootstrap"; +import { Icon } from "src/components/Shared/Icon"; +import { faPlus } from "@fortawesome/free-solid-svg-icons"; +import { CollapseButton } from "src/components/Shared/CollapseButton"; + +export function useTagsEdit( + srcTags: Tag[] | undefined, + setFieldValue: (ids: string[]) => void +) { + const intl = useIntl(); + const Toast = useToast(); + const [createTag] = useTagCreate(); + + const [tags, setTags] = useState([]); + const [newTags, setNewTags] = useState(); + + function onSetTags(items: Tag[]) { + setTags(items); + setFieldValue(items.map((item) => item.id)); + } + + useEffect(() => { + setTags(srcTags ?? []); + }, [srcTags]); + + async function createNewTag(toCreate: GQL.ScrapedTag) { + const tagInput: GQL.TagCreateInput = { name: toCreate.name ?? "" }; + try { + const result = await createTag({ + variables: { + input: tagInput, + }, + }); + + if (!result.data?.tagCreate) { + Toast.error(new Error("Failed to create tag")); + return; + } + + // add the new tag to the new tags value + const newTagIds = tags + .map((t) => t.id) + .concat([result.data.tagCreate.id]); + setFieldValue(newTagIds); + + // remove the tag from the list + const newTagsClone = newTags!.concat(); + const pIndex = newTagsClone.indexOf(toCreate); + newTagsClone.splice(pIndex, 1); + + setNewTags(newTagsClone); + + Toast.success( + intl.formatMessage( + { id: "toast.created_entity" }, + { + entity: intl.formatMessage({ id: "tag" }).toLocaleLowerCase(), + entity_name: toCreate.name, + } + ) + ); + } catch (e) { + Toast.error(e); + } + } + + function updateTagsStateFromScraper( + scrapedTags?: Pick[] + ) { + if (scrapedTags) { + // map tags to their ids and filter out those not found + onSetTags( + scrapedTags.map((p) => { + return { + id: p.stored_id!, + name: p.name ?? "", + aliases: [], + }; + }) + ); + + setNewTags(scrapedTags.filter((t) => !t.stored_id)); + } + } + + function renderNewTags() { + if (!newTags || newTags.length === 0) { + return; + } + + const ret = ( + <> + {newTags.map((t) => ( + createNewTag(t)} + > + {t.name} + + + ))} + + ); + + const minCollapseLength = 10; + + if (newTags.length >= minCollapseLength) { + return ( + + {ret} + + ); + } + + return ret; + } + + function tagsControl() { + return ( + <> + + {renderNewTags()} + + ); + } + + return { + tags, + onSetTags, + tagsControl, + updateTagsStateFromScraper, + }; +} diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index d1072183a64..61daff120a9 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -1118,6 +1118,7 @@ "megabits_per_second": "{value} mbps", "metadata": "Metadata", "movie": "Movie", + "movie_count": "Movie Count", "movie_scene_number": "Scene Number", "movies": "Movies", "name": "Name", diff --git a/ui/v2.5/src/models/list-filter/movies.ts b/ui/v2.5/src/models/list-filter/movies.ts index 9a769e0249f..35e4a24e25c 100644 --- a/ui/v2.5/src/models/list-filter/movies.ts +++ b/ui/v2.5/src/models/list-filter/movies.ts @@ -3,6 +3,7 @@ import { createDateCriterionOption, createMandatoryTimestampCriterionOption, createDurationCriterionOption, + createMandatoryNumberCriterionOption, } from "./criteria/criterion"; import { MovieIsMissingCriterionOption } from "./criteria/is-missing"; import { StudiosCriterionOption } from "./criteria/studios"; @@ -10,10 +11,18 @@ import { PerformersCriterionOption } from "./criteria/performers"; import { ListFilterOptions } from "./filter-options"; import { DisplayMode } from "./types"; import { RatingCriterionOption } from "./criteria/rating"; +import { TagsCriterionOption } from "./criteria/tags"; const defaultSortBy = "name"; -const sortByOptions = ["name", "random", "date", "duration", "rating"] +const sortByOptions = [ + "name", + "random", + "date", + "duration", + "rating", + "tag_count", +] .map(ListFilterOptions.createSortBy) .concat([ { @@ -33,6 +42,8 @@ const criterionOptions = [ RatingCriterionOption, PerformersCriterionOption, createDateCriterionOption("date"), + TagsCriterionOption, + createMandatoryNumberCriterionOption("tag_count"), createMandatoryTimestampCriterionOption("created_at"), createMandatoryTimestampCriterionOption("updated_at"), ]; diff --git a/ui/v2.5/src/models/list-filter/tags.ts b/ui/v2.5/src/models/list-filter/tags.ts index fe1a906f027..9a9b71680a3 100644 --- a/ui/v2.5/src/models/list-filter/tags.ts +++ b/ui/v2.5/src/models/list-filter/tags.ts @@ -35,6 +35,10 @@ const sortByOptions = ["name", "random"] messageID: "scene_count", value: "scenes_count", }, + { + messageID: "movie_count", + value: "movies_count", + }, { messageID: "marker_count", value: "scene_markers_count", @@ -53,6 +57,7 @@ const criterionOptions = [ createMandatoryNumberCriterionOption("image_count"), createMandatoryNumberCriterionOption("gallery_count"), createMandatoryNumberCriterionOption("performer_count"), + createMandatoryNumberCriterionOption("movie_count"), createMandatoryNumberCriterionOption("marker_count"), ParentTagsCriterionOption, new MandatoryNumberCriterionOption("parent_tag_count", "parent_count"), diff --git a/ui/v2.5/src/models/list-filter/types.ts b/ui/v2.5/src/models/list-filter/types.ts index 4a0c49e1771..9638c7e9477 100644 --- a/ui/v2.5/src/models/list-filter/types.ts +++ b/ui/v2.5/src/models/list-filter/types.ts @@ -172,6 +172,7 @@ export type CriterionType = | "image_count" | "gallery_count" | "performer_count" + | "movie_count" | "death_year" | "url" | "interactive" diff --git a/ui/v2.5/src/utils/navigation.ts b/ui/v2.5/src/utils/navigation.ts index 1aece914c8a..e77f40a38aa 100644 --- a/ui/v2.5/src/utils/navigation.ts +++ b/ui/v2.5/src/utils/navigation.ts @@ -78,7 +78,7 @@ const makePerformerImagesUrl = ( }; export interface INamedObject { - id?: string; + id: string; name?: string; } @@ -262,8 +262,7 @@ const makeChildTagsUrl = (tag: Partial) => { return `/tags?${filter.makeQueryParameters()}`; }; -const makeTagScenesUrl = (tag: Partial) => { - if (!tag.id) return "#"; +function makeTagFilter(mode: GQL.FilterMode, tag: INamedObject) { const filter = new ListFilterModel(GQL.FilterMode.Scenes, undefined); const criterion = new TagsCriterion(TagsCriterionOption); criterion.value = { @@ -272,59 +271,31 @@ const makeTagScenesUrl = (tag: Partial) => { depth: 0, }; filter.criteria.push(criterion); - return `/scenes?${filter.makeQueryParameters()}`; + return filter.makeQueryParameters(); +} + +const makeTagScenesUrl = (tag: INamedObject) => { + return `/scenes?${makeTagFilter(GQL.FilterMode.Scenes, tag)}`; }; -const makeTagPerformersUrl = (tag: Partial) => { - if (!tag.id) return "#"; - 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}` }], - excluded: [], - depth: 0, - }; - filter.criteria.push(criterion); - return `/performers?${filter.makeQueryParameters()}`; +const makeTagPerformersUrl = (tag: INamedObject) => { + return `/performers?${makeTagFilter(GQL.FilterMode.Performers, tag)}`; }; -const makeTagSceneMarkersUrl = (tag: Partial) => { - if (!tag.id) return "#"; - 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}` }], - excluded: [], - depth: 0, - }; - filter.criteria.push(criterion); - return `/scenes/markers?${filter.makeQueryParameters()}`; +const makeTagSceneMarkersUrl = (tag: INamedObject) => { + return `/scenes/markers?${makeTagFilter(GQL.FilterMode.SceneMarkers, tag)}`; }; -const makeTagGalleriesUrl = (tag: Partial) => { - if (!tag.id) return "#"; - 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}` }], - excluded: [], - depth: 0, - }; - filter.criteria.push(criterion); - return `/galleries?${filter.makeQueryParameters()}`; +const makeTagGalleriesUrl = (tag: INamedObject) => { + return `/galleries?${makeTagFilter(GQL.FilterMode.Galleries, tag)}`; }; -const makeTagImagesUrl = (tag: Partial) => { - if (!tag.id) return "#"; - 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}` }], - excluded: [], - depth: 0, - }; - filter.criteria.push(criterion); - return `/images?${filter.makeQueryParameters()}`; +const makeTagImagesUrl = (tag: INamedObject) => { + return `/images?${makeTagFilter(GQL.FilterMode.Images, tag)}`; +}; + +const makeTagMoviesUrl = (tag: INamedObject) => { + return `/movies?${makeTagFilter(GQL.FilterMode.Movies, tag)}`; }; type SceneMarkerDataFragment = Pick & { @@ -441,6 +412,7 @@ const NavUtils = { makeTagPerformersUrl, makeTagGalleriesUrl, makeTagImagesUrl, + makeTagMoviesUrl, makeScenesPHashMatchUrl, makeSceneMarkerUrl, makeMovieScenesUrl, From f26766033e03fba06f2b4bd9d74ea2f0f469b57e Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Tue, 18 Jun 2024 13:41:05 +1000 Subject: [PATCH 018/103] Performer urls (#4958) * Populate URLs from legacy fields * Return nil properly in xpath/json scrapers * Improve migration logging --- graphql/schema/types/performer.graphql | 28 +-- .../schema/types/scraped-performer.graphql | 14 +- internal/api/resolver_model_performer.go | 73 ++++++++ internal/api/resolver_mutation_performer.go | 159 +++++++++++++++++- internal/manager/task/migrate.go | 31 ++-- pkg/models/jsonschema/performer.go | 23 +-- pkg/models/mocks/PerformerReaderWriter.go | 23 +++ pkg/models/model_performer.go | 14 +- pkg/models/model_scraped_item.go | 72 +++++--- pkg/models/model_scraped_item_test.go | 6 +- pkg/models/performer.go | 14 +- pkg/models/repository_performer.go | 1 + pkg/performer/export.go | 9 +- pkg/performer/export_test.go | 10 +- pkg/performer/import.go | 22 ++- pkg/performer/url.go | 18 ++ pkg/scraper/json.go | 26 ++- pkg/scraper/performer.go | 51 +++--- pkg/scraper/postprocessing.go | 26 +++ pkg/scraper/scraper.go | 6 + pkg/scraper/stashbox/stash_box.go | 34 ++-- pkg/scraper/xpath.go | 26 ++- pkg/sqlite/anonymise.go | 16 +- pkg/sqlite/database.go | 2 +- .../migrations/62_performer_urls.up.sql | 155 +++++++++++++++++ pkg/sqlite/performer.go | 38 +++-- pkg/sqlite/performer_filter.go | 19 ++- pkg/sqlite/performer_test.go | 72 +++++--- pkg/sqlite/setup_test.go | 33 ++-- pkg/sqlite/tables.go | 9 + pkg/utils/url.go | 15 ++ pkg/utils/url_test.go | 47 ++++++ ui/v2.5/graphql/data/performer-slim.graphql | 4 +- ui/v2.5/graphql/data/performer.graphql | 4 +- ui/v2.5/graphql/data/scrapers.graphql | 8 +- .../Performers/EditPerformersDialog.tsx | 12 -- .../Performers/PerformerDetails/Performer.tsx | 84 ++++----- .../PerformerDetails/PerformerEditPanel.tsx | 40 +---- .../PerformerScrapeDialog.tsx | 43 ++--- .../PerformerDetails/PerformerScrapeModal.tsx | 4 +- .../components/Shared/ExternalLinksButton.tsx | 7 +- .../src/components/Tagger/PerformerModal.tsx | 50 +++++- ui/v2.5/src/components/Tagger/constants.ts | 6 +- ui/v2.5/src/components/Tagger/styles.scss | 6 + ui/v2.5/src/core/performers.ts | 4 +- .../models/list-filter/criteria/is-missing.ts | 2 - ui/v2.5/src/utils/text.ts | 5 - 47 files changed, 992 insertions(+), 379 deletions(-) create mode 100644 pkg/performer/url.go create mode 100644 pkg/sqlite/migrations/62_performer_urls.up.sql create mode 100644 pkg/utils/url.go create mode 100644 pkg/utils/url_test.go diff --git a/graphql/schema/types/performer.graphql b/graphql/schema/types/performer.graphql index c5d32842513..d6d6b2696be 100644 --- a/graphql/schema/types/performer.graphql +++ b/graphql/schema/types/performer.graphql @@ -16,10 +16,11 @@ type Performer { id: ID! name: String! disambiguation: String - url: String + url: String @deprecated(reason: "Use urls") + urls: [String!] gender: GenderEnum - twitter: String - instagram: String + twitter: String @deprecated(reason: "Use urls") + instagram: String @deprecated(reason: "Use urls") birthdate: String ethnicity: String country: String @@ -60,7 +61,8 @@ type Performer { input PerformerCreateInput { name: String! disambiguation: String - url: String + url: String @deprecated(reason: "Use urls") + urls: [String!] gender: GenderEnum birthdate: String ethnicity: String @@ -75,8 +77,8 @@ input PerformerCreateInput { tattoos: String piercings: String alias_list: [String!] - twitter: String - instagram: String + twitter: String @deprecated(reason: "Use urls") + instagram: String @deprecated(reason: "Use urls") favorite: Boolean tag_ids: [ID!] "This should be a URL or a base64 encoded data URL" @@ -95,7 +97,8 @@ input PerformerUpdateInput { id: ID! name: String disambiguation: String - url: String + url: String @deprecated(reason: "Use urls") + urls: [String!] gender: GenderEnum birthdate: String ethnicity: String @@ -110,8 +113,8 @@ input PerformerUpdateInput { tattoos: String piercings: String alias_list: [String!] - twitter: String - instagram: String + twitter: String @deprecated(reason: "Use urls") + instagram: String @deprecated(reason: "Use urls") favorite: Boolean tag_ids: [ID!] "This should be a URL or a base64 encoded data URL" @@ -135,7 +138,8 @@ input BulkPerformerUpdateInput { clientMutationId: String ids: [ID!] disambiguation: String - url: String + url: String @deprecated(reason: "Use urls") + urls: BulkUpdateStrings gender: GenderEnum birthdate: String ethnicity: String @@ -150,8 +154,8 @@ input BulkPerformerUpdateInput { tattoos: String piercings: String alias_list: BulkUpdateStrings - twitter: String - instagram: String + twitter: String @deprecated(reason: "Use urls") + instagram: String @deprecated(reason: "Use urls") favorite: Boolean tag_ids: BulkUpdateIds # rating expressed as 1-100 diff --git a/graphql/schema/types/scraped-performer.graphql b/graphql/schema/types/scraped-performer.graphql index 92ba94d325d..487c89516de 100644 --- a/graphql/schema/types/scraped-performer.graphql +++ b/graphql/schema/types/scraped-performer.graphql @@ -5,9 +5,10 @@ type ScrapedPerformer { name: String disambiguation: String gender: String - url: String - twitter: String - instagram: String + url: String @deprecated(reason: "use urls") + urls: [String!] + twitter: String @deprecated(reason: "use urls") + instagram: String @deprecated(reason: "use urls") birthdate: String ethnicity: String country: String @@ -40,9 +41,10 @@ input ScrapedPerformerInput { name: String disambiguation: String gender: String - url: String - twitter: String - instagram: String + url: String @deprecated(reason: "use urls") + urls: [String!] + twitter: String @deprecated(reason: "use urls") + instagram: String @deprecated(reason: "use urls") birthdate: String ethnicity: String country: String diff --git a/internal/api/resolver_model_performer.go b/internal/api/resolver_model_performer.go index 6164ff29728..58fac77ff24 100644 --- a/internal/api/resolver_model_performer.go +++ b/internal/api/resolver_model_performer.go @@ -24,6 +24,79 @@ func (r *performerResolver) AliasList(ctx context.Context, obj *models.Performer return obj.Aliases.List(), nil } +func (r *performerResolver) URL(ctx context.Context, obj *models.Performer) (*string, error) { + if !obj.URLs.Loaded() { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { + return obj.LoadURLs(ctx, r.repository.Performer) + }); err != nil { + return nil, err + } + } + + urls := obj.URLs.List() + if len(urls) == 0 { + return nil, nil + } + + return &urls[0], nil +} + +func (r *performerResolver) Twitter(ctx context.Context, obj *models.Performer) (*string, error) { + if !obj.URLs.Loaded() { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { + return obj.LoadURLs(ctx, r.repository.Performer) + }); err != nil { + return nil, err + } + } + + urls := obj.URLs.List() + + // find the first twitter url + for _, url := range urls { + if performer.IsTwitterURL(url) { + u := url + return &u, nil + } + } + + return nil, nil +} + +func (r *performerResolver) Instagram(ctx context.Context, obj *models.Performer) (*string, error) { + if !obj.URLs.Loaded() { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { + return obj.LoadURLs(ctx, r.repository.Performer) + }); err != nil { + return nil, err + } + } + + urls := obj.URLs.List() + + // find the first instagram url + for _, url := range urls { + if performer.IsInstagramURL(url) { + u := url + return &u, nil + } + } + + return nil, nil +} + +func (r *performerResolver) Urls(ctx context.Context, obj *models.Performer) ([]string, error) { + if !obj.URLs.Loaded() { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { + return obj.LoadURLs(ctx, r.repository.Performer) + }); err != nil { + return nil, err + } + } + + return obj.URLs.List(), nil +} + func (r *performerResolver) Height(ctx context.Context, obj *models.Performer) (*string, error) { if obj.Height != nil { ret := strconv.Itoa(*obj.Height) diff --git a/internal/api/resolver_mutation_performer.go b/internal/api/resolver_mutation_performer.go index 202778e74d5..7263cc70966 100644 --- a/internal/api/resolver_mutation_performer.go +++ b/internal/api/resolver_mutation_performer.go @@ -12,6 +12,11 @@ import ( "github.com/stashapp/stash/pkg/utils" ) +const ( + twitterURL = "https://twitter.com" + instagramURL = "https://instagram.com" +) + // used to refetch performer after hooks run func (r *mutationResolver) getPerformer(ctx context.Context, id int) (ret *models.Performer, err error) { if err := r.withTxn(ctx, func(ctx context.Context) error { @@ -35,7 +40,6 @@ func (r *mutationResolver) PerformerCreate(ctx context.Context, input models.Per newPerformer.Name = input.Name newPerformer.Disambiguation = translator.string(input.Disambiguation) newPerformer.Aliases = models.NewRelatedStrings(input.AliasList) - newPerformer.URL = translator.string(input.URL) newPerformer.Gender = input.Gender newPerformer.Ethnicity = translator.string(input.Ethnicity) newPerformer.Country = translator.string(input.Country) @@ -47,8 +51,6 @@ func (r *mutationResolver) PerformerCreate(ctx context.Context, input models.Per newPerformer.CareerLength = translator.string(input.CareerLength) newPerformer.Tattoos = translator.string(input.Tattoos) newPerformer.Piercings = translator.string(input.Piercings) - newPerformer.Twitter = translator.string(input.Twitter) - newPerformer.Instagram = translator.string(input.Instagram) newPerformer.Favorite = translator.bool(input.Favorite) newPerformer.Rating = input.Rating100 newPerformer.Details = translator.string(input.Details) @@ -58,6 +60,21 @@ func (r *mutationResolver) PerformerCreate(ctx context.Context, input models.Per newPerformer.IgnoreAutoTag = translator.bool(input.IgnoreAutoTag) newPerformer.StashIDs = models.NewRelatedStashIDs(input.StashIds) + newPerformer.URLs = models.NewRelatedStrings([]string{}) + if input.URL != nil { + newPerformer.URLs.Add(*input.URL) + } + if input.Twitter != nil { + newPerformer.URLs.Add(utils.URLFromHandle(*input.Twitter, twitterURL)) + } + if input.Instagram != nil { + newPerformer.URLs.Add(utils.URLFromHandle(*input.Instagram, instagramURL)) + } + + if input.Urls != nil { + newPerformer.URLs.Add(input.Urls...) + } + var err error newPerformer.Birthdate, err = translator.datePtr(input.Birthdate) @@ -112,6 +129,96 @@ func (r *mutationResolver) PerformerCreate(ctx context.Context, input models.Per return r.getPerformer(ctx, newPerformer.ID) } +func (r *mutationResolver) validateNoLegacyURLs(translator changesetTranslator) error { + // ensure url/twitter/instagram are not included in the input + if translator.hasField("url") { + return fmt.Errorf("url field must not be included if urls is included") + } + if translator.hasField("twitter") { + return fmt.Errorf("twitter field must not be included if urls is included") + } + if translator.hasField("instagram") { + return fmt.Errorf("instagram field must not be included if urls is included") + } + + return nil +} + +func (r *mutationResolver) handleLegacyURLs(ctx context.Context, performerID int, legacyURL, legacyTwitter, legacyInstagram models.OptionalString, updatedPerformer *models.PerformerPartial) error { + qb := r.repository.Performer + + // we need to be careful with URL/Twitter/Instagram + // treat URL as replacing the first non-Twitter/Instagram URL in the list + // twitter should replace any existing twitter URL + // instagram should replace any existing instagram URL + p, err := qb.Find(ctx, performerID) + if err != nil { + return err + } + + if err := p.LoadURLs(ctx, qb); err != nil { + return fmt.Errorf("loading performer URLs: %w", err) + } + + existingURLs := p.URLs.List() + + // performer partial URLs should be empty + if legacyURL.Set { + replaced := false + for i, url := range existingURLs { + if !performer.IsTwitterURL(url) && !performer.IsInstagramURL(url) { + existingURLs[i] = legacyURL.Value + replaced = true + break + } + } + + if !replaced { + existingURLs = append(existingURLs, legacyURL.Value) + } + } + + if legacyTwitter.Set { + value := utils.URLFromHandle(legacyTwitter.Value, twitterURL) + found := false + // find and replace the first twitter URL + for i, url := range existingURLs { + if performer.IsTwitterURL(url) { + existingURLs[i] = value + found = true + break + } + } + + if !found { + existingURLs = append(existingURLs, value) + } + } + if legacyInstagram.Set { + found := false + value := utils.URLFromHandle(legacyInstagram.Value, instagramURL) + // find and replace the first instagram URL + for i, url := range existingURLs { + if performer.IsInstagramURL(url) { + existingURLs[i] = value + found = true + break + } + } + + if !found { + existingURLs = append(existingURLs, value) + } + } + + updatedPerformer.URLs = &models.UpdateStrings{ + Values: existingURLs, + Mode: models.RelationshipUpdateModeSet, + } + + return nil +} + func (r *mutationResolver) PerformerUpdate(ctx context.Context, input models.PerformerUpdateInput) (*models.Performer, error) { performerID, err := strconv.Atoi(input.ID) if err != nil { @@ -127,7 +234,6 @@ func (r *mutationResolver) PerformerUpdate(ctx context.Context, input models.Per updatedPerformer.Name = translator.optionalString(input.Name, "name") updatedPerformer.Disambiguation = translator.optionalString(input.Disambiguation, "disambiguation") - updatedPerformer.URL = translator.optionalString(input.URL, "url") updatedPerformer.Gender = translator.optionalString((*string)(input.Gender), "gender") updatedPerformer.Ethnicity = translator.optionalString(input.Ethnicity, "ethnicity") updatedPerformer.Country = translator.optionalString(input.Country, "country") @@ -139,8 +245,6 @@ func (r *mutationResolver) PerformerUpdate(ctx context.Context, input models.Per updatedPerformer.CareerLength = translator.optionalString(input.CareerLength, "career_length") updatedPerformer.Tattoos = translator.optionalString(input.Tattoos, "tattoos") updatedPerformer.Piercings = translator.optionalString(input.Piercings, "piercings") - updatedPerformer.Twitter = translator.optionalString(input.Twitter, "twitter") - updatedPerformer.Instagram = translator.optionalString(input.Instagram, "instagram") updatedPerformer.Favorite = translator.optionalBool(input.Favorite, "favorite") updatedPerformer.Rating = translator.optionalInt(input.Rating100, "rating100") updatedPerformer.Details = translator.optionalString(input.Details, "details") @@ -149,6 +253,19 @@ func (r *mutationResolver) PerformerUpdate(ctx context.Context, input models.Per updatedPerformer.IgnoreAutoTag = translator.optionalBool(input.IgnoreAutoTag, "ignore_auto_tag") updatedPerformer.StashIDs = translator.updateStashIDs(input.StashIds, "stash_ids") + if translator.hasField("urls") { + // ensure url/twitter/instagram are not included in the input + if err := r.validateNoLegacyURLs(translator); err != nil { + return nil, err + } + + updatedPerformer.URLs = translator.updateStrings(input.Urls, "urls") + } + + legacyURL := translator.optionalString(input.URL, "url") + legacyTwitter := translator.optionalString(input.Twitter, "twitter") + legacyInstagram := translator.optionalString(input.Instagram, "instagram") + updatedPerformer.Birthdate, err = translator.optionalDate(input.Birthdate, "birthdate") if err != nil { return nil, fmt.Errorf("converting birthdate: %w", err) @@ -186,6 +303,12 @@ func (r *mutationResolver) PerformerUpdate(ctx context.Context, input models.Per if err := r.withTxn(ctx, func(ctx context.Context) error { qb := r.repository.Performer + if legacyURL.Set || legacyTwitter.Set || legacyInstagram.Set { + if err := r.handleLegacyURLs(ctx, performerID, legacyURL, legacyTwitter, legacyInstagram, &updatedPerformer); err != nil { + return err + } + } + if err := performer.ValidateUpdate(ctx, performerID, updatedPerformer, qb); err != nil { return err } @@ -225,7 +348,7 @@ func (r *mutationResolver) BulkPerformerUpdate(ctx context.Context, input BulkPe updatedPerformer := models.NewPerformerPartial() updatedPerformer.Disambiguation = translator.optionalString(input.Disambiguation, "disambiguation") - updatedPerformer.URL = translator.optionalString(input.URL, "url") + updatedPerformer.Gender = translator.optionalString((*string)(input.Gender), "gender") updatedPerformer.Ethnicity = translator.optionalString(input.Ethnicity, "ethnicity") updatedPerformer.Country = translator.optionalString(input.Country, "country") @@ -237,8 +360,7 @@ func (r *mutationResolver) BulkPerformerUpdate(ctx context.Context, input BulkPe updatedPerformer.CareerLength = translator.optionalString(input.CareerLength, "career_length") updatedPerformer.Tattoos = translator.optionalString(input.Tattoos, "tattoos") updatedPerformer.Piercings = translator.optionalString(input.Piercings, "piercings") - updatedPerformer.Twitter = translator.optionalString(input.Twitter, "twitter") - updatedPerformer.Instagram = translator.optionalString(input.Instagram, "instagram") + updatedPerformer.Favorite = translator.optionalBool(input.Favorite, "favorite") updatedPerformer.Rating = translator.optionalInt(input.Rating100, "rating100") updatedPerformer.Details = translator.optionalString(input.Details, "details") @@ -246,6 +368,19 @@ func (r *mutationResolver) BulkPerformerUpdate(ctx context.Context, input BulkPe updatedPerformer.Weight = translator.optionalInt(input.Weight, "weight") updatedPerformer.IgnoreAutoTag = translator.optionalBool(input.IgnoreAutoTag, "ignore_auto_tag") + if translator.hasField("urls") { + // ensure url/twitter/instagram are not included in the input + if err := r.validateNoLegacyURLs(translator); err != nil { + return nil, err + } + + updatedPerformer.URLs = translator.updateStringsBulk(input.Urls, "urls") + } + + legacyURL := translator.optionalString(input.URL, "url") + legacyTwitter := translator.optionalString(input.Twitter, "twitter") + legacyInstagram := translator.optionalString(input.Instagram, "instagram") + updatedPerformer.Birthdate, err = translator.optionalDate(input.Birthdate, "birthdate") if err != nil { return nil, fmt.Errorf("converting birthdate: %w", err) @@ -277,6 +412,12 @@ func (r *mutationResolver) BulkPerformerUpdate(ctx context.Context, input BulkPe qb := r.repository.Performer for _, performerID := range performerIDs { + if legacyURL.Set || legacyTwitter.Set || legacyInstagram.Set { + if err := r.handleLegacyURLs(ctx, performerID, legacyURL, legacyTwitter, legacyInstagram, &updatedPerformer); err != nil { + return err + } + } + if err := performer.ValidateUpdate(ctx, performerID, updatedPerformer, qb); err != nil { return err } diff --git a/internal/manager/task/migrate.go b/internal/manager/task/migrate.go index 48ba15a26a9..37062329e48 100644 --- a/internal/manager/task/migrate.go +++ b/internal/manager/task/migrate.go @@ -23,19 +23,27 @@ type MigrateJob struct { Database *sqlite.Database } +type databaseSchemaInfo struct { + CurrentSchemaVersion uint + RequiredSchemaVersion uint + StepsRequired uint +} + func (s *MigrateJob) Execute(ctx context.Context, progress *job.Progress) error { - required, err := s.required() + schemaInfo, err := s.required() if err != nil { return err } - if required == 0 { + if schemaInfo.StepsRequired == 0 { logger.Infof("database is already at the latest schema version") return nil } + logger.Infof("Migrating database from %d to %d", schemaInfo.CurrentSchemaVersion, schemaInfo.RequiredSchemaVersion) + // set the number of tasks = required steps + optimise - progress.SetTotal(int(required + 1)) + progress.SetTotal(int(schemaInfo.StepsRequired + 1)) database := s.Database @@ -79,28 +87,31 @@ func (s *MigrateJob) Execute(ctx context.Context, progress *job.Progress) error } } + logger.Infof("Database migration complete") + return nil } -func (s *MigrateJob) required() (uint, error) { +func (s *MigrateJob) required() (ret databaseSchemaInfo, err error) { database := s.Database m, err := sqlite.NewMigrator(database) if err != nil { - return 0, err + return } defer m.Close() - currentSchemaVersion := m.CurrentSchemaVersion() - targetSchemaVersion := m.RequiredSchemaVersion() + ret.CurrentSchemaVersion = m.CurrentSchemaVersion() + ret.RequiredSchemaVersion = m.RequiredSchemaVersion() - if targetSchemaVersion < currentSchemaVersion { + if ret.RequiredSchemaVersion < ret.CurrentSchemaVersion { // shouldn't happen - return 0, nil + return } - return targetSchemaVersion - currentSchemaVersion, nil + ret.StepsRequired = ret.RequiredSchemaVersion - ret.CurrentSchemaVersion + return } func (s *MigrateJob) runMigrations(ctx context.Context, progress *job.Progress) error { diff --git a/pkg/models/jsonschema/performer.go b/pkg/models/jsonschema/performer.go index 248cf955736..7ffa69983b4 100644 --- a/pkg/models/jsonschema/performer.go +++ b/pkg/models/jsonschema/performer.go @@ -34,16 +34,14 @@ func (s *StringOrStringList) UnmarshalJSON(data []byte) error { } type Performer struct { - Name string `json:"name,omitempty"` - Disambiguation string `json:"disambiguation,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"` + Disambiguation string `json:"disambiguation,omitempty"` + Gender string `json:"gender,omitempty"` + URLs []string `json:"urls,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"` @@ -66,6 +64,11 @@ type Performer struct { Weight int `json:"weight,omitempty"` StashIDs []models.StashID `json:"stash_ids,omitempty"` IgnoreAutoTag bool `json:"ignore_auto_tag,omitempty"` + + // deprecated - for import only + URL string `json:"url,omitempty"` + Twitter string `json:"twitter,omitempty"` + Instagram string `json:"instagram,omitempty"` } func (s Performer) Filename() string { diff --git a/pkg/models/mocks/PerformerReaderWriter.go b/pkg/models/mocks/PerformerReaderWriter.go index 7bbc6ef794e..0f3e2be02b6 100644 --- a/pkg/models/mocks/PerformerReaderWriter.go +++ b/pkg/models/mocks/PerformerReaderWriter.go @@ -383,6 +383,29 @@ func (_m *PerformerReaderWriter) GetTagIDs(ctx context.Context, relatedID int) ( return r0, r1 } +// GetURLs provides a mock function with given fields: ctx, relatedID +func (_m *PerformerReaderWriter) GetURLs(ctx context.Context, relatedID int) ([]string, error) { + ret := _m.Called(ctx, relatedID) + + var r0 []string + if rf, ok := ret.Get(0).(func(context.Context, int) []string); ok { + r0 = rf(ctx, relatedID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]string) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { + r1 = rf(ctx, relatedID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // HasImage provides a mock function with given fields: ctx, performerID func (_m *PerformerReaderWriter) HasImage(ctx context.Context, performerID int) (bool, error) { ret := _m.Called(ctx, performerID) diff --git a/pkg/models/model_performer.go b/pkg/models/model_performer.go index 09f92e13c6d..85257ba38a4 100644 --- a/pkg/models/model_performer.go +++ b/pkg/models/model_performer.go @@ -10,9 +10,6 @@ type Performer struct { Name string `json:"name"` Disambiguation string `json:"disambiguation"` 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"` @@ -37,6 +34,7 @@ type Performer struct { IgnoreAutoTag bool `json:"ignore_auto_tag"` Aliases RelatedStrings `json:"aliases"` + URLs RelatedStrings `json:"urls"` TagIDs RelatedIDs `json:"tag_ids"` StashIDs RelatedStashIDs `json:"stash_ids"` } @@ -55,9 +53,7 @@ type PerformerPartial struct { Name OptionalString Disambiguation OptionalString Gender OptionalString - URL OptionalString - Twitter OptionalString - Instagram OptionalString + URLs *UpdateStrings Birthdate OptionalDate Ethnicity OptionalString Country OptionalString @@ -99,6 +95,12 @@ func (s *Performer) LoadAliases(ctx context.Context, l AliasLoader) error { }) } +func (s *Performer) LoadURLs(ctx context.Context, l URLLoader) error { + return s.URLs.load(func() ([]string, error) { + return l.GetURLs(ctx, s.ID) + }) +} + func (s *Performer) LoadTagIDs(ctx context.Context, l TagIDLoader) error { return s.TagIDs.load(func() ([]int, error) { return l.GetTagIDs(ctx, s.ID) diff --git a/pkg/models/model_scraped_item.go b/pkg/models/model_scraped_item.go index 5cc5c679cb3..206f1109b2e 100644 --- a/pkg/models/model_scraped_item.go +++ b/pkg/models/model_scraped_item.go @@ -107,9 +107,10 @@ type ScrapedPerformer struct { Name *string `json:"name"` Disambiguation *string `json:"disambiguation"` Gender *string `json:"gender"` - URL *string `json:"url"` - Twitter *string `json:"twitter"` - Instagram *string `json:"instagram"` + URLs []string `json:"urls"` + URL *string `json:"url"` // deprecated + Twitter *string `json:"twitter"` // deprecated + Instagram *string `json:"instagram"` // deprecated Birthdate *string `json:"birthdate"` Ethnicity *string `json:"ethnicity"` Country *string `json:"country"` @@ -191,9 +192,7 @@ func (p *ScrapedPerformer) ToPerformer(endpoint string, excluded map[string]bool ret.Weight = &w } } - if p.Instagram != nil && !excluded["instagram"] { - ret.Instagram = *p.Instagram - } + if p.Measurements != nil && !excluded["measurements"] { ret.Measurements = *p.Measurements } @@ -221,11 +220,27 @@ func (p *ScrapedPerformer) ToPerformer(endpoint string, excluded map[string]bool ret.Circumcised = &v } } - if p.Twitter != nil && !excluded["twitter"] { - ret.Twitter = *p.Twitter - } - if p.URL != nil && !excluded["url"] { - ret.URL = *p.URL + + // if URLs are provided, only use those + if len(p.URLs) > 0 { + if !excluded["urls"] { + ret.URLs = NewRelatedStrings(p.URLs) + } + } else { + urls := []string{} + if p.URL != nil && !excluded["url"] { + urls = append(urls, *p.URL) + } + if p.Twitter != nil && !excluded["twitter"] { + urls = append(urls, *p.Twitter) + } + if p.Instagram != nil && !excluded["instagram"] { + urls = append(urls, *p.Instagram) + } + + if len(urls) > 0 { + ret.URLs = NewRelatedStrings(urls) + } } if p.RemoteSiteID != nil && endpoint != "" { @@ -309,9 +324,6 @@ func (p *ScrapedPerformer) ToPartial(endpoint string, excluded map[string]bool, ret.Weight = NewOptionalInt(w) } } - if p.Instagram != nil && !excluded["instagram"] { - ret.Instagram = NewOptionalString(*p.Instagram) - } if p.Measurements != nil && !excluded["measurements"] { ret.Measurements = NewOptionalString(*p.Measurements) } @@ -330,11 +342,33 @@ func (p *ScrapedPerformer) ToPartial(endpoint string, excluded map[string]bool, if p.Tattoos != nil && !excluded["tattoos"] { ret.Tattoos = NewOptionalString(*p.Tattoos) } - if p.Twitter != nil && !excluded["twitter"] { - ret.Twitter = NewOptionalString(*p.Twitter) - } - if p.URL != nil && !excluded["url"] { - ret.URL = NewOptionalString(*p.URL) + + // if URLs are provided, only use those + if len(p.URLs) > 0 { + if !excluded["urls"] { + ret.URLs = &UpdateStrings{ + Values: p.URLs, + Mode: RelationshipUpdateModeSet, + } + } + } else { + urls := []string{} + if p.URL != nil && !excluded["url"] { + urls = append(urls, *p.URL) + } + if p.Twitter != nil && !excluded["twitter"] { + urls = append(urls, *p.Twitter) + } + if p.Instagram != nil && !excluded["instagram"] { + urls = append(urls, *p.Instagram) + } + + if len(urls) > 0 { + ret.URLs = &UpdateStrings{ + Values: urls, + Mode: RelationshipUpdateModeSet, + } + } } if p.RemoteSiteID != nil && endpoint != "" { diff --git a/pkg/models/model_scraped_item_test.go b/pkg/models/model_scraped_item_test.go index a6e42f2fd80..50657188deb 100644 --- a/pkg/models/model_scraped_item_test.go +++ b/pkg/models/model_scraped_item_test.go @@ -161,9 +161,9 @@ func Test_scrapedToPerformerInput(t *testing.T) { Tattoos: nextVal(), Piercings: nextVal(), Aliases: nextVal(), + URL: nextVal(), Twitter: nextVal(), Instagram: nextVal(), - URL: nextVal(), Details: nextVal(), RemoteSiteID: &remoteSiteID, }, @@ -186,9 +186,7 @@ func Test_scrapedToPerformerInput(t *testing.T) { Tattoos: *nextVal(), Piercings: *nextVal(), Aliases: NewRelatedStrings([]string{*nextVal()}), - Twitter: *nextVal(), - Instagram: *nextVal(), - URL: *nextVal(), + URLs: NewRelatedStrings([]string{*nextVal(), *nextVal(), *nextVal()}), Details: *nextVal(), StashIDs: NewRelatedStashIDs([]StashID{ { diff --git a/pkg/models/performer.go b/pkg/models/performer.go index 75b0f85af67..b14f60044be 100644 --- a/pkg/models/performer.go +++ b/pkg/models/performer.go @@ -203,7 +203,8 @@ type PerformerFilterType struct { type PerformerCreateInput struct { Name string `json:"name"` Disambiguation *string `json:"disambiguation"` - URL *string `json:"url"` + URL *string `json:"url"` // deprecated + Urls []string `json:"urls"` Gender *GenderEnum `json:"gender"` Birthdate *string `json:"birthdate"` Ethnicity *string `json:"ethnicity"` @@ -220,8 +221,8 @@ type PerformerCreateInput struct { Piercings *string `json:"piercings"` Aliases *string `json:"aliases"` AliasList []string `json:"alias_list"` - Twitter *string `json:"twitter"` - Instagram *string `json:"instagram"` + Twitter *string `json:"twitter"` // deprecated + Instagram *string `json:"instagram"` // deprecated Favorite *bool `json:"favorite"` TagIds []string `json:"tag_ids"` // This should be a URL or a base64 encoded data URL @@ -239,7 +240,8 @@ type PerformerUpdateInput struct { ID string `json:"id"` Name *string `json:"name"` Disambiguation *string `json:"disambiguation"` - URL *string `json:"url"` + URL *string `json:"url"` // deprecated + Urls []string `json:"urls"` Gender *GenderEnum `json:"gender"` Birthdate *string `json:"birthdate"` Ethnicity *string `json:"ethnicity"` @@ -256,8 +258,8 @@ type PerformerUpdateInput struct { Piercings *string `json:"piercings"` Aliases *string `json:"aliases"` AliasList []string `json:"alias_list"` - Twitter *string `json:"twitter"` - Instagram *string `json:"instagram"` + Twitter *string `json:"twitter"` // deprecated + Instagram *string `json:"instagram"` // deprecated Favorite *bool `json:"favorite"` TagIds []string `json:"tag_ids"` // This should be a URL or a base64 encoded data URL diff --git a/pkg/models/repository_performer.go b/pkg/models/repository_performer.go index 22ade1d1d7d..3fd93619011 100644 --- a/pkg/models/repository_performer.go +++ b/pkg/models/repository_performer.go @@ -78,6 +78,7 @@ type PerformerReader interface { AliasLoader StashIDLoader TagIDLoader + URLLoader All(ctx context.Context) ([]*Performer, error) GetImage(ctx context.Context, performerID int) ([]byte, error) diff --git a/pkg/performer/export.go b/pkg/performer/export.go index 9aec8b34e56..8f720338f3d 100644 --- a/pkg/performer/export.go +++ b/pkg/performer/export.go @@ -16,6 +16,7 @@ type ImageAliasStashIDGetter interface { GetImage(ctx context.Context, performerID int) ([]byte, error) models.AliasLoader models.StashIDLoader + models.URLLoader } // ToJSON converts a Performer object into its JSON equivalent. @@ -23,7 +24,6 @@ func ToJSON(ctx context.Context, reader ImageAliasStashIDGetter, performer *mode newPerformerJSON := jsonschema.Performer{ Name: performer.Name, Disambiguation: performer.Disambiguation, - URL: performer.URL, Ethnicity: performer.Ethnicity, Country: performer.Country, EyeColor: performer.EyeColor, @@ -32,8 +32,6 @@ func ToJSON(ctx context.Context, reader ImageAliasStashIDGetter, performer *mode CareerLength: performer.CareerLength, Tattoos: performer.Tattoos, Piercings: performer.Piercings, - Twitter: performer.Twitter, - Instagram: performer.Instagram, Favorite: performer.Favorite, Details: performer.Details, HairColor: performer.HairColor, @@ -78,6 +76,11 @@ func ToJSON(ctx context.Context, reader ImageAliasStashIDGetter, performer *mode newPerformerJSON.Aliases = performer.Aliases.List() + if err := performer.LoadURLs(ctx, reader); err != nil { + return nil, fmt.Errorf("loading performer urls: %w", err) + } + newPerformerJSON.URLs = performer.URLs.List() + if err := performer.LoadStashIDs(ctx, reader); err != nil { return nil, fmt.Errorf("loading performer stash ids: %w", err) } diff --git a/pkg/performer/export_test.go b/pkg/performer/export_test.go index 572634aa6a7..36353b17de7 100644 --- a/pkg/performer/export_test.go +++ b/pkg/performer/export_test.go @@ -77,7 +77,7 @@ func createFullPerformer(id int, name string) *models.Performer { ID: id, Name: name, Disambiguation: disambiguation, - URL: url, + URLs: models.NewRelatedStrings([]string{url, twitter, instagram}), Aliases: models.NewRelatedStrings(aliases), Birthdate: &birthDate, CareerLength: careerLength, @@ -90,11 +90,9 @@ func createFullPerformer(id int, name string) *models.Performer { Favorite: true, Gender: &genderEnum, Height: &height, - Instagram: instagram, Measurements: measurements, Piercings: piercings, Tattoos: tattoos, - Twitter: twitter, CreatedAt: createTime, UpdatedAt: updateTime, Rating: &rating, @@ -114,6 +112,7 @@ func createEmptyPerformer(id int) models.Performer { CreatedAt: createTime, UpdatedAt: updateTime, Aliases: models.NewRelatedStrings([]string{}), + URLs: models.NewRelatedStrings([]string{}), TagIDs: models.NewRelatedIDs([]int{}), StashIDs: models.NewRelatedStashIDs([]models.StashID{}), } @@ -123,7 +122,7 @@ func createFullJSONPerformer(name string, image string) *jsonschema.Performer { return &jsonschema.Performer{ Name: name, Disambiguation: disambiguation, - URL: url, + URLs: []string{url, twitter, instagram}, Aliases: aliases, Birthdate: birthDate.String(), CareerLength: careerLength, @@ -136,11 +135,9 @@ func createFullJSONPerformer(name string, image string) *jsonschema.Performer { Favorite: true, Gender: gender, Height: strconv.Itoa(height), - Instagram: instagram, Measurements: measurements, Piercings: piercings, Tattoos: tattoos, - Twitter: twitter, CreatedAt: json.JSONTime{ Time: createTime, }, @@ -161,6 +158,7 @@ func createFullJSONPerformer(name string, image string) *jsonschema.Performer { func createEmptyJSONPerformer() *jsonschema.Performer { return &jsonschema.Performer{ Aliases: []string{}, + URLs: []string{}, StashIDs: []models.StashID{}, CreatedAt: json.JSONTime{ Time: createTime, diff --git a/pkg/performer/import.go b/pkg/performer/import.go index afa6cd4bca8..d50384fa3d3 100644 --- a/pkg/performer/import.go +++ b/pkg/performer/import.go @@ -188,7 +188,6 @@ func performerJSONToPerformer(performerJSON jsonschema.Performer) models.Perform newPerformer := models.Performer{ Name: performerJSON.Name, Disambiguation: performerJSON.Disambiguation, - URL: performerJSON.URL, Ethnicity: performerJSON.Ethnicity, Country: performerJSON.Country, EyeColor: performerJSON.EyeColor, @@ -198,8 +197,6 @@ func performerJSONToPerformer(performerJSON jsonschema.Performer) models.Perform Tattoos: performerJSON.Tattoos, Piercings: performerJSON.Piercings, Aliases: models.NewRelatedStrings(performerJSON.Aliases), - Twitter: performerJSON.Twitter, - Instagram: performerJSON.Instagram, Details: performerJSON.Details, HairColor: performerJSON.HairColor, Favorite: performerJSON.Favorite, @@ -211,6 +208,25 @@ func performerJSONToPerformer(performerJSON jsonschema.Performer) models.Perform StashIDs: models.NewRelatedStashIDs(performerJSON.StashIDs), } + if len(performerJSON.URLs) > 0 { + newPerformer.URLs = models.NewRelatedStrings(performerJSON.URLs) + } else { + urls := []string{} + if performerJSON.URL != "" { + urls = append(urls, performerJSON.URL) + } + if performerJSON.Twitter != "" { + urls = append(urls, performerJSON.Twitter) + } + if performerJSON.Instagram != "" { + urls = append(urls, performerJSON.Instagram) + } + + if len(urls) > 0 { + newPerformer.URLs = models.NewRelatedStrings([]string{performerJSON.URL}) + } + } + if performerJSON.Gender != "" { v := models.GenderEnum(performerJSON.Gender) newPerformer.Gender = &v diff --git a/pkg/performer/url.go b/pkg/performer/url.go new file mode 100644 index 00000000000..4b52adad580 --- /dev/null +++ b/pkg/performer/url.go @@ -0,0 +1,18 @@ +package performer + +import ( + "regexp" +) + +var ( + twitterURLRE = regexp.MustCompile(`^https?:\/\/(?:www\.)?twitter\.com\/`) + instagramURLRE = regexp.MustCompile(`^https?:\/\/(?:www\.)?instagram\.com\/`) +) + +func IsTwitterURL(url string) bool { + return twitterURLRE.MatchString(url) +} + +func IsInstagramURL(url string) bool { + return instagramURLRE.MatchString(url) +} diff --git a/pkg/scraper/json.go b/pkg/scraper/json.go index 98e85378580..ae96ecb06c8 100644 --- a/pkg/scraper/json.go +++ b/pkg/scraper/json.go @@ -81,15 +81,33 @@ func (s *jsonScraper) scrapeByURL(ctx context.Context, url string, ty ScrapeCont } q := s.getJsonQuery(doc) + // if these just return the return values from scraper.scrape* functions then + // it ends up returning ScrapedContent(nil) rather than nil switch ty { case ScrapeContentTypePerformer: - return scraper.scrapePerformer(ctx, q) + ret, err := scraper.scrapePerformer(ctx, q) + if err != nil || ret == nil { + return nil, err + } + return ret, nil case ScrapeContentTypeScene: - return scraper.scrapeScene(ctx, q) + ret, err := scraper.scrapeScene(ctx, q) + if err != nil || ret == nil { + return nil, err + } + return ret, nil case ScrapeContentTypeGallery: - return scraper.scrapeGallery(ctx, q) + ret, err := scraper.scrapeGallery(ctx, q) + if err != nil || ret == nil { + return nil, err + } + return ret, nil case ScrapeContentTypeMovie: - return scraper.scrapeMovie(ctx, q) + ret, err := scraper.scrapeMovie(ctx, q) + if err != nil || ret == nil { + return nil, err + } + return ret, nil } return nil, ErrNotSupported diff --git a/pkg/scraper/performer.go b/pkg/scraper/performer.go index 26936882366..98e93176205 100644 --- a/pkg/scraper/performer.go +++ b/pkg/scraper/performer.go @@ -2,29 +2,30 @@ package scraper type ScrapedPerformerInput struct { // Set if performer matched - StoredID *string `json:"stored_id"` - Name *string `json:"name"` - Disambiguation *string `json:"disambiguation"` - Gender *string `json:"gender"` - URL *string `json:"url"` - Twitter *string `json:"twitter"` - Instagram *string `json:"instagram"` - Birthdate *string `json:"birthdate"` - Ethnicity *string `json:"ethnicity"` - Country *string `json:"country"` - EyeColor *string `json:"eye_color"` - Height *string `json:"height"` - Measurements *string `json:"measurements"` - FakeTits *string `json:"fake_tits"` - PenisLength *string `json:"penis_length"` - Circumcised *string `json:"circumcised"` - CareerLength *string `json:"career_length"` - Tattoos *string `json:"tattoos"` - Piercings *string `json:"piercings"` - Aliases *string `json:"aliases"` - Details *string `json:"details"` - DeathDate *string `json:"death_date"` - HairColor *string `json:"hair_color"` - Weight *string `json:"weight"` - RemoteSiteID *string `json:"remote_site_id"` + StoredID *string `json:"stored_id"` + Name *string `json:"name"` + Disambiguation *string `json:"disambiguation"` + Gender *string `json:"gender"` + URLs []string `json:"urls"` + URL *string `json:"url"` // deprecated + Twitter *string `json:"twitter"` // deprecated + Instagram *string `json:"instagram"` // deprecated + Birthdate *string `json:"birthdate"` + Ethnicity *string `json:"ethnicity"` + Country *string `json:"country"` + EyeColor *string `json:"eye_color"` + Height *string `json:"height"` + Measurements *string `json:"measurements"` + FakeTits *string `json:"fake_tits"` + PenisLength *string `json:"penis_length"` + Circumcised *string `json:"circumcised"` + CareerLength *string `json:"career_length"` + Tattoos *string `json:"tattoos"` + Piercings *string `json:"piercings"` + Aliases *string `json:"aliases"` + Details *string `json:"details"` + DeathDate *string `json:"death_date"` + HairColor *string `json:"hair_color"` + Weight *string `json:"weight"` + RemoteSiteID *string `json:"remote_site_id"` } diff --git a/pkg/scraper/postprocessing.go b/pkg/scraper/postprocessing.go index a375b50582f..e153c561664 100644 --- a/pkg/scraper/postprocessing.go +++ b/pkg/scraper/postprocessing.go @@ -6,6 +6,7 @@ import ( "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/match" "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/utils" ) // postScrape handles post-processing of scraped content. If the content @@ -67,6 +68,31 @@ func (c Cache) postScrapePerformer(ctx context.Context, p models.ScrapedPerforme p.Country = resolveCountryName(p.Country) + // populate URL/URLs + // if URLs are provided, only use those + if len(p.URLs) > 0 { + p.URL = &p.URLs[0] + } else { + urls := []string{} + if p.URL != nil { + urls = append(urls, *p.URL) + } + if p.Twitter != nil && *p.Twitter != "" { + // handle twitter profile names + u := utils.URLFromHandle(*p.Twitter, "https://twitter.com") + urls = append(urls, u) + } + if p.Instagram != nil && *p.Instagram != "" { + // handle instagram profile names + u := utils.URLFromHandle(*p.Instagram, "https://instagram.com") + urls = append(urls, u) + } + + if len(urls) > 0 { + p.URLs = urls + } + } + return p, nil } diff --git a/pkg/scraper/scraper.go b/pkg/scraper/scraper.go index 23ad411bdb0..4eb67dcf415 100644 --- a/pkg/scraper/scraper.go +++ b/pkg/scraper/scraper.go @@ -163,6 +163,12 @@ func (i *Input) populateURL() { if i.Scene != nil && i.Scene.URL == nil && len(i.Scene.URLs) > 0 { i.Scene.URL = &i.Scene.URLs[0] } + if i.Gallery != nil && i.Gallery.URL == nil && len(i.Gallery.URLs) > 0 { + i.Gallery.URL = &i.Gallery.URLs[0] + } + if i.Performer != nil && i.Performer.URL == nil && len(i.Performer.URLs) > 0 { + i.Performer.URL = &i.Performer.URLs[0] + } } // simple type definitions that can help customize diff --git a/pkg/scraper/stashbox/stash_box.go b/pkg/scraper/stashbox/stash_box.go index 407238dae2e..350bac5c4a6 100644 --- a/pkg/scraper/stashbox/stash_box.go +++ b/pkg/scraper/stashbox/stash_box.go @@ -9,7 +9,6 @@ import ( "io" "mime/multipart" "net/http" - "regexp" "strconv" "strings" @@ -41,6 +40,7 @@ type PerformerReader interface { match.PerformerFinder models.AliasLoader models.StashIDLoader + models.URLLoader FindBySceneID(ctx context.Context, sceneID int) ([]*models.Performer, error) GetImage(ctx context.Context, performerID int) ([]byte, error) } @@ -685,6 +685,10 @@ func performerFragmentToScrapedPerformer(p graphql.PerformerFragment) *models.Sc sp.Aliases = &alias } + for _, u := range p.Urls { + sp.URLs = append(sp.URLs, u.URL) + } + return sp } @@ -1128,6 +1132,10 @@ func (c Client) SubmitPerformerDraft(ctx context.Context, performer *models.Perf return nil, err } + if err := performer.LoadURLs(ctx, pqb); err != nil { + return nil, err + } + if err := performer.LoadStashIDs(ctx, pqb); err != nil { return nil, err } @@ -1195,28 +1203,8 @@ func (c Client) SubmitPerformerDraft(ctx context.Context, performer *models.Perf } } - var urls []string - if len(strings.TrimSpace(performer.Twitter)) > 0 { - reg := regexp.MustCompile(`https?:\/\/(?:www\.)?twitter\.com`) - if reg.MatchString(performer.Twitter) { - urls = append(urls, strings.TrimSpace(performer.Twitter)) - } else { - urls = append(urls, "https://twitter.com/"+strings.TrimSpace(performer.Twitter)) - } - } - if len(strings.TrimSpace(performer.Instagram)) > 0 { - reg := regexp.MustCompile(`https?:\/\/(?:www\.)?instagram\.com`) - if reg.MatchString(performer.Instagram) { - urls = append(urls, strings.TrimSpace(performer.Instagram)) - } else { - urls = append(urls, "https://instagram.com/"+strings.TrimSpace(performer.Instagram)) - } - } - if len(strings.TrimSpace(performer.URL)) > 0 { - urls = append(urls, strings.TrimSpace(performer.URL)) - } - if len(urls) > 0 { - draft.Urls = urls + if len(performer.URLs.List()) > 0 { + draft.Urls = performer.URLs.List() } stashIDs, err := pqb.GetStashIDs(ctx, performer.ID) diff --git a/pkg/scraper/xpath.go b/pkg/scraper/xpath.go index 29a4b0a1926..d13c8e4c077 100644 --- a/pkg/scraper/xpath.go +++ b/pkg/scraper/xpath.go @@ -62,15 +62,33 @@ func (s *xpathScraper) scrapeByURL(ctx context.Context, url string, ty ScrapeCon } q := s.getXPathQuery(doc) + // if these just return the return values from scraper.scrape* functions then + // it ends up returning ScrapedContent(nil) rather than nil switch ty { case ScrapeContentTypePerformer: - return scraper.scrapePerformer(ctx, q) + ret, err := scraper.scrapePerformer(ctx, q) + if err != nil || ret == nil { + return nil, err + } + return ret, nil case ScrapeContentTypeScene: - return scraper.scrapeScene(ctx, q) + ret, err := scraper.scrapeScene(ctx, q) + if err != nil || ret == nil { + return nil, err + } + return ret, nil case ScrapeContentTypeGallery: - return scraper.scrapeGallery(ctx, q) + ret, err := scraper.scrapeGallery(ctx, q) + if err != nil || ret == nil { + return nil, err + } + return ret, nil case ScrapeContentTypeMovie: - return scraper.scrapeMovie(ctx, q) + ret, err := scraper.scrapeMovie(ctx, q) + if err != nil || ret == nil { + return nil, err + } + return ret, nil } return nil, ErrNotSupported diff --git a/pkg/sqlite/anonymise.go b/pkg/sqlite/anonymise.go index 44381c0700e..465e6cad551 100644 --- a/pkg/sqlite/anonymise.go +++ b/pkg/sqlite/anonymise.go @@ -495,9 +495,6 @@ func (db *Anonymiser) anonymisePerformers(ctx context.Context) error { table.Col(idColumn), table.Col("name"), table.Col("details"), - table.Col("url"), - table.Col("twitter"), - table.Col("instagram"), table.Col("tattoos"), table.Col("piercings"), ).Where(table.Col(idColumn).Gt(lastID)).Limit(1000) @@ -510,9 +507,6 @@ func (db *Anonymiser) anonymisePerformers(ctx context.Context) error { id int name sql.NullString details sql.NullString - url sql.NullString - twitter sql.NullString - instagram sql.NullString tattoos sql.NullString piercings sql.NullString ) @@ -521,9 +515,6 @@ func (db *Anonymiser) anonymisePerformers(ctx context.Context) error { &id, &name, &details, - &url, - &twitter, - &instagram, &tattoos, &piercings, ); err != nil { @@ -533,9 +524,6 @@ func (db *Anonymiser) anonymisePerformers(ctx context.Context) error { set := goqu.Record{} db.obfuscateNullString(set, "name", name) db.obfuscateNullString(set, "details", details) - db.obfuscateNullString(set, "url", url) - db.obfuscateNullString(set, "twitter", twitter) - db.obfuscateNullString(set, "instagram", instagram) db.obfuscateNullString(set, "tattoos", tattoos) db.obfuscateNullString(set, "piercings", piercings) @@ -566,6 +554,10 @@ func (db *Anonymiser) anonymisePerformers(ctx context.Context) error { return err } + if err := db.anonymiseURLs(ctx, goqu.T(performerURLsTable), "performer_id"); err != nil { + return err + } + return nil } diff --git a/pkg/sqlite/database.go b/pkg/sqlite/database.go index 7cfcd200384..cf502392f34 100644 --- a/pkg/sqlite/database.go +++ b/pkg/sqlite/database.go @@ -30,7 +30,7 @@ const ( dbConnTimeout = 30 ) -var appSchemaVersion uint = 61 +var appSchemaVersion uint = 62 //go:embed migrations/*.sql var migrationsBox embed.FS diff --git a/pkg/sqlite/migrations/62_performer_urls.up.sql b/pkg/sqlite/migrations/62_performer_urls.up.sql new file mode 100644 index 00000000000..cebfa86d616 --- /dev/null +++ b/pkg/sqlite/migrations/62_performer_urls.up.sql @@ -0,0 +1,155 @@ +PRAGMA foreign_keys=OFF; + +CREATE TABLE `performer_urls` ( + `performer_id` integer NOT NULL, + `position` integer NOT NULL, + `url` varchar(255) NOT NULL, + foreign key(`performer_id`) references `performers`(`id`) on delete CASCADE, + PRIMARY KEY(`performer_id`, `position`, `url`) +); + +CREATE INDEX `performers_urls_url` on `performer_urls` (`url`); + +-- drop url, twitter and instagram +-- make name not null +CREATE TABLE `performers_new` ( + `id` integer not null primary key autoincrement, + `name` varchar(255) not null, + `disambiguation` varchar(255), + `gender` varchar(20), + `birthdate` date, + `ethnicity` varchar(255), + `country` varchar(255), + `eye_color` varchar(255), + `height` int, + `measurements` varchar(255), + `fake_tits` varchar(255), + `career_length` varchar(255), + `tattoos` varchar(255), + `piercings` 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', + `image_blob` varchar(255) REFERENCES `blobs`(`checksum`), + `penis_length` float, + `circumcised` varchar[10] +); + +INSERT INTO `performers_new` + ( + `id`, + `name`, + `disambiguation`, + `gender`, + `birthdate`, + `ethnicity`, + `country`, + `eye_color`, + `height`, + `measurements`, + `fake_tits`, + `career_length`, + `tattoos`, + `piercings`, + `favorite`, + `created_at`, + `updated_at`, + `details`, + `death_date`, + `hair_color`, + `weight`, + `rating`, + `ignore_auto_tag`, + `image_blob`, + `penis_length`, + `circumcised` + ) + SELECT + `id`, + `name`, + `disambiguation`, + `gender`, + `birthdate`, + `ethnicity`, + `country`, + `eye_color`, + `height`, + `measurements`, + `fake_tits`, + `career_length`, + `tattoos`, + `piercings`, + `favorite`, + `created_at`, + `updated_at`, + `details`, + `death_date`, + `hair_color`, + `weight`, + `rating`, + `ignore_auto_tag`, + `image_blob`, + `penis_length`, + `circumcised` + FROM `performers`; + +INSERT INTO `performer_urls` + ( + `performer_id`, + `position`, + `url` + ) + SELECT + `id`, + '0', + `url` + FROM `performers` + WHERE `performers`.`url` IS NOT NULL AND `performers`.`url` != ''; + +INSERT INTO `performer_urls` + ( + `performer_id`, + `position`, + `url` + ) + SELECT + `id`, + (SELECT count(*) FROM `performer_urls` WHERE `performer_id` = `performers`.`id`)+1, + CASE + WHEN `twitter` LIKE 'http%://%' THEN `twitter` + ELSE 'https://www.twitter.com/' || `twitter` + END + FROM `performers` + WHERE `performers`.`twitter` IS NOT NULL AND `performers`.`twitter` != ''; + +INSERT INTO `performer_urls` + ( + `performer_id`, + `position`, + `url` + ) + SELECT + `id`, + (SELECT count(*) FROM `performer_urls` WHERE `performer_id` = `performers`.`id`)+1, + CASE + WHEN `instagram` LIKE 'http%://%' THEN `instagram` + ELSE 'https://www.instagram.com/' || `instagram` + END + FROM `performers` + WHERE `performers`.`instagram` IS NOT NULL AND `performers`.`instagram` != ''; + +DROP INDEX `performers_name_disambiguation_unique`; +DROP INDEX `performers_name_unique`; +DROP TABLE `performers`; +ALTER TABLE `performers_new` rename to `performers`; + +CREATE UNIQUE INDEX `performers_name_disambiguation_unique` on `performers` (`name`, `disambiguation`) WHERE `disambiguation` IS NOT NULL; +CREATE UNIQUE INDEX `performers_name_unique` on `performers` (`name`) WHERE `disambiguation` IS NULL; + +PRAGMA foreign_keys=ON; diff --git a/pkg/sqlite/performer.go b/pkg/sqlite/performer.go index 4ba05168d6e..0c2f1d78fb4 100644 --- a/pkg/sqlite/performer.go +++ b/pkg/sqlite/performer.go @@ -23,6 +23,9 @@ const ( performerAliasColumn = "alias" performersTagsTable = "performers_tags" + performerURLsTable = "performer_urls" + performerURLColumn = "url" + performerImageBlobColumn = "image_blob" ) @@ -31,9 +34,6 @@ type performerRow struct { Name null.String `db:"name"` // TODO: make schema non-nullable Disambigation zero.String `db:"disambiguation"` Gender zero.String `db:"gender"` - URL zero.String `db:"url"` - Twitter zero.String `db:"twitter"` - Instagram zero.String `db:"instagram"` Birthdate NullDate `db:"birthdate"` Ethnicity zero.String `db:"ethnicity"` Country zero.String `db:"country"` @@ -68,9 +68,6 @@ func (r *performerRow) fromPerformer(o models.Performer) { if o.Gender != nil && 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) r.Birthdate = NullDateFromDatePtr(o.Birthdate) r.Ethnicity = zero.StringFrom(o.Ethnicity) r.Country = zero.StringFrom(o.Country) @@ -101,9 +98,6 @@ func (r *performerRow) resolve() *models.Performer { ID: r.ID, Name: r.Name.String, Disambiguation: r.Disambigation.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, @@ -148,9 +142,6 @@ func (r *performerRowRecord) fromPartial(o models.PerformerPartial) { r.setString("name", o.Name) r.setNullString("disambiguation", o.Disambiguation) r.setNullString("gender", o.Gender) - r.setNullString("url", o.URL) - r.setNullString("twitter", o.Twitter) - r.setNullString("instagram", o.Instagram) r.setNullDate("birthdate", o.Birthdate) r.setNullString("ethnicity", o.Ethnicity) r.setNullString("country", o.Country) @@ -272,6 +263,13 @@ func (qb *PerformerStore) Create(ctx context.Context, newObject *models.Performe } } + if newObject.URLs.Loaded() { + const startPos = 0 + if err := performersURLsTableMgr.insertJoins(ctx, id, startPos, newObject.URLs.List()); err != nil { + return err + } + } + if newObject.TagIDs.Loaded() { if err := performersTagsTableMgr.insertJoins(ctx, id, newObject.TagIDs.List()); err != nil { return err @@ -315,6 +313,12 @@ func (qb *PerformerStore) UpdatePartial(ctx context.Context, id int, partial mod } } + if partial.URLs != nil { + if err := performersURLsTableMgr.modifyJoins(ctx, id, partial.URLs.Values, partial.URLs.Mode); err != nil { + return nil, err + } + } + if partial.TagIDs != nil { if err := performersTagsTableMgr.modifyJoins(ctx, id, partial.TagIDs.IDs, partial.TagIDs.Mode); err != nil { return nil, err @@ -343,6 +347,12 @@ func (qb *PerformerStore) Update(ctx context.Context, updatedObject *models.Perf } } + if updatedObject.URLs.Loaded() { + if err := performersURLsTableMgr.replaceJoins(ctx, updatedObject.ID, updatedObject.URLs.List()); err != nil { + return err + } + } + if updatedObject.TagIDs.Loaded() { if err := performersTagsTableMgr.replaceJoins(ctx, updatedObject.ID, updatedObject.TagIDs.List()); err != nil { return err @@ -785,6 +795,10 @@ func (qb *PerformerStore) GetAliases(ctx context.Context, performerID int) ([]st return performersAliasesTableMgr.get(ctx, performerID) } +func (qb *PerformerStore) GetURLs(ctx context.Context, performerID int) ([]string, error) { + return performersURLsTableMgr.get(ctx, performerID) +} + func (qb *PerformerStore) GetStashIDs(ctx context.Context, performerID int) ([]models.StashID, error) { return performersStashIDsTableMgr.get(ctx, performerID) } diff --git a/pkg/sqlite/performer_filter.go b/pkg/sqlite/performer_filter.go index 13c2ec5a248..72990a7febd 100644 --- a/pkg/sqlite/performer_filter.go +++ b/pkg/sqlite/performer_filter.go @@ -134,7 +134,7 @@ func (qb *performerFilterHandler) criterionHandler() criterionHandler { stringCriterionHandler(filter.Piercings, tableName+".piercings"), intCriterionHandler(filter.Rating100, tableName+".rating", nil), stringCriterionHandler(filter.HairColor, tableName+".hair_color"), - stringCriterionHandler(filter.URL, tableName+".url"), + qb.urlsCriterionHandler(filter.URL), intCriterionHandler(filter.Weight, tableName+".weight", nil), criterionHandlerFunc(func(ctx context.Context, f *filterBuilder) { if filter.StashID != nil { @@ -211,6 +211,9 @@ func (qb *performerFilterHandler) performerIsMissingCriterionHandler(isMissing * return func(ctx context.Context, f *filterBuilder) { if isMissing != nil && *isMissing != "" { switch *isMissing { + case "url": + performersURLsTableMgr.join(f, "", "performers.id") + f.addWhere("performer_urls.url IS NULL") case "scenes": // Deprecated: use `scene_count == 0` filter instead f.addLeftJoin(performersScenesTable, "scenes_join", "scenes_join.performer_id = performers.id") f.addWhere("scenes_join.scene_id IS NULL") @@ -241,6 +244,20 @@ func (qb *performerFilterHandler) performerAgeFilterCriterionHandler(age *models } } +func (qb *performerFilterHandler) urlsCriterionHandler(url *models.StringCriterionInput) criterionHandlerFunc { + h := stringListCriterionHandlerBuilder{ + primaryTable: performerTable, + primaryFK: performerIDColumn, + joinTable: performerURLsTable, + stringColumn: performerURLColumn, + addJoinTable: func(f *filterBuilder) { + performersURLsTableMgr.join(f, "", "performers.id") + }, + } + + return h.handler(url) +} + func (qb *performerFilterHandler) aliasCriterionHandler(alias *models.StringCriterionInput) criterionHandlerFunc { h := stringListCriterionHandlerBuilder{ primaryTable: performerTable, diff --git a/pkg/sqlite/performer_test.go b/pkg/sqlite/performer_test.go index d333913d282..c0124d09d48 100644 --- a/pkg/sqlite/performer_test.go +++ b/pkg/sqlite/performer_test.go @@ -22,6 +22,11 @@ func loadPerformerRelationships(ctx context.Context, expected models.Performer, return err } } + if expected.URLs.Loaded() { + if err := actual.LoadURLs(ctx, db.Performer); err != nil { + return err + } + } if expected.TagIDs.Loaded() { if err := actual.LoadTagIDs(ctx, db.Performer); err != nil { return err @@ -45,6 +50,7 @@ func Test_PerformerStore_Create(t *testing.T) { url = "url" twitter = "twitter" instagram = "instagram" + urls = []string{url, twitter, instagram} rating = 3 ethnicity = "ethnicity" country = "country" @@ -84,9 +90,7 @@ func Test_PerformerStore_Create(t *testing.T) { Name: name, Disambiguation: disambiguation, Gender: &gender, - URL: url, - Twitter: twitter, - Instagram: instagram, + URLs: models.NewRelatedStrings(urls), Birthdate: &birthdate, Ethnicity: ethnicity, Country: country, @@ -193,6 +197,7 @@ func Test_PerformerStore_Update(t *testing.T) { url = "url" twitter = "twitter" instagram = "instagram" + urls = []string{url, twitter, instagram} rating = 3 ethnicity = "ethnicity" country = "country" @@ -233,9 +238,7 @@ func Test_PerformerStore_Update(t *testing.T) { Name: name, Disambiguation: disambiguation, Gender: &gender, - URL: url, - Twitter: twitter, - Instagram: instagram, + URLs: models.NewRelatedStrings(urls), Birthdate: &birthdate, Ethnicity: ethnicity, Country: country, @@ -277,6 +280,7 @@ func Test_PerformerStore_Update(t *testing.T) { &models.Performer{ ID: performerIDs[performerIdxWithGallery], Aliases: models.NewRelatedStrings([]string{}), + URLs: models.NewRelatedStrings([]string{}), TagIDs: models.NewRelatedIDs([]int{}), StashIDs: models.NewRelatedStashIDs([]models.StashID{}), }, @@ -341,9 +345,7 @@ func clearPerformerPartial() models.PerformerPartial { return models.PerformerPartial{ Disambiguation: nullString, Gender: nullString, - URL: nullString, - Twitter: nullString, - Instagram: nullString, + URLs: &models.UpdateStrings{Mode: models.RelationshipUpdateModeSet}, Birthdate: nullDate, Ethnicity: nullString, Country: nullString, @@ -376,6 +378,7 @@ func Test_PerformerStore_UpdatePartial(t *testing.T) { url = "url" twitter = "twitter" instagram = "instagram" + urls = []string{url, twitter, instagram} rating = 3 ethnicity = "ethnicity" country = "country" @@ -418,21 +421,22 @@ func Test_PerformerStore_UpdatePartial(t *testing.T) { Name: models.NewOptionalString(name), Disambiguation: models.NewOptionalString(disambiguation), 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), - PenisLength: models.NewOptionalFloat64(penisLength), - Circumcised: models.NewOptionalString(circumcised.String()), - CareerLength: models.NewOptionalString(careerLength), - Tattoos: models.NewOptionalString(tattoos), - Piercings: models.NewOptionalString(piercings), + URLs: &models.UpdateStrings{ + Values: urls, + Mode: models.RelationshipUpdateModeSet, + }, + 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), + PenisLength: models.NewOptionalFloat64(penisLength), + Circumcised: models.NewOptionalString(circumcised.String()), + CareerLength: models.NewOptionalString(careerLength), + Tattoos: models.NewOptionalString(tattoos), + Piercings: models.NewOptionalString(piercings), Aliases: &models.UpdateStrings{ Values: aliases, Mode: models.RelationshipUpdateModeSet, @@ -469,9 +473,7 @@ func Test_PerformerStore_UpdatePartial(t *testing.T) { Name: name, Disambiguation: disambiguation, Gender: &gender, - URL: url, - Twitter: twitter, - Instagram: instagram, + URLs: models.NewRelatedStrings(urls), Birthdate: &birthdate, Ethnicity: ethnicity, Country: country, @@ -516,6 +518,7 @@ func Test_PerformerStore_UpdatePartial(t *testing.T) { ID: performerIDs[performerIdxWithTwoTags], Name: getPerformerStringValue(performerIdxWithTwoTags, "Name"), Favorite: getPerformerBoolValue(performerIdxWithTwoTags), + URLs: models.NewRelatedStrings([]string{}), Aliases: models.NewRelatedStrings([]string{}), TagIDs: models.NewRelatedIDs([]int{}), StashIDs: models.NewRelatedStashIDs([]models.StashID{}), @@ -1290,7 +1293,14 @@ func TestPerformerQueryURL(t *testing.T) { verifyFn := func(g *models.Performer) { t.Helper() - verifyString(t, g.URL, urlCriterion) + + urls := g.URLs.List() + var url string + if len(urls) > 0 { + url = urls[0] + } + + verifyString(t, url, urlCriterion) } verifyPerformerQuery(t, filter, verifyFn) @@ -1318,6 +1328,12 @@ func verifyPerformerQuery(t *testing.T, filter models.PerformerFilterType, verif t.Helper() performers := queryPerformers(ctx, t, &filter, nil) + for _, performer := range performers { + if err := performer.LoadURLs(ctx, db.Performer); err != nil { + t.Errorf("Error loading movie relationships: %v", err) + } + } + // assume it should find at least one assert.Greater(t, len(performers), 0) diff --git a/pkg/sqlite/setup_test.go b/pkg/sqlite/setup_test.go index 736eae6a68b..ab5a46c613a 100644 --- a/pkg/sqlite/setup_test.go +++ b/pkg/sqlite/setup_test.go @@ -1374,6 +1374,15 @@ func getPerformerNullStringValue(index int, field string) string { return ret.String } +func getPerformerEmptyString(index int, field string) string { + v := getPrefixedNullStringValue("performer", index, field) + if !v.Valid { + return "" + } + + return v.String +} + func getPerformerBoolValue(index int) bool { index = index % 2 return index == 1 @@ -1479,17 +1488,19 @@ func createPerformers(ctx context.Context, n int, o int) error { Name: getPerformerStringValue(index, name), Disambiguation: getPerformerStringValue(index, "disambiguation"), Aliases: models.NewRelatedStrings(performerAliases(index)), - URL: getPerformerNullStringValue(i, urlField), - Favorite: getPerformerBoolValue(i), - Birthdate: getPerformerBirthdate(i), - DeathDate: getPerformerDeathDate(i), - Details: getPerformerStringValue(i, "Details"), - Ethnicity: getPerformerStringValue(i, "Ethnicity"), - PenisLength: getPerformerPenisLength(i), - Circumcised: getPerformerCircumcised(i), - Rating: getIntPtr(getRating(i)), - IgnoreAutoTag: getIgnoreAutoTag(i), - TagIDs: models.NewRelatedIDs(tids), + URLs: models.NewRelatedStrings([]string{ + getPerformerEmptyString(i, urlField), + }), + Favorite: getPerformerBoolValue(i), + Birthdate: getPerformerBirthdate(i), + DeathDate: getPerformerDeathDate(i), + Details: getPerformerStringValue(i, "Details"), + Ethnicity: getPerformerStringValue(i, "Ethnicity"), + PenisLength: getPerformerPenisLength(i), + Circumcised: getPerformerCircumcised(i), + Rating: getIntPtr(getRating(i)), + IgnoreAutoTag: getIgnoreAutoTag(i), + TagIDs: models.NewRelatedIDs(tids), } careerLength := getPerformerCareerLength(i) diff --git a/pkg/sqlite/tables.go b/pkg/sqlite/tables.go index d4425cfe3e9..ba86d3b7f6f 100644 --- a/pkg/sqlite/tables.go +++ b/pkg/sqlite/tables.go @@ -29,6 +29,7 @@ var ( scenesURLsJoinTable = goqu.T(scenesURLsTable) performersAliasesJoinTable = goqu.T(performersAliasesTable) + performersURLsJoinTable = goqu.T(performerURLsTable) performersTagsJoinTable = goqu.T(performersTagsTable) performersStashIDsJoinTable = goqu.T("performer_stash_ids") @@ -255,6 +256,14 @@ var ( stringColumn: performersAliasesJoinTable.Col(performerAliasColumn), } + performersURLsTableMgr = &orderedValueTable[string]{ + table: table{ + table: performersURLsJoinTable, + idColumn: performersURLsJoinTable.Col(performerIDColumn), + }, + valueColumn: performersURLsJoinTable.Col(performerURLColumn), + } + performersTagsTableMgr = &joinTable{ table: table{ table: performersTagsJoinTable, diff --git a/pkg/utils/url.go b/pkg/utils/url.go new file mode 100644 index 00000000000..e4d2df23735 --- /dev/null +++ b/pkg/utils/url.go @@ -0,0 +1,15 @@ +package utils + +import "regexp" + +// URLFromHandle adds the site URL to the input if the input is not already a URL +// siteURL must not end with a slash +func URLFromHandle(input string, siteURL string) string { + // if the input is already a URL, return it + re := regexp.MustCompile(`^https?://`) + if re.MatchString(input) { + return input + } + + return siteURL + "/" + input +} diff --git a/pkg/utils/url_test.go b/pkg/utils/url_test.go new file mode 100644 index 00000000000..3076314a7fe --- /dev/null +++ b/pkg/utils/url_test.go @@ -0,0 +1,47 @@ +package utils + +import "testing" + +func TestURLFromHandle(t *testing.T) { + type args struct { + input string + siteURL string + } + tests := []struct { + name string + args args + want string + }{ + { + name: "input is already a URL https", + args: args{ + input: "https://foo.com", + siteURL: "https://bar.com", + }, + want: "https://foo.com", + }, + { + name: "input is already a URL http", + args: args{ + input: "http://foo.com", + siteURL: "https://bar.com", + }, + want: "http://foo.com", + }, + { + name: "input is not a URL", + args: args{ + input: "foo", + siteURL: "https://foo.com", + }, + want: "https://foo.com/foo", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := URLFromHandle(tt.args.input, tt.args.siteURL); got != tt.want { + t.Errorf("URLFromHandle() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/ui/v2.5/graphql/data/performer-slim.graphql b/ui/v2.5/graphql/data/performer-slim.graphql index 0018c97009d..d9f5f423383 100644 --- a/ui/v2.5/graphql/data/performer-slim.graphql +++ b/ui/v2.5/graphql/data/performer-slim.graphql @@ -3,9 +3,7 @@ fragment SlimPerformerData on Performer { name disambiguation gender - url - twitter - instagram + urls image_path favorite ignore_auto_tag diff --git a/ui/v2.5/graphql/data/performer.graphql b/ui/v2.5/graphql/data/performer.graphql index cd43ca4a58a..91393f39e82 100644 --- a/ui/v2.5/graphql/data/performer.graphql +++ b/ui/v2.5/graphql/data/performer.graphql @@ -2,10 +2,8 @@ fragment PerformerData on Performer { id name disambiguation - url + urls gender - twitter - instagram birthdate ethnicity country diff --git a/ui/v2.5/graphql/data/scrapers.graphql b/ui/v2.5/graphql/data/scrapers.graphql index 087ba2efbc0..a68bb5c70bd 100644 --- a/ui/v2.5/graphql/data/scrapers.graphql +++ b/ui/v2.5/graphql/data/scrapers.graphql @@ -18,9 +18,7 @@ fragment ScrapedPerformerData on ScrapedPerformer { name disambiguation gender - url - twitter - instagram + urls birthdate ethnicity country @@ -50,9 +48,7 @@ fragment ScrapedScenePerformerData on ScrapedPerformer { name disambiguation gender - url - twitter - instagram + urls birthdate ethnicity country diff --git a/ui/v2.5/src/components/Performers/EditPerformersDialog.tsx b/ui/v2.5/src/components/Performers/EditPerformersDialog.tsx index 2df34bbd8a3..bd3f6acd1f9 100644 --- a/ui/v2.5/src/components/Performers/EditPerformersDialog.tsx +++ b/ui/v2.5/src/components/Performers/EditPerformersDialog.tsx @@ -36,9 +36,6 @@ interface IListOperationProps { const performerFields = [ "favorite", "disambiguation", - "url", - "instagram", - "twitter", "rating100", "gender", "birthdate", @@ -359,15 +356,6 @@ export const EditPerformersDialog: React.FC = ( {renderTextField("career_length", updateInput.career_length, (v) => setUpdateField({ career_length: v }) )} - {renderTextField("url", updateInput.url, (v) => - setUpdateField({ url: v }) - )} - {renderTextField("twitter", updateInput.twitter, (v) => - setUpdateField({ twitter: v }) - )} - {renderTextField("instagram", updateInput.instagram, (v) => - setUpdateField({ instagram: v }) - )} diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx index b0712f489df..85674e0231d 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx @@ -20,7 +20,6 @@ import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; import { useLightbox } from "src/hooks/Lightbox/hooks"; import { useToast } from "src/hooks/Toast"; import { ConfigurationContext } from "src/hooks/Config"; -import TextUtils from "src/utils/text"; import { RatingSystem } from "src/components/Shared/Rating/RatingSystem"; import { CompressedPerformerDetailsPanel, @@ -44,7 +43,7 @@ import { useRatingKeybinds } from "src/hooks/keybinds"; import { DetailImage } from "src/components/Shared/DetailImage"; import { useLoadStickyHeader } from "src/hooks/detailsPanel"; import { useScrollToTopOnMount } from "src/hooks/scrollToTop"; -import { ExternalLink } from "src/components/Shared/ExternalLink"; +import { ExternalLinksButton } from "src/components/Shared/ExternalLinksButton"; interface IProps { performer: GQL.PerformerDataFragment; @@ -90,6 +89,29 @@ const PerformerPage: React.FC = ({ performer, tabKey }) => { const [encodingImage, setEncodingImage] = useState(false); const loadStickyHeader = useLoadStickyHeader(); + // a list of urls to display in the performer details + const urls = useMemo(() => { + if (!performer.urls?.length) { + return []; + } + + const twitter = performer.urls.filter((u) => + u.match(/https?:\/\/(?:www\.)?twitter.com\//) + ); + const instagram = performer.urls.filter((u) => + u.match(/https?:\/\/(?:www\.)?instagram.com\//) + ); + const others = performer.urls.filter( + (u) => !twitter.includes(u) && !instagram.includes(u) + ); + + return [ + { icon: faLink, className: "", urls: others }, + { icon: faTwitter, className: "twitter", urls: twitter }, + { icon: faInstagram, className: "instagram", urls: instagram }, + ]; + }, [performer.urls]); + const activeImage = useMemo(() => { const performerImage = performer.image_path; if (isEditing) { @@ -478,11 +500,6 @@ const PerformerPage: React.FC = ({ performer, tabKey }) => { } function renderClickableIcons() { - /* Collect urls adding into details */ - /* This code can be removed once multple urls are supported for performers */ - const detailURLsRegex = /\[((?:http|www\.)[^\n\]]+)\]/gm; - let urls = performer?.details?.match(detailURLsRegex); - return ( - {performer.url && ( - - )} - {(urls ?? []).map((url, index) => ( - + {urls.map((url) => ( + ))} - {performer.twitter && ( - - )} - {performer.instagram && ( - - )} ); } diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx index dc38e53ea02..e7d7a8b41e3 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx @@ -14,7 +14,6 @@ import { Icon } from "src/components/Shared/Icon"; import { ImageInput } from "src/components/Shared/ImageInput"; import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; import { CountrySelect } from "src/components/Shared/CountrySelect"; -import { URLField } from "src/components/Shared/URLField"; import ImageUtils from "src/utils/image"; import { getStashIDs } from "src/utils/stashIds"; import { stashboxDisplayName } from "src/utils/stashbox"; @@ -45,6 +44,7 @@ import { yupInputEnum, yupDateString, yupUniqueAliases, + yupUniqueStringList, } from "src/utils/yup"; import { useTagsEdit } from "src/hooks/tagsEdit"; @@ -109,9 +109,7 @@ export const PerformerEditPanel: React.FC = ({ tattoos: yup.string().ensure(), piercings: yup.string().ensure(), career_length: yup.string().ensure(), - url: yup.string().ensure(), - twitter: yup.string().ensure(), - instagram: yup.string().ensure(), + urls: yupUniqueStringList(intl), details: yup.string().ensure(), tag_ids: yup.array(yup.string().required()).defined(), ignore_auto_tag: yup.boolean().defined(), @@ -139,9 +137,7 @@ export const PerformerEditPanel: React.FC = ({ tattoos: performer.tattoos ?? "", piercings: performer.piercings ?? "", career_length: performer.career_length ?? "", - url: performer.url ?? "", - twitter: performer.twitter ?? "", - instagram: performer.instagram ?? "", + urls: performer.urls ?? [], details: performer.details ?? "", tag_ids: (performer.tags ?? []).map((t) => t.id), ignore_auto_tag: performer.ignore_auto_tag ?? false, @@ -239,14 +235,8 @@ export const PerformerEditPanel: React.FC = ({ if (state.piercings) { formik.setFieldValue("piercings", state.piercings); } - if (state.url) { - formik.setFieldValue("url", state.url); - } - if (state.twitter) { - formik.setFieldValue("twitter", state.twitter); - } - if (state.instagram) { - formik.setFieldValue("instagram", state.instagram); + if (state.urls) { + formik.setFieldValue("urls", state.urls); } if (state.gender) { // gender is a string in the scraper data @@ -411,8 +401,7 @@ export const PerformerEditPanel: React.FC = ({ } } - async function onScrapePerformerURL() { - const { url } = formik.values; + async function onScrapePerformerURL(url: string) { if (!url) return; setIsLoading(true); try { @@ -613,6 +602,7 @@ export const PerformerEditPanel: React.FC = ({ renderDateField, renderStringListField, renderStashIDsField, + renderURLListField, } = formikUtils(intl, formik); function renderCountryField() { @@ -627,18 +617,6 @@ export const PerformerEditPanel: React.FC = ({ return renderField("country", title, control); } - function renderUrlField() { - const title = intl.formatMessage({ id: "url" }); - const control = ( - - ); - - return renderField("url", title, control); - } function renderTagsField() { const title = intl.formatMessage({ id: "tags" }); @@ -686,10 +664,8 @@ export const PerformerEditPanel: React.FC = ({ {renderInputField("career_length")} - {renderUrlField()} + {renderURLListField("urls", onScrapePerformerURL, urlScrapable)} - {renderInputField("twitter")} - {renderInputField("instagram")} {renderInputField("details", "textarea")} {renderTagsField()} diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScrapeDialog.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScrapeDialog.tsx index dbc4c5108e9..eb5f26a8333 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 { ScrapeDialogRow, ScrapedTextAreaRow, ScrapedCountryRow, + ScrapedStringListRow, } from "src/components/Shared/ScrapeDialog/ScrapeDialog"; import { Form } from "react-bootstrap"; import { @@ -23,6 +24,7 @@ import { import { IStashBox } from "./PerformerStashBoxModal"; import { ScrapeResult } from "src/components/Shared/ScrapeDialog/scrapeResult"; import { Tag } from "src/components/Tags/TagSelect"; +import { uniq } from "lodash-es"; import { useScrapedTags } from "src/components/Shared/ScrapeDialog/scrapedTags"; function renderScrapedGender( @@ -268,14 +270,13 @@ export const PerformerScrapeDialog: React.FC = ( const [piercings, setPiercings] = useState>( new ScrapeResult(props.performer.piercings, props.scraped.piercings) ); - const [url, setURL] = useState>( - new ScrapeResult(props.performer.url, props.scraped.url) - ); - const [twitter, setTwitter] = useState>( - new ScrapeResult(props.performer.twitter, props.scraped.twitter) - ); - const [instagram, setInstagram] = useState>( - new ScrapeResult(props.performer.instagram, props.scraped.instagram) + const [urls, setURLs] = useState>( + new ScrapeResult( + props.performer.urls, + props.scraped.urls + ? uniq((props.performer.urls ?? []).concat(props.scraped.urls ?? [])) + : undefined + ) ); const [gender, setGender] = useState>( new ScrapeResult( @@ -334,9 +335,7 @@ export const PerformerScrapeDialog: React.FC = ( careerLength, tattoos, piercings, - url, - twitter, - instagram, + urls, gender, image, tags, @@ -368,9 +367,7 @@ export const PerformerScrapeDialog: React.FC = ( career_length: careerLength.getNewValue(), tattoos: tattoos.getNewValue(), piercings: piercings.getNewValue(), - url: url.getNewValue(), - twitter: twitter.getNewValue(), - instagram: instagram.getNewValue(), + urls: urls.getNewValue(), gender: gender.getNewValue(), tags: tags.getNewValue(), images: newImage ? [newImage] : undefined, @@ -482,20 +479,10 @@ export const PerformerScrapeDialog: React.FC = ( result={piercings} onChange={(value) => setPiercings(value)} /> - setURL(value)} - /> - setTwitter(value)} - /> - setInstagram(value)} + setURLs(value)} /> = ({
) : (
    - {performers.map((p) => ( -
  • + {performers.map((p, i) => ( +
{truncate ? ( -
+
) : ( - {text} + {text} )}
); } + function maybeRenderURLListField( + name: string, + text: string[] | null | undefined, + truncate: boolean = true + ) { + if (!text) return; + + return ( +
+
+ {!create && ( + + )} + + : + +
+
+
    + {text.map((t, i) => ( +
  • + + {truncate ? : t} + +
  • + ))} +
+
+
+ ); + } + function maybeRenderImage() { if (!images.length) return; @@ -205,9 +243,7 @@ const PerformerModal: React.FC = ({ career_length: performer.career_length, tattoos: performer.tattoos, piercings: performer.piercings, - url: performer.url, - twitter: performer.twitter, - instagram: performer.instagram, + urls: performer.urls, image: images.length > imageIndex ? images[imageIndex] : undefined, details: performer.details, death_date: performer.death_date, @@ -290,9 +326,7 @@ const PerformerModal: React.FC = ({ {maybeRenderField("piercings", performer.piercings, false)} {maybeRenderField("weight", performer.weight, false)} {maybeRenderField("details", performer.details)} - {maybeRenderField("url", performer.url)} - {maybeRenderField("twitter", performer.twitter)} - {maybeRenderField("instagram", performer.instagram)} + {maybeRenderURLListField("urls", performer.urls)} {maybeRenderStashBoxLink()}
{maybeRenderImage()} diff --git a/ui/v2.5/src/components/Tagger/constants.ts b/ui/v2.5/src/components/Tagger/constants.ts index cbfacc76d54..cecbdeb1b81 100644 --- a/ui/v2.5/src/components/Tagger/constants.ts +++ b/ui/v2.5/src/components/Tagger/constants.ts @@ -78,10 +78,8 @@ export const PERFORMER_FIELDS = [ "tattoos", "piercings", "career_length", - "url", - "twitter", - "instagram", + "urls", "details", ]; -export const STUDIO_FIELDS = ["name", "image", "url", "parent_studio"]; +export const STUDIO_FIELDS = ["name", "image", "urls", "parent_studio"]; diff --git a/ui/v2.5/src/components/Tagger/styles.scss b/ui/v2.5/src/components/Tagger/styles.scss index 5a5bc3904ba..5fcff5baf02 100644 --- a/ui/v2.5/src/components/Tagger/styles.scss +++ b/ui/v2.5/src/components/Tagger/styles.scss @@ -165,6 +165,12 @@ width: 12px; } } + + &-value ul { + font-size: 0.8em; + list-style-type: none; + padding-inline-start: 0; + } } .PerformerTagger { diff --git a/ui/v2.5/src/core/performers.ts b/ui/v2.5/src/core/performers.ts index 39a5daa88e8..83a62eac359 100644 --- a/ui/v2.5/src/core/performers.ts +++ b/ui/v2.5/src/core/performers.ts @@ -90,7 +90,6 @@ export const scrapedPerformerToCreateInput = ( const input: GQL.PerformerCreateInput = { name: toCreate.name ?? "", - url: toCreate.url, gender: stringToGender(toCreate.gender), birthdate: toCreate.birthdate, ethnicity: toCreate.ethnicity, @@ -103,8 +102,7 @@ export const scrapedPerformerToCreateInput = ( tattoos: toCreate.tattoos, piercings: toCreate.piercings, alias_list: aliases, - twitter: toCreate.twitter, - instagram: toCreate.instagram, + urls: toCreate.urls, tag_ids: filterData((toCreate.tags ?? []).map((t) => t.stored_id)), image: (toCreate.images ?? []).length > 0 diff --git a/ui/v2.5/src/models/list-filter/criteria/is-missing.ts b/ui/v2.5/src/models/list-filter/criteria/is-missing.ts index d3ecd2e8e38..15272d7563f 100644 --- a/ui/v2.5/src/models/list-filter/criteria/is-missing.ts +++ b/ui/v2.5/src/models/list-filter/criteria/is-missing.ts @@ -50,8 +50,6 @@ export const PerformerIsMissingCriterionOption = new IsMissingCriterionOption( "is_missing", [ "url", - "twitter", - "instagram", "ethnicity", "country", "hair_color", diff --git a/ui/v2.5/src/utils/text.ts b/ui/v2.5/src/utils/text.ts index b604f1aa84d..627822f21c2 100644 --- a/ui/v2.5/src/utils/text.ts +++ b/ui/v2.5/src/utils/text.ts @@ -369,9 +369,6 @@ const resolution = (width: number, height: number) => { } }; -const twitterURL = new URL("https://www.twitter.com"); -const instagramURL = new URL("https://www.instagram.com"); - const sanitiseURL = (url?: string, siteURL?: URL) => { if (!url) { return url; @@ -485,8 +482,6 @@ const TextUtils = { resolution, sanitiseURL, domainFromURL, - twitterURL, - instagramURL, formatDate, formatDateTime, secondsAsTimeString, From b3d35dfae448dbed5f19640ac9495d5865656ec7 Mon Sep 17 00:00:00 2001 From: bob123491234 <54259225+bob123491234@users.noreply.github.com> Date: Tue, 18 Jun 2024 00:55:20 -0500 Subject: [PATCH 019/103] Add tags to studios (#4858) * Fix makeTagFilter mode * Remove studio_tags filter criterion This is handled by studios_filter. The support for this still needs to be added in the UI, so I have removed the criterion options in the short-term. --------- Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com> --- graphql/schema/types/filters.graphql | 7 ++ graphql/schema/types/studio.graphql | 3 + graphql/schema/types/tag.graphql | 1 + internal/api/resolver_model_studio.go | 14 +++ internal/api/resolver_model_tag.go | 12 ++ internal/api/resolver_mutation_studio.go | 10 ++ internal/manager/task_export.go | 13 +++ internal/manager/task_import.go | 3 + pkg/models/jsonschema/studio.go | 1 + pkg/models/mocks/StudioReaderWriter.go | 65 +++++++++++ pkg/models/mocks/TagReaderWriter.go | 23 ++++ pkg/models/model_studio.go | 12 ++ pkg/models/repository_studio.go | 3 + pkg/models/repository_tag.go | 1 + pkg/models/studio.go | 6 + pkg/models/tag.go | 2 + pkg/sqlite/database.go | 2 +- pkg/sqlite/migrations/63_studio_tags.up.sql | 9 ++ pkg/sqlite/setup_test.go | 23 ++++ pkg/sqlite/studio.go | 40 +++++++ pkg/sqlite/studio_filter.go | 27 +++++ pkg/sqlite/studio_test.go | 104 ++++++++++++++++++ pkg/sqlite/tables.go | 9 ++ pkg/sqlite/tag.go | 16 +++ pkg/sqlite/tag_filter.go | 12 ++ pkg/sqlite/tag_test.go | 54 +++++++++ pkg/studio/export_test.go | 2 + pkg/studio/import.go | 76 +++++++++++++ pkg/studio/import_test.go | 103 ++++++++++++++++- pkg/studio/query.go | 13 +++ ui/v2.5/graphql/data/studio-slim.graphql | 4 + ui/v2.5/graphql/data/studio.graphql | 3 + ui/v2.5/graphql/data/tag.graphql | 2 + .../components/Shared/PopoverCountButton.tsx | 11 +- ui/v2.5/src/components/Shared/TagLink.tsx | 11 +- ui/v2.5/src/components/Studios/StudioCard.tsx | 27 ++++- .../StudioDetails/StudioDetailsPanel.tsx | 31 +++++- .../Studios/StudioDetails/StudioEditPanel.tsx | 13 +++ ui/v2.5/src/components/Tags/TagCard.tsx | 14 +++ .../src/components/Tags/TagDetails/Tag.tsx | 22 ++++ .../Tags/TagDetails/TagStudiosPanel.tsx | 17 +++ ui/v2.5/src/locales/en-GB.json | 2 + .../src/models/list-filter/criteria/tags.ts | 7 ++ ui/v2.5/src/models/list-filter/galleries.ts | 2 + ui/v2.5/src/models/list-filter/images.ts | 2 + ui/v2.5/src/models/list-filter/movies.ts | 2 + ui/v2.5/src/models/list-filter/scenes.ts | 2 + ui/v2.5/src/models/list-filter/studios.ts | 5 +- ui/v2.5/src/models/list-filter/tags.ts | 5 + ui/v2.5/src/models/list-filter/types.ts | 2 + ui/v2.5/src/utils/navigation.ts | 7 +- 51 files changed, 844 insertions(+), 13 deletions(-) create mode 100644 pkg/sqlite/migrations/63_studio_tags.up.sql create mode 100644 ui/v2.5/src/components/Tags/TagDetails/TagStudiosPanel.tsx diff --git a/graphql/schema/types/filters.graphql b/graphql/schema/types/filters.graphql index 1df9d2fba1b..98b790d4fe3 100644 --- a/graphql/schema/types/filters.graphql +++ b/graphql/schema/types/filters.graphql @@ -362,6 +362,8 @@ input StudioFilterType { parents: MultiCriterionInput "Filter by StashID" stash_id_endpoint: StashIDCriterionInput + "Filter to only include studios with these tags" + tags: HierarchicalMultiCriterionInput "Filter to only include studios missing this property" is_missing: String # rating expressed as 1-100 @@ -374,6 +376,8 @@ input StudioFilterType { image_count: IntCriterionInput "Filter by gallery count" gallery_count: IntCriterionInput + "Filter by tag count" + tag_count: IntCriterionInput "Filter by url" url: StringCriterionInput "Filter by studio aliases" @@ -498,6 +502,9 @@ input TagFilterType { "Filter by number of performers with this tag" performer_count: IntCriterionInput + "Filter by number of studios with this tag" + studio_count: IntCriterionInput + "Filter by number of movies with this tag" movie_count: IntCriterionInput diff --git a/graphql/schema/types/studio.graphql b/graphql/schema/types/studio.graphql index ff4eb5011c6..f90183ed09b 100644 --- a/graphql/schema/types/studio.graphql +++ b/graphql/schema/types/studio.graphql @@ -5,6 +5,7 @@ type Studio { parent_studio: Studio child_studios: [Studio!]! aliases: [String!]! + tags: [Tag!]! ignore_auto_tag: Boolean! image_path: String # Resolver @@ -35,6 +36,7 @@ input StudioCreateInput { favorite: Boolean details: String aliases: [String!] + tag_ids: [ID!] ignore_auto_tag: Boolean } @@ -51,6 +53,7 @@ input StudioUpdateInput { favorite: Boolean details: String aliases: [String!] + tag_ids: [ID!] ignore_auto_tag: Boolean } diff --git a/graphql/schema/types/tag.graphql b/graphql/schema/types/tag.graphql index 35229c5cb81..6263b64a8d8 100644 --- a/graphql/schema/types/tag.graphql +++ b/graphql/schema/types/tag.graphql @@ -13,6 +13,7 @@ type Tag { image_count(depth: Int): Int! # Resolver gallery_count(depth: Int): Int! # Resolver performer_count(depth: Int): Int! # Resolver + studio_count(depth: Int): Int! # Resolver movie_count(depth: Int): Int! # Resolver parents: [Tag!]! children: [Tag!]! diff --git a/internal/api/resolver_model_studio.go b/internal/api/resolver_model_studio.go index f7bc3a00df2..011ab343e0f 100644 --- a/internal/api/resolver_model_studio.go +++ b/internal/api/resolver_model_studio.go @@ -40,6 +40,20 @@ func (r *studioResolver) Aliases(ctx context.Context, obj *models.Studio) ([]str return obj.Aliases.List(), nil } +func (r *studioResolver) Tags(ctx context.Context, obj *models.Studio) (ret []*models.Tag, err error) { + if !obj.TagIDs.Loaded() { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { + return obj.LoadTagIDs(ctx, r.repository.Studio) + }); err != nil { + return nil, err + } + } + + var errs []error + ret, errs = loaders.From(ctx).TagByID.LoadAll(obj.TagIDs.List()) + return ret, firstError(errs) +} + func (r *studioResolver) SceneCount(ctx context.Context, obj *models.Studio, depth *int) (ret int, err error) { if err := r.withReadTxn(ctx, func(ctx context.Context) error { ret, err = scene.CountByStudioID(ctx, r.repository.Scene, obj.ID, depth) diff --git a/internal/api/resolver_model_tag.go b/internal/api/resolver_model_tag.go index 7c32667d24f..a9930fb23db 100644 --- a/internal/api/resolver_model_tag.go +++ b/internal/api/resolver_model_tag.go @@ -11,6 +11,7 @@ import ( "github.com/stashapp/stash/pkg/movie" "github.com/stashapp/stash/pkg/performer" "github.com/stashapp/stash/pkg/scene" + "github.com/stashapp/stash/pkg/studio" ) func (r *tagResolver) Parents(ctx context.Context, obj *models.Tag) (ret []*models.Tag, err error) { @@ -108,6 +109,17 @@ func (r *tagResolver) PerformerCount(ctx context.Context, obj *models.Tag, depth return ret, nil } +func (r *tagResolver) StudioCount(ctx context.Context, obj *models.Tag, depth *int) (ret int, err error) { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { + ret, err = studio.CountByTagID(ctx, r.repository.Studio, obj.ID, depth) + return err + }); err != nil { + return 0, err + } + + return ret, nil +} + func (r *tagResolver) MovieCount(ctx context.Context, obj *models.Tag, depth *int) (ret int, err error) { if err := r.withReadTxn(ctx, func(ctx context.Context) error { ret, err = movie.CountByTagID(ctx, r.repository.Movie, obj.ID, depth) diff --git a/internal/api/resolver_mutation_studio.go b/internal/api/resolver_mutation_studio.go index 05d84a97940..a33e5d9b676 100644 --- a/internal/api/resolver_mutation_studio.go +++ b/internal/api/resolver_mutation_studio.go @@ -48,6 +48,11 @@ func (r *mutationResolver) StudioCreate(ctx context.Context, input models.Studio return nil, fmt.Errorf("converting parent id: %w", err) } + newStudio.TagIDs, err = translator.relatedIds(input.TagIds) + if err != nil { + return nil, fmt.Errorf("converting tag ids: %w", err) + } + // Process the base 64 encoded image string var imageData []byte if input.Image != nil { @@ -114,6 +119,11 @@ func (r *mutationResolver) StudioUpdate(ctx context.Context, input models.Studio return nil, fmt.Errorf("converting parent id: %w", err) } + updatedStudio.TagIDs, err = translator.updateIds(input.TagIds, "tag_ids") + if err != nil { + return nil, fmt.Errorf("converting tag ids: %w", err) + } + // Process the base 64 encoded image string var imageData []byte imageIncluded := translator.hasField("image") diff --git a/internal/manager/task_export.go b/internal/manager/task_export.go index 2daac200815..0a294e70e4a 100644 --- a/internal/manager/task_export.go +++ b/internal/manager/task_export.go @@ -982,6 +982,7 @@ func (t *ExportTask) ExportStudios(ctx context.Context, workers int) { func (t *ExportTask) exportStudio(ctx context.Context, wg *sync.WaitGroup, jobChan <-chan *models.Studio) { defer wg.Done() + r := t.repository studioReader := t.repository.Studio for s := range jobChan { @@ -992,6 +993,18 @@ func (t *ExportTask) exportStudio(ctx context.Context, wg *sync.WaitGroup, jobCh continue } + tags, err := r.Tag.FindByStudioID(ctx, s.ID) + if err != nil { + logger.Errorf("[studios] <%s> error getting studio tags: %s", s.Name, err.Error()) + continue + } + + newStudioJSON.Tags = tag.GetNames(tags) + + if t.includeDependencies { + t.tags.IDs = sliceutil.AppendUniques(t.tags.IDs, tag.GetIDs(tags)) + } + fn := newStudioJSON.Filename() if err := t.json.saveStudio(fn, newStudioJSON); err != nil { diff --git a/internal/manager/task_import.go b/internal/manager/task_import.go index c9d5b54ba72..47fbf0cd1d5 100644 --- a/internal/manager/task_import.go +++ b/internal/manager/task_import.go @@ -292,8 +292,11 @@ func (t *ImportTask) ImportStudios(ctx context.Context) { } func (t *ImportTask) importStudio(ctx context.Context, studioJSON *jsonschema.Studio, pendingParent map[string][]*jsonschema.Studio) error { + r := t.repository + importer := &studio.Importer{ ReaderWriter: t.repository.Studio, + TagWriter: r.Tag, Input: *studioJSON, MissingRefBehaviour: t.MissingRefBehaviour, } diff --git a/pkg/models/jsonschema/studio.go b/pkg/models/jsonschema/studio.go index 84842fa14e3..80ed97d9294 100644 --- a/pkg/models/jsonschema/studio.go +++ b/pkg/models/jsonschema/studio.go @@ -22,6 +22,7 @@ type Studio struct { Details string `json:"details,omitempty"` Aliases []string `json:"aliases,omitempty"` StashIDs []models.StashID `json:"stash_ids,omitempty"` + Tags []string `json:"tags,omitempty"` IgnoreAutoTag bool `json:"ignore_auto_tag,omitempty"` } diff --git a/pkg/models/mocks/StudioReaderWriter.go b/pkg/models/mocks/StudioReaderWriter.go index c46e45d4c24..d4932ca71da 100644 --- a/pkg/models/mocks/StudioReaderWriter.go +++ b/pkg/models/mocks/StudioReaderWriter.go @@ -58,6 +58,27 @@ func (_m *StudioReaderWriter) Count(ctx context.Context) (int, error) { return r0, r1 } +// CountByTagID provides a mock function with given fields: ctx, tagID +func (_m *StudioReaderWriter) CountByTagID(ctx context.Context, tagID int) (int, error) { + ret := _m.Called(ctx, tagID) + + var r0 int + if rf, ok := ret.Get(0).(func(context.Context, int) int); ok { + r0 = rf(ctx, tagID) + } else { + r0 = ret.Get(0).(int) + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { + r1 = rf(ctx, tagID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // Create provides a mock function with given fields: ctx, newStudio func (_m *StudioReaderWriter) Create(ctx context.Context, newStudio *models.Studio) error { ret := _m.Called(ctx, newStudio) @@ -316,6 +337,29 @@ func (_m *StudioReaderWriter) GetStashIDs(ctx context.Context, relatedID int) ([ return r0, r1 } +// GetTagIDs provides a mock function with given fields: ctx, relatedID +func (_m *StudioReaderWriter) GetTagIDs(ctx context.Context, relatedID int) ([]int, error) { + ret := _m.Called(ctx, relatedID) + + var r0 []int + if rf, ok := ret.Get(0).(func(context.Context, int) []int); ok { + r0 = rf(ctx, relatedID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]int) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { + r1 = rf(ctx, relatedID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // HasImage provides a mock function with given fields: ctx, studioID func (_m *StudioReaderWriter) HasImage(ctx context.Context, studioID int) (bool, error) { ret := _m.Called(ctx, studioID) @@ -367,6 +411,27 @@ func (_m *StudioReaderWriter) Query(ctx context.Context, studioFilter *models.St return r0, r1, r2 } +// QueryCount provides a mock function with given fields: ctx, studioFilter, findFilter +func (_m *StudioReaderWriter) QueryCount(ctx context.Context, studioFilter *models.StudioFilterType, findFilter *models.FindFilterType) (int, error) { + ret := _m.Called(ctx, studioFilter, findFilter) + + var r0 int + if rf, ok := ret.Get(0).(func(context.Context, *models.StudioFilterType, *models.FindFilterType) int); ok { + r0 = rf(ctx, studioFilter, findFilter) + } else { + r0 = ret.Get(0).(int) + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, *models.StudioFilterType, *models.FindFilterType) error); ok { + r1 = rf(ctx, studioFilter, findFilter) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // QueryForAutoTag provides a mock function with given fields: ctx, words func (_m *StudioReaderWriter) QueryForAutoTag(ctx context.Context, words []string) ([]*models.Studio, error) { ret := _m.Called(ctx, words) diff --git a/pkg/models/mocks/TagReaderWriter.go b/pkg/models/mocks/TagReaderWriter.go index d18f6a66b6c..c3dfe7bd255 100644 --- a/pkg/models/mocks/TagReaderWriter.go +++ b/pkg/models/mocks/TagReaderWriter.go @@ -427,6 +427,29 @@ func (_m *TagReaderWriter) FindBySceneMarkerID(ctx context.Context, sceneMarkerI return r0, r1 } +// FindByStudioID provides a mock function with given fields: ctx, studioID +func (_m *TagReaderWriter) FindByStudioID(ctx context.Context, studioID int) ([]*models.Tag, error) { + ret := _m.Called(ctx, studioID) + + var r0 []*models.Tag + if rf, ok := ret.Get(0).(func(context.Context, int) []*models.Tag); ok { + r0 = rf(ctx, studioID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*models.Tag) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { + r1 = rf(ctx, studioID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // FindMany provides a mock function with given fields: ctx, ids func (_m *TagReaderWriter) FindMany(ctx context.Context, ids []int) ([]*models.Tag, error) { ret := _m.Called(ctx, ids) diff --git a/pkg/models/model_studio.go b/pkg/models/model_studio.go index e6e8b7b205c..0f4a09bc202 100644 --- a/pkg/models/model_studio.go +++ b/pkg/models/model_studio.go @@ -19,6 +19,7 @@ type Studio struct { IgnoreAutoTag bool `json:"ignore_auto_tag"` Aliases RelatedStrings `json:"aliases"` + TagIDs RelatedIDs `json:"tag_ids"` StashIDs RelatedStashIDs `json:"stash_ids"` } @@ -45,6 +46,7 @@ type StudioPartial struct { IgnoreAutoTag OptionalBool Aliases *UpdateStrings + TagIDs *UpdateIDs StashIDs *UpdateStashIDs } @@ -61,6 +63,12 @@ func (s *Studio) LoadAliases(ctx context.Context, l AliasLoader) error { }) } +func (s *Studio) LoadTagIDs(ctx context.Context, l TagIDLoader) error { + return s.TagIDs.load(func() ([]int, error) { + return l.GetTagIDs(ctx, s.ID) + }) +} + func (s *Studio) LoadStashIDs(ctx context.Context, l StashIDLoader) error { return s.StashIDs.load(func() ([]StashID, error) { return l.GetStashIDs(ctx, s.ID) @@ -72,6 +80,10 @@ func (s *Studio) LoadRelationships(ctx context.Context, l PerformerReader) error return err } + if err := s.LoadTagIDs(ctx, l); err != nil { + return err + } + if err := s.LoadStashIDs(ctx, l); err != nil { return err } diff --git a/pkg/models/repository_studio.go b/pkg/models/repository_studio.go index 272bf8fed23..a2b9202f303 100644 --- a/pkg/models/repository_studio.go +++ b/pkg/models/repository_studio.go @@ -22,6 +22,7 @@ type StudioFinder interface { // StudioQueryer provides methods to query studios. type StudioQueryer interface { Query(ctx context.Context, studioFilter *StudioFilterType, findFilter *FindFilterType) ([]*Studio, int, error) + QueryCount(ctx context.Context, studioFilter *StudioFilterType, findFilter *FindFilterType) (int, error) } type StudioAutoTagQueryer interface { @@ -36,6 +37,7 @@ type StudioAutoTagQueryer interface { // StudioCounter provides methods to count studios. type StudioCounter interface { Count(ctx context.Context) (int, error) + CountByTagID(ctx context.Context, tagID int) (int, error) } // StudioCreator provides methods to create studios. @@ -74,6 +76,7 @@ type StudioReader interface { AliasLoader StashIDLoader + TagIDLoader All(ctx context.Context) ([]*Studio, error) GetImage(ctx context.Context, studioID int) ([]byte, error) diff --git a/pkg/models/repository_tag.go b/pkg/models/repository_tag.go index 287aeb211b8..00f35abc43f 100644 --- a/pkg/models/repository_tag.go +++ b/pkg/models/repository_tag.go @@ -22,6 +22,7 @@ type TagFinder interface { FindByPerformerID(ctx context.Context, performerID int) ([]*Tag, error) FindByMovieID(ctx context.Context, movieID int) ([]*Tag, error) FindBySceneMarkerID(ctx context.Context, sceneMarkerID int) ([]*Tag, error) + FindByStudioID(ctx context.Context, studioID int) ([]*Tag, error) FindByName(ctx context.Context, name string, nocase bool) (*Tag, error) FindByNames(ctx context.Context, names []string, nocase bool) ([]*Tag, error) } diff --git a/pkg/models/studio.go b/pkg/models/studio.go index 0f8b5d15300..d5575b7ad3b 100644 --- a/pkg/models/studio.go +++ b/pkg/models/studio.go @@ -14,6 +14,10 @@ type StudioFilterType struct { IsMissing *string `json:"is_missing"` // Filter by rating expressed as 1-100 Rating100 *IntCriterionInput `json:"rating100"` + // Filter to only include studios with these tags + Tags *HierarchicalMultiCriterionInput `json:"tags"` + // Filter by tag count + TagCount *IntCriterionInput `json:"tag_count"` // Filter by favorite Favorite *bool `json:"favorite"` // Filter by scene count @@ -53,6 +57,7 @@ type StudioCreateInput struct { Favorite *bool `json:"favorite"` Details *string `json:"details"` Aliases []string `json:"aliases"` + TagIds []string `json:"tag_ids"` IgnoreAutoTag *bool `json:"ignore_auto_tag"` } @@ -68,5 +73,6 @@ type StudioUpdateInput struct { Favorite *bool `json:"favorite"` Details *string `json:"details"` Aliases []string `json:"aliases"` + TagIds []string `json:"tag_ids"` IgnoreAutoTag *bool `json:"ignore_auto_tag"` } diff --git a/pkg/models/tag.go b/pkg/models/tag.go index 7ee0705a432..cc32a6ce25c 100644 --- a/pkg/models/tag.go +++ b/pkg/models/tag.go @@ -20,6 +20,8 @@ type TagFilterType struct { GalleryCount *IntCriterionInput `json:"gallery_count"` // Filter by number of performers with this tag PerformerCount *IntCriterionInput `json:"performer_count"` + // Filter by number of studios with this tag + StudioCount *IntCriterionInput `json:"studio_count"` // Filter by number of movies with this tag MovieCount *IntCriterionInput `json:"movie_count"` // Filter by number of markers with this tag diff --git a/pkg/sqlite/database.go b/pkg/sqlite/database.go index cf502392f34..6436efee873 100644 --- a/pkg/sqlite/database.go +++ b/pkg/sqlite/database.go @@ -30,7 +30,7 @@ const ( dbConnTimeout = 30 ) -var appSchemaVersion uint = 62 +var appSchemaVersion uint = 63 //go:embed migrations/*.sql var migrationsBox embed.FS diff --git a/pkg/sqlite/migrations/63_studio_tags.up.sql b/pkg/sqlite/migrations/63_studio_tags.up.sql new file mode 100644 index 00000000000..ea652f18c1e --- /dev/null +++ b/pkg/sqlite/migrations/63_studio_tags.up.sql @@ -0,0 +1,9 @@ +CREATE TABLE `studios_tags` ( + `studio_id` integer NOT NULL, + `tag_id` integer NOT NULL, + foreign key(`studio_id`) references `studios`(`id`) on delete CASCADE, + foreign key(`tag_id`) references `tags`(`id`) on delete CASCADE, + PRIMARY KEY(`studio_id`, `tag_id`) +); + +CREATE INDEX `index_studios_tags_on_tag_id` on `studios_tags` (`tag_id`); \ No newline at end of file diff --git a/pkg/sqlite/setup_test.go b/pkg/sqlite/setup_test.go index ab5a46c613a..4a6e3edb48d 100644 --- a/pkg/sqlite/setup_test.go +++ b/pkg/sqlite/setup_test.go @@ -207,6 +207,9 @@ const ( tagIdxWithPerformer tagIdx1WithPerformer tagIdx2WithPerformer + tagIdxWithStudio + tagIdx1WithStudio + tagIdx2WithStudio tagIdxWithGallery tagIdx1WithGallery tagIdx2WithGallery @@ -245,6 +248,10 @@ const ( studioIdxWithScenePerformer studioIdxWithImagePerformer studioIdxWithGalleryPerformer + studioIdxWithTag + studioIdx2WithTag + studioIdxWithTwoTags + studioIdxWithParentTag studioIdxWithGrandChild studioIdxWithParentAndChild studioIdxWithGrandParent @@ -510,6 +517,15 @@ var ( } ) +var ( + studioTags = linkMap{ + studioIdxWithTag: {tagIdxWithStudio}, + studioIdx2WithTag: {tagIdx2WithStudio}, + studioIdxWithTwoTags: {tagIdx1WithStudio, tagIdx2WithStudio}, + studioIdxWithParentTag: {tagIdxWithParentAndChild}, + } +) + var ( performerTags = linkMap{ performerIdxWithTag: {tagIdxWithPerformer}, @@ -1566,6 +1582,11 @@ func getTagPerformerCount(id int) int { return len(performerTags.reverseLookup(idx)) } +func getTagStudioCount(id int) int { + idx := indexFromID(tagIDs, id) + return len(studioTags.reverseLookup(idx)) +} + func getTagParentCount(id int) int { if id == tagIDs[tagIdxWithParentTag] || id == tagIDs[tagIdxWithGrandParent] || id == tagIDs[tagIdxWithParentAndChild] { return 1 @@ -1681,11 +1702,13 @@ func createStudios(ctx context.Context, n int, o int) error { // studios [ i ] and [ n + o - i - 1 ] should have similar names with only the Name!=NaMe part different name = getStudioStringValue(index, name) + tids := indexesToIDs(tagIDs, studioTags[i]) studio := models.Studio{ Name: name, URL: getStudioStringValue(index, urlField), Favorite: getStudioBoolValue(index), IgnoreAutoTag: getIgnoreAutoTag(i), + TagIDs: models.NewRelatedIDs(tids), } // only add aliases for some scenes if i == studioIdxWithMovie || i%5 == 0 { diff --git a/pkg/sqlite/studio.go b/pkg/sqlite/studio.go index ac6a4a4d938..95edf4173e2 100644 --- a/pkg/sqlite/studio.go +++ b/pkg/sqlite/studio.go @@ -25,6 +25,7 @@ const ( studioParentIDColumn = "parent_id" studioNameColumn = "name" studioImageBlobColumn = "image_blob" + studiosTagsTable = "studios_tags" ) type studioRow struct { @@ -94,6 +95,7 @@ type studioRepositoryType struct { repository stashIDs stashIDRepository + tags joinRepository scenes repository images repository @@ -124,11 +126,21 @@ var ( tableName: galleryTable, idColumn: studioIDColumn, }, + tags: joinRepository{ + repository: repository{ + tableName: studiosTagsTable, + idColumn: studioIDColumn, + }, + fkColumn: tagIDColumn, + foreignTable: tagTable, + orderBy: "tags.name ASC", + }, } ) type StudioStore struct { blobJoinQueryBuilder + tagRelationshipStore tableMgr *table } @@ -139,6 +151,11 @@ func NewStudioStore(blobStore *BlobStore) *StudioStore { blobStore: blobStore, joinTable: studioTable, }, + tagRelationshipStore: tagRelationshipStore{ + idRelationshipStore: idRelationshipStore{ + joinTable: studiosTagsTableMgr, + }, + }, tableMgr: studioTableMgr, } @@ -173,6 +190,10 @@ func (qb *StudioStore) Create(ctx context.Context, newObject *models.Studio) err } } + if err := qb.tagRelationshipStore.createRelationships(ctx, id, newObject.TagIDs); err != nil { + return err + } + if newObject.StashIDs.Loaded() { if err := studiosStashIDsTableMgr.insertJoins(ctx, id, newObject.StashIDs.List()); err != nil { return err @@ -213,6 +234,10 @@ func (qb *StudioStore) UpdatePartial(ctx context.Context, input models.StudioPar } } + if err := qb.tagRelationshipStore.modifyRelationships(ctx, input.ID, input.TagIDs); err != nil { + return nil, err + } + if input.StashIDs != nil { if err := studiosStashIDsTableMgr.modifyJoins(ctx, input.ID, input.StashIDs.StashIDs, input.StashIDs.Mode); err != nil { return nil, err @@ -237,6 +262,10 @@ func (qb *StudioStore) Update(ctx context.Context, updatedObject *models.Studio) } } + if err := qb.tagRelationshipStore.replaceRelationships(ctx, updatedObject.ID, updatedObject.TagIDs); err != nil { + return err + } + if updatedObject.StashIDs.Loaded() { if err := studiosStashIDsTableMgr.replaceJoins(ctx, updatedObject.ID, updatedObject.StashIDs.List()); err != nil { return err @@ -538,6 +567,15 @@ func (qb *StudioStore) Query(ctx context.Context, studioFilter *models.StudioFil return studios, countResult, nil } +func (qb *StudioStore) QueryCount(ctx context.Context, studioFilter *models.StudioFilterType, findFilter *models.FindFilterType) (int, error) { + query, err := qb.makeQuery(ctx, studioFilter, findFilter) + if err != nil { + return 0, err + } + + return query.executeCount(ctx) +} + var studioSortOptions = sortOptions{ "child_count", "created_at", @@ -569,6 +607,8 @@ func (qb *StudioStore) getStudioSort(findFilter *models.FindFilterType) (string, sortQuery := "" switch sort { + case "tag_count": + sortQuery += getCountSort(studioTable, studiosTagsTable, studioIDColumn, direction) case "scenes_count": sortQuery += getCountSort(studioTable, sceneTable, studioIDColumn, direction) case "images_count": diff --git a/pkg/sqlite/studio_filter.go b/pkg/sqlite/studio_filter.go index 45745c4717d..040fc185818 100644 --- a/pkg/sqlite/studio_filter.go +++ b/pkg/sqlite/studio_filter.go @@ -74,11 +74,13 @@ func (qb *studioFilterHandler) criterionHandler() criterionHandler { }, qb.isMissingCriterionHandler(studioFilter.IsMissing), + qb.tagCountCriterionHandler(studioFilter.TagCount), qb.sceneCountCriterionHandler(studioFilter.SceneCount), qb.imageCountCriterionHandler(studioFilter.ImageCount), qb.galleryCountCriterionHandler(studioFilter.GalleryCount), qb.parentCriterionHandler(studioFilter.Parents), qb.aliasCriterionHandler(studioFilter.Aliases), + qb.tagsCriterionHandler(studioFilter.Tags), qb.childCountCriterionHandler(studioFilter.ChildCount), ×tampCriterionHandler{studioFilter.CreatedAt, studioTable + ".created_at", nil}, ×tampCriterionHandler{studioFilter.UpdatedAt, studioTable + ".updated_at", nil}, @@ -161,6 +163,16 @@ func (qb *studioFilterHandler) galleryCountCriterionHandler(galleryCount *models } } +func (qb *studioFilterHandler) tagCountCriterionHandler(tagCount *models.IntCriterionInput) criterionHandlerFunc { + h := countCriterionHandlerBuilder{ + primaryTable: studioTable, + joinTable: studiosTagsTable, + primaryFK: studioIDColumn, + } + + return h.handler(tagCount) +} + func (qb *studioFilterHandler) parentCriterionHandler(parents *models.MultiCriterionInput) criterionHandlerFunc { addJoinsFunc := func(f *filterBuilder) { f.addLeftJoin("studios", "parent_studio", "parent_studio.id = studios.parent_id") @@ -200,3 +212,18 @@ func (qb *studioFilterHandler) childCountCriterionHandler(childCount *models.Int } } } + +func (qb *studioFilterHandler) tagsCriterionHandler(tags *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { + h := joinedHierarchicalMultiCriterionHandlerBuilder{ + primaryTable: studioTable, + foreignTable: tagTable, + foreignFK: "tag_id", + + relationsTable: "tags_relations", + joinTable: studiosTagsTable, + joinAs: "studio_tag", + primaryFK: studioIDColumn, + } + + return h.handler(tags) +} diff --git a/pkg/sqlite/studio_test.go b/pkg/sqlite/studio_test.go index c75c2a61f43..627129f0d1e 100644 --- a/pkg/sqlite/studio_test.go +++ b/pkg/sqlite/studio_test.go @@ -704,6 +704,110 @@ func TestStudioQueryRating(t *testing.T) { verifyStudiosRating(t, ratingCriterion) } +func queryStudios(ctx context.Context, t *testing.T, studioFilter *models.StudioFilterType, findFilter *models.FindFilterType) []*models.Studio { + t.Helper() + studios, _, err := db.Studio.Query(ctx, studioFilter, findFilter) + if err != nil { + t.Errorf("Error querying studio: %s", err.Error()) + } + + return studios +} + +func TestStudioQueryTags(t *testing.T) { + withTxn(func(ctx context.Context) error { + tagCriterion := models.HierarchicalMultiCriterionInput{ + Value: []string{ + strconv.Itoa(tagIDs[tagIdxWithStudio]), + strconv.Itoa(tagIDs[tagIdx1WithStudio]), + }, + Modifier: models.CriterionModifierIncludes, + } + + studioFilter := models.StudioFilterType{ + Tags: &tagCriterion, + } + + // ensure ids are correct + studios := queryStudios(ctx, t, &studioFilter, nil) + assert.Len(t, studios, 2) + for _, studio := range studios { + assert.True(t, studio.ID == studioIDs[studioIdxWithTag] || studio.ID == studioIDs[studioIdxWithTwoTags]) + } + + tagCriterion = models.HierarchicalMultiCriterionInput{ + Value: []string{ + strconv.Itoa(tagIDs[tagIdx1WithStudio]), + strconv.Itoa(tagIDs[tagIdx2WithStudio]), + }, + Modifier: models.CriterionModifierIncludesAll, + } + + studios = queryStudios(ctx, t, &studioFilter, nil) + + assert.Len(t, studios, 1) + assert.Equal(t, sceneIDs[studioIdxWithTwoTags], studios[0].ID) + + tagCriterion = models.HierarchicalMultiCriterionInput{ + Value: []string{ + strconv.Itoa(tagIDs[tagIdx1WithStudio]), + }, + Modifier: models.CriterionModifierExcludes, + } + + q := getSceneStringValue(studioIdxWithTwoTags, titleField) + findFilter := models.FindFilterType{ + Q: &q, + } + + studios = queryStudios(ctx, t, &studioFilter, &findFilter) + assert.Len(t, studios, 0) + + return nil + }) +} + +func TestStudioQueryTagCount(t *testing.T) { + const tagCount = 1 + tagCountCriterion := models.IntCriterionInput{ + Value: tagCount, + Modifier: models.CriterionModifierEquals, + } + + verifyStudiosTagCount(t, tagCountCriterion) + + tagCountCriterion.Modifier = models.CriterionModifierNotEquals + verifyStudiosTagCount(t, tagCountCriterion) + + tagCountCriterion.Modifier = models.CriterionModifierGreaterThan + verifyStudiosTagCount(t, tagCountCriterion) + + tagCountCriterion.Modifier = models.CriterionModifierLessThan + verifyStudiosTagCount(t, tagCountCriterion) +} + +func verifyStudiosTagCount(t *testing.T, tagCountCriterion models.IntCriterionInput) { + withTxn(func(ctx context.Context) error { + sqb := db.Studio + studioFilter := models.StudioFilterType{ + TagCount: &tagCountCriterion, + } + + studios := queryStudios(ctx, t, &studioFilter, nil) + assert.Greater(t, len(studios), 0) + + for _, studio := range studios { + ids, err := sqb.GetTagIDs(ctx, studio.ID) + if err != nil { + return err + } + verifyInt(t, len(ids), tagCountCriterion) + } + + return nil + }) +} + func verifyStudioQuery(t *testing.T, filter models.StudioFilterType, verifyFn func(ctx context.Context, s *models.Studio)) { withTxn(func(ctx context.Context) error { t.Helper() diff --git a/pkg/sqlite/tables.go b/pkg/sqlite/tables.go index ba86d3b7f6f..2f500639e36 100644 --- a/pkg/sqlite/tables.go +++ b/pkg/sqlite/tables.go @@ -34,6 +34,7 @@ var ( performersStashIDsJoinTable = goqu.T("performer_stash_ids") studiosAliasesJoinTable = goqu.T(studioAliasesTable) + studiosTagsJoinTable = goqu.T(studiosTagsTable) studiosStashIDsJoinTable = goqu.T("studio_stash_ids") moviesURLsJoinTable = goqu.T(movieURLsTable) @@ -294,6 +295,14 @@ var ( stringColumn: studiosAliasesJoinTable.Col(studioAliasColumn), } + studiosTagsTableMgr = &joinTable{ + table: table{ + table: studiosTagsJoinTable, + idColumn: studiosTagsJoinTable.Col(studioIDColumn), + }, + fkColumn: studiosTagsJoinTable.Col(tagIDColumn), + } + studiosStashIDsTableMgr = &stashIDTable{ table: table{ table: studiosStashIDsJoinTable, diff --git a/pkg/sqlite/tag.go b/pkg/sqlite/tag.go index a4bf3793aa1..c6494f38b91 100644 --- a/pkg/sqlite/tag.go +++ b/pkg/sqlite/tag.go @@ -448,6 +448,18 @@ func (qb *TagStore) FindBySceneMarkerID(ctx context.Context, sceneMarkerID int) return qb.queryTags(ctx, query, args) } +func (qb *TagStore) FindByStudioID(ctx context.Context, studioID int) ([]*models.Tag, error) { + query := ` + SELECT tags.* FROM tags + LEFT JOIN studios_tags as studios_join on studios_join.tag_id = tags.id + WHERE studios_join.studio_id = ? + GROUP BY tags.id + ` + query += qb.getDefaultTagSort() + args := []interface{}{studioID} + return qb.queryTags(ctx, query, args) +} + func (qb *TagStore) FindByName(ctx context.Context, name string, nocase bool) (*models.Tag, error) { // query := "SELECT * FROM tags WHERE name = ?" // if nocase { @@ -628,6 +640,7 @@ var tagSortOptions = sortOptions{ "id", "images_count", "movies_count", + "studios_count", "name", "performers_count", "random", @@ -668,6 +681,8 @@ func (qb *TagStore) getTagSort(query *queryBuilder, findFilter *models.FindFilte sortQuery += getCountSort(tagTable, galleriesTagsTable, tagIDColumn, direction) case "performers_count": sortQuery += getCountSort(tagTable, performersTagsTable, tagIDColumn, direction) + case "studios_count": + sortQuery += getCountSort(tagTable, studiosTagsTable, tagIDColumn, direction) case "movies_count": sortQuery += getCountSort(tagTable, moviesTagsTable, tagIDColumn, direction) default: @@ -767,6 +782,7 @@ func (qb *TagStore) Merge(ctx context.Context, source []int, destination int) er galleriesTagsTable: galleryIDColumn, imagesTagsTable: imageIDColumn, "performers_tags": "performer_id", + "studios_tags": "studio_id", } args = append(args, destination) diff --git a/pkg/sqlite/tag_filter.go b/pkg/sqlite/tag_filter.go index 776a49fc4f3..5bae18c0010 100644 --- a/pkg/sqlite/tag_filter.go +++ b/pkg/sqlite/tag_filter.go @@ -66,6 +66,7 @@ func (qb *tagFilterHandler) criterionHandler() criterionHandler { qb.imageCountCriterionHandler(tagFilter.ImageCount), qb.galleryCountCriterionHandler(tagFilter.GalleryCount), qb.performerCountCriterionHandler(tagFilter.PerformerCount), + qb.studioCountCriterionHandler(tagFilter.StudioCount), qb.movieCountCriterionHandler(tagFilter.MovieCount), qb.markerCountCriterionHandler(tagFilter.MarkerCount), qb.parentsCriterionHandler(tagFilter.Parents), @@ -175,6 +176,17 @@ func (qb *tagFilterHandler) performerCountCriterionHandler(performerCount *model } } +func (qb *tagFilterHandler) studioCountCriterionHandler(studioCount *models.IntCriterionInput) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if studioCount != nil { + f.addLeftJoin("studios_tags", "", "studios_tags.tag_id = tags.id") + clause, args := getIntCriterionWhereClause("count(distinct studios_tags.studio_id)", *studioCount) + + f.addHaving(clause, args...) + } + } +} + func (qb *tagFilterHandler) movieCountCriterionHandler(movieCount *models.IntCriterionInput) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if movieCount != nil { diff --git a/pkg/sqlite/tag_test.go b/pkg/sqlite/tag_test.go index d71316413e4..099f8b91221 100644 --- a/pkg/sqlite/tag_test.go +++ b/pkg/sqlite/tag_test.go @@ -230,6 +230,10 @@ func TestTagQuerySort(t *testing.T) { tags = queryTags(ctx, t, sqb, nil, findFilter) assert.Equal(tagIDs[tagIdx2WithPerformer], tags[0].ID) + sortBy = "studios_count" + tags = queryTags(ctx, t, sqb, nil, findFilter) + assert.Equal(tagIDs[tagIdx2WithStudio], tags[0].ID) + sortBy = "movies_count" tags = queryTags(ctx, t, sqb, nil, findFilter) assert.Equal(tagIDs[tagIdx1WithMovie], tags[0].ID) @@ -569,6 +573,45 @@ func verifyTagPerformerCount(t *testing.T, imageCountCriterion models.IntCriteri }) } +func TestTagQueryStudioCount(t *testing.T) { + countCriterion := models.IntCriterionInput{ + Value: 1, + Modifier: models.CriterionModifierEquals, + } + + verifyTagStudioCount(t, countCriterion) + + countCriterion.Modifier = models.CriterionModifierNotEquals + verifyTagStudioCount(t, countCriterion) + + countCriterion.Modifier = models.CriterionModifierLessThan + verifyTagStudioCount(t, countCriterion) + + countCriterion.Value = 0 + countCriterion.Modifier = models.CriterionModifierGreaterThan + verifyTagStudioCount(t, countCriterion) +} + +func verifyTagStudioCount(t *testing.T, imageCountCriterion models.IntCriterionInput) { + withTxn(func(ctx context.Context) error { + qb := db.Tag + tagFilter := models.TagFilterType{ + StudioCount: &imageCountCriterion, + } + + tags, _, err := qb.Query(ctx, &tagFilter, nil) + if err != nil { + t.Errorf("Error querying tag: %s", err.Error()) + } + + for _, tag := range tags { + verifyInt(t, getTagStudioCount(tag.ID), imageCountCriterion) + } + + return nil + }) +} + func TestTagQueryParentCount(t *testing.T) { countCriterion := models.IntCriterionInput{ Value: 1, @@ -882,6 +925,9 @@ func TestTagMerge(t *testing.T) { tagIdxWithPerformer, tagIdx1WithPerformer, tagIdx2WithPerformer, + tagIdxWithStudio, + tagIdx1WithStudio, + tagIdx2WithStudio, tagIdxWithGallery, tagIdx1WithGallery, tagIdx2WithGallery, @@ -970,6 +1016,14 @@ func TestTagMerge(t *testing.T) { assert.Contains(performerTagIDs, destID) + // ensure studio points to new tag + studioTagIDs, err := db.Studio.GetTagIDs(ctx, studioIDs[studioIdxWithTwoTags]) + if err != nil { + return err + } + + assert.Contains(studioTagIDs, destID) + return nil }); err != nil { t.Error(err.Error()) diff --git a/pkg/studio/export_test.go b/pkg/studio/export_test.go index da6da8ad4f8..0e42141ec37 100644 --- a/pkg/studio/export_test.go +++ b/pkg/studio/export_test.go @@ -68,6 +68,7 @@ func createFullStudio(id int, parentID int) models.Studio { Rating: &rating, IgnoreAutoTag: autoTagIgnored, Aliases: models.NewRelatedStrings(aliases), + TagIDs: models.NewRelatedIDs([]int{}), StashIDs: models.NewRelatedStashIDs(stashIDs), } @@ -84,6 +85,7 @@ func createEmptyStudio(id int) models.Studio { CreatedAt: createTime, UpdatedAt: updateTime, Aliases: models.NewRelatedStrings([]string{}), + TagIDs: models.NewRelatedIDs([]int{}), StashIDs: models.NewRelatedStashIDs([]models.StashID{}), } } diff --git a/pkg/studio/import.go b/pkg/studio/import.go index bfee4133fb3..d880650787d 100644 --- a/pkg/studio/import.go +++ b/pkg/studio/import.go @@ -4,9 +4,11 @@ import ( "context" "errors" "fmt" + "strings" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models/jsonschema" + "github.com/stashapp/stash/pkg/sliceutil" "github.com/stashapp/stash/pkg/utils" ) @@ -19,6 +21,7 @@ var ErrParentStudioNotExist = errors.New("parent studio does not exist") type Importer struct { ReaderWriter ImporterReaderWriter + TagWriter models.TagFinderCreator Input jsonschema.Studio MissingRefBehaviour models.ImportMissingRefEnum @@ -34,6 +37,10 @@ func (i *Importer) PreImport(ctx context.Context) error { return err } + if err := i.populateTags(ctx); err != nil { + return err + } + var err error if len(i.Input.Image) > 0 { i.imageData, err = utils.ProcessBase64Image(i.Input.Image) @@ -45,6 +52,74 @@ func (i *Importer) PreImport(ctx context.Context) error { return nil } +func (i *Importer) populateTags(ctx context.Context) error { + if len(i.Input.Tags) > 0 { + + tags, err := importTags(ctx, i.TagWriter, i.Input.Tags, i.MissingRefBehaviour) + if err != nil { + return err + } + + for _, p := range tags { + i.studio.TagIDs.Add(p.ID) + } + } + + return nil +} + +func importTags(ctx context.Context, tagWriter models.TagFinderCreator, names []string, missingRefBehaviour models.ImportMissingRefEnum) ([]*models.Tag, error) { + tags, err := tagWriter.FindByNames(ctx, names, false) + if err != nil { + return nil, err + } + + var pluckedNames []string + for _, tag := range tags { + pluckedNames = append(pluckedNames, tag.Name) + } + + missingTags := sliceutil.Filter(names, func(name string) bool { + return !sliceutil.Contains(pluckedNames, name) + }) + + if len(missingTags) > 0 { + if missingRefBehaviour == models.ImportMissingRefEnumFail { + return nil, fmt.Errorf("tags [%s] not found", strings.Join(missingTags, ", ")) + } + + if missingRefBehaviour == models.ImportMissingRefEnumCreate { + createdTags, err := createTags(ctx, tagWriter, missingTags) + if err != nil { + return nil, fmt.Errorf("error creating tags: %v", err) + } + + tags = append(tags, createdTags...) + } + + // ignore if MissingRefBehaviour set to Ignore + } + + return tags, nil +} + +func createTags(ctx context.Context, tagWriter models.TagFinderCreator, names []string) ([]*models.Tag, error) { + var ret []*models.Tag + for _, name := range names { + newTag := models.NewTag() + newTag.Name = name + + err := tagWriter.Create(ctx, &newTag) + if err != nil { + return nil, err + } + + ret = append(ret, &newTag) + } + + return ret, nil +} + func (i *Importer) populateParentStudio(ctx context.Context) error { if i.Input.ParentStudio != "" { studio, err := i.ReaderWriter.FindByName(ctx, i.Input.ParentStudio, false) @@ -149,6 +224,7 @@ func studioJSONtoStudio(studioJSON jsonschema.Studio) models.Studio { CreatedAt: studioJSON.CreatedAt.GetTime(), UpdatedAt: studioJSON.UpdatedAt.GetTime(), + TagIDs: models.NewRelatedIDs([]int{}), StashIDs: models.NewRelatedStashIDs(studioJSON.StashIDs), } diff --git a/pkg/studio/import_test.go b/pkg/studio/import_test.go index e89256371cf..882b8ca5682 100644 --- a/pkg/studio/import_test.go +++ b/pkg/studio/import_test.go @@ -16,13 +16,19 @@ const invalidImage = "aW1hZ2VCeXRlcw&&" const ( studioNameErr = "studioNameErr" - existingStudioName = "existingTagName" + existingStudioName = "existingStudioName" existingStudioID = 100 + existingTagID = 105 + errTagsID = 106 existingParentStudioName = "existingParentStudioName" existingParentStudioErr = "existingParentStudioErr" missingParentStudioName = "existingParentStudioName" + + existingTagName = "existingTagName" + existingTagErr = "existingTagErr" + missingTagName = "missingTagName" ) var testCtx = context.Background() @@ -67,6 +73,97 @@ func TestImporterPreImport(t *testing.T) { assert.Equal(t, expectedStudio, i.studio) } +func TestImporterPreImportWithTag(t *testing.T) { + db := mocks.NewDatabase() + + i := Importer{ + ReaderWriter: db.Studio, + TagWriter: db.Tag, + MissingRefBehaviour: models.ImportMissingRefEnumFail, + Input: jsonschema.Studio{ + Tags: []string{ + existingTagName, + }, + }, + } + + db.Tag.On("FindByNames", testCtx, []string{existingTagName}, false).Return([]*models.Tag{ + { + ID: existingTagID, + Name: existingTagName, + }, + }, nil).Once() + db.Tag.On("FindByNames", testCtx, []string{existingTagErr}, false).Return(nil, errors.New("FindByNames error")).Once() + + err := i.PreImport(testCtx) + assert.Nil(t, err) + assert.Equal(t, existingTagID, i.studio.TagIDs.List()[0]) + + i.Input.Tags = []string{existingTagErr} + err = i.PreImport(testCtx) + assert.NotNil(t, err) + + db.AssertExpectations(t) +} + +func TestImporterPreImportWithMissingTag(t *testing.T) { + db := mocks.NewDatabase() + + i := Importer{ + ReaderWriter: db.Studio, + TagWriter: db.Tag, + Input: jsonschema.Studio{ + Tags: []string{ + missingTagName, + }, + }, + MissingRefBehaviour: models.ImportMissingRefEnumFail, + } + + db.Tag.On("FindByNames", testCtx, []string{missingTagName}, false).Return(nil, nil).Times(3) + db.Tag.On("Create", testCtx, mock.AnythingOfType("*models.Tag")).Run(func(args mock.Arguments) { + t := args.Get(1).(*models.Tag) + t.ID = existingTagID + }).Return(nil) + + err := i.PreImport(testCtx) + assert.NotNil(t, err) + + i.MissingRefBehaviour = models.ImportMissingRefEnumIgnore + err = i.PreImport(testCtx) + assert.Nil(t, err) + + i.MissingRefBehaviour = models.ImportMissingRefEnumCreate + err = i.PreImport(testCtx) + assert.Nil(t, err) + assert.Equal(t, existingTagID, i.studio.TagIDs.List()[0]) + + db.AssertExpectations(t) +} + +func TestImporterPreImportWithMissingTagCreateErr(t *testing.T) { + db := mocks.NewDatabase() + + i := Importer{ + ReaderWriter: db.Studio, + TagWriter: db.Tag, + Input: jsonschema.Studio{ + Tags: []string{ + missingTagName, + }, + }, + MissingRefBehaviour: models.ImportMissingRefEnumCreate, + } + + db.Tag.On("FindByNames", testCtx, []string{missingTagName}, false).Return(nil, nil).Once() + db.Tag.On("Create", testCtx, mock.AnythingOfType("*models.Tag")).Return(errors.New("Create error")) + + err := i.PreImport(testCtx) + assert.NotNil(t, err) + + db.AssertExpectations(t) +} + func TestImporterPreImportWithParent(t *testing.T) { db := mocks.NewDatabase() @@ -156,6 +253,7 @@ func TestImporterPostImport(t *testing.T) { i := Importer{ ReaderWriter: db.Studio, + TagWriter: db.Tag, Input: jsonschema.Studio{ Aliases: []string{"alias"}, }, @@ -181,6 +279,7 @@ func TestImporterFindExistingID(t *testing.T) { i := Importer{ ReaderWriter: db.Studio, + TagWriter: db.Tag, Input: jsonschema.Studio{ Name: studioName, }, @@ -223,6 +322,7 @@ func TestCreate(t *testing.T) { i := Importer{ ReaderWriter: db.Studio, + TagWriter: db.Tag, studio: studio, } @@ -258,6 +358,7 @@ func TestUpdate(t *testing.T) { i := Importer{ ReaderWriter: db.Studio, + TagWriter: db.Tag, studio: studio, } diff --git a/pkg/studio/query.go b/pkg/studio/query.go index b20cec33109..97e8e2c1bbe 100644 --- a/pkg/studio/query.go +++ b/pkg/studio/query.go @@ -2,6 +2,7 @@ package studio import ( "context" + "strconv" "github.com/stashapp/stash/pkg/models" ) @@ -53,3 +54,15 @@ func ByAlias(ctx context.Context, qb models.StudioQueryer, alias string) (*model return nil, nil } + +func CountByTagID(ctx context.Context, qb models.StudioQueryer, id int, depth *int) (int, error) { + filter := &models.StudioFilterType{ + Tags: &models.HierarchicalMultiCriterionInput{ + Value: []string{strconv.Itoa(id)}, + Modifier: models.CriterionModifierIncludes, + Depth: depth, + }, + } + + return qb.QueryCount(ctx, filter, nil) +} diff --git a/ui/v2.5/graphql/data/studio-slim.graphql b/ui/v2.5/graphql/data/studio-slim.graphql index c3751319427..406a2ffa70a 100644 --- a/ui/v2.5/graphql/data/studio-slim.graphql +++ b/ui/v2.5/graphql/data/studio-slim.graphql @@ -12,4 +12,8 @@ fragment SlimStudioData on Studio { details rating100 aliases + tags { + id + name + } } diff --git a/ui/v2.5/graphql/data/studio.graphql b/ui/v2.5/graphql/data/studio.graphql index 576faea230d..afd254d2294 100644 --- a/ui/v2.5/graphql/data/studio.graphql +++ b/ui/v2.5/graphql/data/studio.graphql @@ -33,6 +33,9 @@ fragment StudioData on Studio { rating100 favorite aliases + tags { + ...SlimTagData + } } fragment SelectStudioData on Studio { diff --git a/ui/v2.5/graphql/data/tag.graphql b/ui/v2.5/graphql/data/tag.graphql index d473bf8c6d4..695bb5de6de 100644 --- a/ui/v2.5/graphql/data/tag.graphql +++ b/ui/v2.5/graphql/data/tag.graphql @@ -16,6 +16,8 @@ fragment TagData on Tag { gallery_count_all: gallery_count(depth: -1) performer_count performer_count_all: performer_count(depth: -1) + studio_count + studio_count_all: studio_count(depth: -1) movie_count movie_count_all: movie_count(depth: -1) diff --git a/ui/v2.5/src/components/Shared/PopoverCountButton.tsx b/ui/v2.5/src/components/Shared/PopoverCountButton.tsx index dc30cfa1f8b..c455145fc3c 100644 --- a/ui/v2.5/src/components/Shared/PopoverCountButton.tsx +++ b/ui/v2.5/src/components/Shared/PopoverCountButton.tsx @@ -4,6 +4,7 @@ import { faImages, faPlayCircle, faUser, + faVideo, faMapMarkerAlt, } from "@fortawesome/free-solid-svg-icons"; import React, { useMemo } from "react"; @@ -20,7 +21,8 @@ type PopoverLinkType = | "gallery" | "marker" | "movie" - | "performer"; + | "performer" + | "studio"; interface IProps { className?: string; @@ -54,6 +56,8 @@ export const PopoverCountButton: React.FC = ({ return faFilm; case "performer": return faUser; + case "studio": + return faVideo; } } @@ -89,6 +93,11 @@ export const PopoverCountButton: React.FC = ({ one: "performer", other: "performers", }; + case "studio": + return { + one: "studio", + other: "studios", + }; } } diff --git a/ui/v2.5/src/components/Shared/TagLink.tsx b/ui/v2.5/src/components/Shared/TagLink.tsx index 9c2ed1cb340..f2fe7c49ff6 100644 --- a/ui/v2.5/src/components/Shared/TagLink.tsx +++ b/ui/v2.5/src/components/Shared/TagLink.tsx @@ -191,7 +191,14 @@ export const GalleryLink: React.FC = ({ interface ITagLinkProps { tag: INamedObject; - linkType?: "scene" | "gallery" | "image" | "details" | "performer" | "movie"; + linkType?: + | "scene" + | "gallery" + | "image" + | "details" + | "performer" + | "movie" + | "studio"; className?: string; hoverPlacement?: Placement; showHierarchyIcon?: boolean; @@ -212,6 +219,8 @@ export const TagLink: React.FC = ({ return NavUtils.makeTagScenesUrl(tag); case "performer": return NavUtils.makeTagPerformersUrl(tag); + case "studio": + return NavUtils.makeTagStudiosUrl(tag); case "gallery": return NavUtils.makeTagGalleriesUrl(tag); case "image": diff --git a/ui/v2.5/src/components/Studios/StudioCard.tsx b/ui/v2.5/src/components/Studios/StudioCard.tsx index 007635cce10..1c1e5e6eeac 100644 --- a/ui/v2.5/src/components/Studios/StudioCard.tsx +++ b/ui/v2.5/src/components/Studios/StudioCard.tsx @@ -6,13 +6,17 @@ import { GridCard, calculateCardWidth, } from "src/components/Shared/GridCard/GridCard"; -import { ButtonGroup } from "react-bootstrap"; +import { HoverPopover } from "../Shared/HoverPopover"; +import { Icon } from "../Shared/Icon"; +import { TagLink } from "../Shared/TagLink"; +import { Button, ButtonGroup } from "react-bootstrap"; import { FormattedMessage } from "react-intl"; import { PopoverCountButton } from "../Shared/PopoverCountButton"; import { RatingBanner } from "../Shared/RatingBanner"; import ScreenUtils from "src/utils/screen"; import { FavoriteIcon } from "../Shared/FavoriteIcon"; import { useStudioUpdate } from "src/core/StashService"; +import { faTag } from "@fortawesome/free-solid-svg-icons"; interface IProps { studio: GQL.StudioDataFragment; @@ -164,13 +168,31 @@ export const StudioCard: React.FC = ({ ); } + function maybeRenderTagPopoverButton() { + if (studio.tags.length <= 0) return; + + const popoverContent = studio.tags.map((tag) => ( + + )); + + return ( + + + + ); + } + function maybeRenderPopoverButtonGroup() { if ( studio.scene_count || studio.image_count || studio.gallery_count || studio.movie_count || - studio.performer_count + studio.performer_count || + studio.tags.length > 0 ) { return ( <> @@ -181,6 +203,7 @@ export const StudioCard: React.FC = ({ {maybeRenderImagesPopoverButton()} {maybeRenderGalleriesPopoverButton()} {maybeRenderPerformersPopoverButton()} + {maybeRenderTagPopoverButton()} ); diff --git a/ui/v2.5/src/components/Studios/StudioDetails/StudioDetailsPanel.tsx b/ui/v2.5/src/components/Studios/StudioDetails/StudioDetailsPanel.tsx index a6c5126cb6b..5bf877b11f2 100644 --- a/ui/v2.5/src/components/Studios/StudioDetails/StudioDetailsPanel.tsx +++ b/ui/v2.5/src/components/Studios/StudioDetails/StudioDetailsPanel.tsx @@ -1,4 +1,5 @@ import React from "react"; +import { TagLink } from "src/components/Shared/TagLink"; import * as GQL from "src/core/generated-graphql"; import { DetailItem } from "src/components/Shared/DetailItem"; import { StashIDPill } from "src/components/Shared/StashID"; @@ -15,6 +16,19 @@ export const StudioDetailsPanel: React.FC = ({ collapsed, fullWidth, }) => { + function renderTagsField() { + if (!studio.tags.length) { + return; + } + return ( +
    + {(studio.tags ?? []).map((tag) => ( + + ))} +
+ ); + } + function renderStashIDs() { if (!studio.stash_ids?.length) { return; @@ -36,11 +50,18 @@ export const StudioDetailsPanel: React.FC = ({ function maybeRenderExtraDetails() { if (!collapsed) { return ( - + <> + + + ); } } diff --git a/ui/v2.5/src/components/Studios/StudioDetails/StudioEditPanel.tsx b/ui/v2.5/src/components/Studios/StudioDetails/StudioEditPanel.tsx index dc0c03f36d9..1089e5ffe74 100644 --- a/ui/v2.5/src/components/Studios/StudioDetails/StudioEditPanel.tsx +++ b/ui/v2.5/src/components/Studios/StudioDetails/StudioEditPanel.tsx @@ -16,6 +16,7 @@ import { handleUnsavedChanges } from "src/utils/navigation"; import { formikUtils } from "src/utils/form"; import { yupFormikValidate, yupUniqueAliases } from "src/utils/yup"; import { Studio, StudioSelect } from "../StudioSelect"; +import { useTagsEdit } from "src/hooks/tagsEdit"; interface IStudioEditPanel { studio: Partial; @@ -50,6 +51,7 @@ export const StudioEditPanel: React.FC = ({ details: yup.string().ensure(), parent_id: yup.string().required().nullable(), aliases: yupUniqueAliases(intl, "name"), + tag_ids: yup.array(yup.string().required()).defined(), ignore_auto_tag: yup.boolean().defined(), stash_ids: yup.mixed().defined(), image: yup.string().nullable().optional(), @@ -62,6 +64,7 @@ export const StudioEditPanel: React.FC = ({ details: studio.details ?? "", parent_id: studio.parent_studio?.id ?? null, aliases: studio.aliases ?? [], + tag_ids: (studio.tags ?? []).map((t) => t.id), ignore_auto_tag: studio.ignore_auto_tag ?? false, stash_ids: getStashIDs(studio.stash_ids), }; @@ -75,6 +78,10 @@ export const StudioEditPanel: React.FC = ({ onSubmit: (values) => onSave(schema.cast(values)), }); + const { tagsControl } = useTagsEdit(studio.tags, (ids) => + formik.setFieldValue("tag_ids", ids) + ); + function onSetParentStudio(item: Studio | null) { setParentStudio(item); formik.setFieldValue("parent_id", item ? item.id : null); @@ -157,6 +164,11 @@ export const StudioEditPanel: React.FC = ({ return renderField("parent_id", title, control); } + function renderTagsField() { + const title = intl.formatMessage({ id: "tags" }); + return renderField("tag_ids", title, tagsControl()); + } + if (isLoading) return ; return ( @@ -178,6 +190,7 @@ export const StudioEditPanel: React.FC = ({ {renderInputField("url")} {renderInputField("details", "textarea")} {renderParentStudioField()} + {renderTagsField()} {renderStashIDsField("stash_ids", "studios")}
{renderInputField("ignore_auto_tag", "checkbox")} diff --git a/ui/v2.5/src/components/Tags/TagCard.tsx b/ui/v2.5/src/components/Tags/TagCard.tsx index 51444f99949..424f8c5f518 100644 --- a/ui/v2.5/src/components/Tags/TagCard.tsx +++ b/ui/v2.5/src/components/Tags/TagCard.tsx @@ -223,6 +223,19 @@ export const TagCard: React.FC = ({ ); } + function maybeRenderStudiosPopoverButton() { + if (!tag.studio_count) return; + + return ( + + ); + } + function maybeRenderMoviesPopoverButton() { if (!tag.movie_count) return; @@ -248,6 +261,7 @@ export const TagCard: React.FC = ({ {maybeRenderMoviesPopoverButton()} {maybeRenderSceneMarkersPopoverButton()} {maybeRenderPerformersPopoverButton()} + {maybeRenderStudiosPopoverButton()} ); diff --git a/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx b/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx index aa10275b6cb..c80473db8fd 100644 --- a/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx +++ b/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx @@ -26,6 +26,7 @@ import { TagScenesPanel } from "./TagScenesPanel"; import { TagMarkersPanel } from "./TagMarkersPanel"; import { TagImagesPanel } from "./TagImagesPanel"; import { TagPerformersPanel } from "./TagPerformersPanel"; +import { TagStudiosPanel } from "./TagStudiosPanel"; import { TagGalleriesPanel } from "./TagGalleriesPanel"; import { CompressedTagDetailsPanel, TagDetailsPanel } from "./TagDetailsPanel"; import { TagEditPanel } from "./TagEditPanel"; @@ -61,6 +62,7 @@ const validTabs = [ "movies", "markers", "performers", + "studios", ] as const; type TabKey = (typeof validTabs)[number]; @@ -109,6 +111,8 @@ const TagPage: React.FC = ({ tag, tabKey }) => { (showAllCounts ? tag.scene_marker_count_all : tag.scene_marker_count) ?? 0; const performerCount = (showAllCounts ? tag.performer_count_all : tag.performer_count) ?? 0; + const studioCount = + (showAllCounts ? tag.studio_count_all : tag.studio_count) ?? 0; const populatedDefaultTab = useMemo(() => { let ret: TabKey = "scenes"; @@ -123,6 +127,8 @@ const TagPage: React.FC = ({ tag, tabKey }) => { ret = "markers"; } else if (performerCount != 0) { ret = "performers"; + } else if (studioCount != 0) { + ret = "studios"; } } @@ -133,6 +139,7 @@ const TagPage: React.FC = ({ tag, tabKey }) => { galleryCount, sceneMarkerCount, performerCount, + studioCount, movieCount, ]); @@ -521,6 +528,21 @@ const TagPage: React.FC = ({ tag, tabKey }) => { > + + {intl.formatMessage({ id: "studios" })} + + + } + > + + ); diff --git a/ui/v2.5/src/components/Tags/TagDetails/TagStudiosPanel.tsx b/ui/v2.5/src/components/Tags/TagDetails/TagStudiosPanel.tsx new file mode 100644 index 00000000000..ef63cdd5248 --- /dev/null +++ b/ui/v2.5/src/components/Tags/TagDetails/TagStudiosPanel.tsx @@ -0,0 +1,17 @@ +import React from "react"; +import * as GQL from "src/core/generated-graphql"; +import { useTagFilterHook } from "src/core/tags"; +import { StudioList } from "src/components/Studios/StudioList"; + +interface ITagStudiosPanel { + active: boolean; + tag: GQL.TagDataFragment; +} + +export const TagStudiosPanel: React.FC = ({ + active, + tag, +}) => { + const filterHook = useTagFilterHook(tag); + return ; +}; diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index 61daff120a9..805eb9b50a9 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -1376,6 +1376,7 @@ "status": "Status: {statusText}", "studio": "Studio", "studio_and_parent": "Studio & Parent", + "studio_count": "Studio Count", "studio_depth": "Levels (empty for all)", "studio_tagger": { "add_new_studios": "Add New Studios", @@ -1415,6 +1416,7 @@ "update_studios": "Update Studios", "updating_untagged_studios_description": "Updating untagged studios will try to match any studios that lack a stashid and update the metadata." }, + "studio_tags": "Studio Tags", "studios": "Studios", "sub_tag_count": "Sub-Tag Count", "sub_tag_of": "Sub-tag of {parent}", diff --git a/ui/v2.5/src/models/list-filter/criteria/tags.ts b/ui/v2.5/src/models/list-filter/criteria/tags.ts index e85392b6500..0dd5d54e319 100644 --- a/ui/v2.5/src/models/list-filter/criteria/tags.ts +++ b/ui/v2.5/src/models/list-filter/criteria/tags.ts @@ -55,6 +55,13 @@ export const PerformerTagsCriterionOption = new BaseTagsCriterionOption( withoutEqualsModifierOptions ); +// TODO - this requires using a nested studios_filter which needs to be added separately +// export const StudioTagsCriterionOption = new BaseTagsCriterionOption( +// "studio_tags", +// "studio_tags", +// withoutEqualsModifierOptions +// ); + export const ParentTagsCriterionOption = new BaseTagsCriterionOption( "parent_tags", "parents", diff --git a/ui/v2.5/src/models/list-filter/galleries.ts b/ui/v2.5/src/models/list-filter/galleries.ts index 8c1fc5a76c0..630267c728a 100644 --- a/ui/v2.5/src/models/list-filter/galleries.ts +++ b/ui/v2.5/src/models/list-filter/galleries.ts @@ -14,6 +14,7 @@ import { ScenesCriterionOption } from "./criteria/scenes"; import { StudiosCriterionOption } from "./criteria/studios"; import { PerformerTagsCriterionOption, + // StudioTagsCriterionOption, TagsCriterionOption, } from "./criteria/tags"; import { ListFilterOptions, MediaSortByOptions } from "./filter-options"; @@ -62,6 +63,7 @@ const criterionOptions = [ createMandatoryNumberCriterionOption("performer_age"), PerformerFavoriteCriterionOption, createMandatoryNumberCriterionOption("image_count"), + // StudioTagsCriterionOption, ScenesCriterionOption, StudiosCriterionOption, createStringCriterionOption("url"), diff --git a/ui/v2.5/src/models/list-filter/images.ts b/ui/v2.5/src/models/list-filter/images.ts index b8696fea40d..d8619112df6 100644 --- a/ui/v2.5/src/models/list-filter/images.ts +++ b/ui/v2.5/src/models/list-filter/images.ts @@ -16,6 +16,7 @@ import { OrientationCriterionOption } from "./criteria/orientation"; import { StudiosCriterionOption } from "./criteria/studios"; import { PerformerTagsCriterionOption, + // StudioTagsCriterionOption, TagsCriterionOption, } from "./criteria/tags"; import { ListFilterOptions, MediaSortByOptions } from "./filter-options"; @@ -54,6 +55,7 @@ const criterionOptions = [ createMandatoryNumberCriterionOption("performer_count"), createMandatoryNumberCriterionOption("performer_age"), PerformerFavoriteCriterionOption, + // StudioTagsCriterionOption, StudiosCriterionOption, createStringCriterionOption("url"), createDateCriterionOption("date"), diff --git a/ui/v2.5/src/models/list-filter/movies.ts b/ui/v2.5/src/models/list-filter/movies.ts index 35e4a24e25c..7e89d59393c 100644 --- a/ui/v2.5/src/models/list-filter/movies.ts +++ b/ui/v2.5/src/models/list-filter/movies.ts @@ -11,6 +11,7 @@ import { PerformersCriterionOption } from "./criteria/performers"; import { ListFilterOptions } from "./filter-options"; import { DisplayMode } from "./types"; import { RatingCriterionOption } from "./criteria/rating"; +// import { StudioTagsCriterionOption } from "./criteria/tags"; import { TagsCriterionOption } from "./criteria/tags"; const defaultSortBy = "name"; @@ -32,6 +33,7 @@ const sortByOptions = [ ]); const displayModeOptions = [DisplayMode.Grid]; const criterionOptions = [ + // StudioTagsCriterionOption, StudiosCriterionOption, MovieIsMissingCriterionOption, createStringCriterionOption("url"), diff --git a/ui/v2.5/src/models/list-filter/scenes.ts b/ui/v2.5/src/models/list-filter/scenes.ts index f6210a91841..c25ee97668d 100644 --- a/ui/v2.5/src/models/list-filter/scenes.ts +++ b/ui/v2.5/src/models/list-filter/scenes.ts @@ -17,6 +17,7 @@ import { StudiosCriterionOption } from "./criteria/studios"; import { InteractiveCriterionOption } from "./criteria/interactive"; import { PerformerTagsCriterionOption, + // StudioTagsCriterionOption, TagsCriterionOption, } from "./criteria/tags"; import { ListFilterOptions, MediaSortByOptions } from "./filter-options"; @@ -99,6 +100,7 @@ const criterionOptions = [ createMandatoryNumberCriterionOption("performer_count"), createMandatoryNumberCriterionOption("performer_age"), PerformerFavoriteCriterionOption, + // StudioTagsCriterionOption, StudiosCriterionOption, MoviesCriterionOption, GalleriesCriterionOption, diff --git a/ui/v2.5/src/models/list-filter/studios.ts b/ui/v2.5/src/models/list-filter/studios.ts index ff3eeeebdcb..a25fd9e2267 100644 --- a/ui/v2.5/src/models/list-filter/studios.ts +++ b/ui/v2.5/src/models/list-filter/studios.ts @@ -10,11 +10,12 @@ import { StudioIsMissingCriterionOption } from "./criteria/is-missing"; import { RatingCriterionOption } from "./criteria/rating"; import { StashIDCriterionOption } from "./criteria/stash-ids"; import { ParentStudiosCriterionOption } from "./criteria/studios"; +import { TagsCriterionOption } from "./criteria/tags"; import { ListFilterOptions } from "./filter-options"; import { DisplayMode } from "./types"; const defaultSortBy = "name"; -const sortByOptions = ["name", "random", "rating"] +const sortByOptions = ["name", "tag_count", "random", "rating"] .map(ListFilterOptions.createSortBy) .concat([ { @@ -42,8 +43,10 @@ const criterionOptions = [ createStringCriterionOption("details"), ParentStudiosCriterionOption, StudioIsMissingCriterionOption, + TagsCriterionOption, RatingCriterionOption, createBooleanCriterionOption("ignore_auto_tag"), + createMandatoryNumberCriterionOption("tag_count"), createMandatoryNumberCriterionOption("scene_count"), createMandatoryNumberCriterionOption("image_count"), createMandatoryNumberCriterionOption("gallery_count"), diff --git a/ui/v2.5/src/models/list-filter/tags.ts b/ui/v2.5/src/models/list-filter/tags.ts index 9a9b71680a3..51df9ed896c 100644 --- a/ui/v2.5/src/models/list-filter/tags.ts +++ b/ui/v2.5/src/models/list-filter/tags.ts @@ -43,6 +43,10 @@ const sortByOptions = ["name", "random"] messageID: "marker_count", value: "scene_markers_count", }, + { + messageID: "studio_count", + value: "studios_count", + }, ]); const displayModeOptions = [DisplayMode.Grid, DisplayMode.List]; @@ -57,6 +61,7 @@ const criterionOptions = [ createMandatoryNumberCriterionOption("image_count"), createMandatoryNumberCriterionOption("gallery_count"), createMandatoryNumberCriterionOption("performer_count"), + createMandatoryNumberCriterionOption("studio_count"), createMandatoryNumberCriterionOption("movie_count"), createMandatoryNumberCriterionOption("marker_count"), ParentTagsCriterionOption, diff --git a/ui/v2.5/src/models/list-filter/types.ts b/ui/v2.5/src/models/list-filter/types.ts index 9638c7e9477..5a63179ad4b 100644 --- a/ui/v2.5/src/models/list-filter/types.ts +++ b/ui/v2.5/src/models/list-filter/types.ts @@ -142,6 +142,7 @@ export type CriterionType = | "tags" | "scene_tags" | "performer_tags" + | "studio_tags" | "tag_count" | "performers" | "studios" @@ -172,6 +173,7 @@ export type CriterionType = | "image_count" | "gallery_count" | "performer_count" + | "studio_count" | "movie_count" | "death_year" | "url" diff --git a/ui/v2.5/src/utils/navigation.ts b/ui/v2.5/src/utils/navigation.ts index e77f40a38aa..864618fd414 100644 --- a/ui/v2.5/src/utils/navigation.ts +++ b/ui/v2.5/src/utils/navigation.ts @@ -263,7 +263,7 @@ const makeChildTagsUrl = (tag: Partial) => { }; function makeTagFilter(mode: GQL.FilterMode, tag: INamedObject) { - const filter = new ListFilterModel(GQL.FilterMode.Scenes, undefined); + const filter = new ListFilterModel(mode, undefined); const criterion = new TagsCriterion(TagsCriterionOption); criterion.value = { items: [{ id: tag.id, label: tag.name || `Tag ${tag.id}` }], @@ -282,6 +282,10 @@ const makeTagPerformersUrl = (tag: INamedObject) => { return `/performers?${makeTagFilter(GQL.FilterMode.Performers, tag)}`; }; +const makeTagStudiosUrl = (tag: INamedObject) => { + return `/studios?${makeTagFilter(GQL.FilterMode.Studios, tag)}`; +}; + const makeTagSceneMarkersUrl = (tag: INamedObject) => { return `/scenes/markers?${makeTagFilter(GQL.FilterMode.SceneMarkers, tag)}`; }; @@ -410,6 +414,7 @@ const NavUtils = { makeTagSceneMarkersUrl, makeTagScenesUrl, makeTagPerformersUrl, + makeTagStudiosUrl, makeTagGalleriesUrl, makeTagImagesUrl, makeTagMoviesUrl, From 9c13b39f99434bc39d21ddb9b1a4842b1b9b5f1d Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Wed, 19 Jun 2024 19:52:33 +1000 Subject: [PATCH 020/103] Fix identify clearing parent studio when merging (#4993) * Refactor ScrapedStudio.ToPartial signature * Add unit test * Don't clear parent studio during ToPartial --- internal/identify/studio.go | 6 +- internal/manager/task_stash_box_tag.go | 12 +-- pkg/models/model_scraped_item.go | 8 +- pkg/models/model_scraped_item_test.go | 120 +++++++++++++++++++++++++ 4 files changed, 132 insertions(+), 14 deletions(-) diff --git a/internal/identify/studio.go b/internal/identify/studio.go index d05967bc4f2..51bcaf2eec9 100644 --- a/internal/identify/studio.go +++ b/internal/identify/studio.go @@ -46,17 +46,17 @@ func createMissingStudio(ctx context.Context, endpoint string, w models.StudioRe return nil, err } - studioPartial := s.Parent.ToPartial(s.Parent.StoredID, endpoint, nil, existingStashIDs) + studioPartial := s.Parent.ToPartial(*s.Parent.StoredID, endpoint, nil, existingStashIDs) parentImage, err := s.Parent.GetImage(ctx, nil) if err != nil { return nil, err } - if err := studio.ValidateModify(ctx, *studioPartial, w); err != nil { + if err := studio.ValidateModify(ctx, studioPartial, w); err != nil { return nil, err } - _, err = w.UpdatePartial(ctx, *studioPartial) + _, err = w.UpdatePartial(ctx, studioPartial) if err != nil { return nil, err } diff --git a/internal/manager/task_stash_box_tag.go b/internal/manager/task_stash_box_tag.go index 298b58e279f..8bb39960140 100644 --- a/internal/manager/task_stash_box_tag.go +++ b/internal/manager/task_stash_box_tag.go @@ -311,13 +311,13 @@ func (t *StashBoxBatchTagTask) processMatchedStudio(ctx context.Context, s *mode return err } - partial := s.ToPartial(s.StoredID, t.box.Endpoint, excluded, existingStashIDs) + partial := s.ToPartial(*s.StoredID, t.box.Endpoint, excluded, existingStashIDs) - if err := studio.ValidateModify(ctx, *partial, qb); err != nil { + if err := studio.ValidateModify(ctx, partial, qb); err != nil { return err } - if _, err := qb.UpdatePartial(ctx, *partial); err != nil { + if _, err := qb.UpdatePartial(ctx, partial); err != nil { return err } @@ -435,13 +435,13 @@ func (t *StashBoxBatchTagTask) processParentStudio(ctx context.Context, parent * return err } - partial := parent.ToPartial(parent.StoredID, t.box.Endpoint, excluded, existingStashIDs) + partial := parent.ToPartial(*parent.StoredID, t.box.Endpoint, excluded, existingStashIDs) - if err := studio.ValidateModify(ctx, *partial, qb); err != nil { + if err := studio.ValidateModify(ctx, partial, qb); err != nil { return err } - if _, err := qb.UpdatePartial(ctx, *partial); err != nil { + if _, err := qb.UpdatePartial(ctx, partial); err != nil { return err } diff --git a/pkg/models/model_scraped_item.go b/pkg/models/model_scraped_item.go index 206f1109b2e..84c69d7e49b 100644 --- a/pkg/models/model_scraped_item.go +++ b/pkg/models/model_scraped_item.go @@ -62,9 +62,9 @@ func (s *ScrapedStudio) GetImage(ctx context.Context, excluded map[string]bool) return nil, nil } -func (s *ScrapedStudio) ToPartial(id *string, endpoint string, excluded map[string]bool, existingStashIDs []StashID) *StudioPartial { +func (s *ScrapedStudio) ToPartial(id string, endpoint string, excluded map[string]bool, existingStashIDs []StashID) StudioPartial { ret := NewStudioPartial() - ret.ID, _ = strconv.Atoi(*id) + ret.ID, _ = strconv.Atoi(id) if s.Name != "" && !excluded["name"] { ret.Name = NewOptionalString(s.Name) @@ -82,8 +82,6 @@ func (s *ScrapedStudio) ToPartial(id *string, endpoint string, excluded map[stri ret.ParentID = NewOptionalInt(parentID) } } - } else { - ret.ParentID = NewOptionalIntPtr(nil) } if s.RemoteSiteID != nil && endpoint != "" { @@ -97,7 +95,7 @@ func (s *ScrapedStudio) ToPartial(id *string, endpoint string, excluded map[stri }) } - return &ret + return ret } // A performer from a scraping operation... diff --git a/pkg/models/model_scraped_item_test.go b/pkg/models/model_scraped_item_test.go index 50657188deb..87ce2ad57dc 100644 --- a/pkg/models/model_scraped_item_test.go +++ b/pkg/models/model_scraped_item_test.go @@ -247,3 +247,123 @@ func Test_scrapedToPerformerInput(t *testing.T) { }) } } + +func TestScrapedStudio_ToPartial(t *testing.T) { + var ( + id = 1000 + idStr = strconv.Itoa(id) + storedID = "storedID" + parentStoredID = 2000 + parentStoredIDStr = strconv.Itoa(parentStoredID) + name = "name" + url = "url" + remoteSiteID = "remoteSiteID" + endpoint = "endpoint" + image = "image" + images = []string{image} + + existingEndpoint = "existingEndpoint" + existingStashID = StashID{"existingStashID", existingEndpoint} + existingStashIDs = []StashID{existingStashID} + ) + + fullStudio := ScrapedStudio{ + StoredID: &storedID, + Name: name, + URL: &url, + Parent: &ScrapedStudio{ + StoredID: &parentStoredIDStr, + }, + Image: &image, + Images: images, + RemoteSiteID: &remoteSiteID, + } + + type args struct { + id string + endpoint string + excluded map[string]bool + existingStashIDs []StashID + } + + stdArgs := args{ + id: idStr, + endpoint: endpoint, + excluded: map[string]bool{}, + existingStashIDs: existingStashIDs, + } + + excludeAll := map[string]bool{ + "name": true, + "url": true, + "parent": true, + } + + tests := []struct { + name string + o ScrapedStudio + args args + want StudioPartial + }{ + { + "full no exclusions", + fullStudio, + stdArgs, + StudioPartial{ + ID: id, + Name: NewOptionalString(name), + URL: NewOptionalString(url), + ParentID: NewOptionalInt(parentStoredID), + StashIDs: &UpdateStashIDs{ + StashIDs: append(existingStashIDs, StashID{ + Endpoint: endpoint, + StashID: remoteSiteID, + }), + Mode: RelationshipUpdateModeSet, + }, + }, + }, + { + "exclude all", + fullStudio, + args{ + id: idStr, + excluded: excludeAll, + }, + StudioPartial{ + ID: id, + }, + }, + { + "overwrite stash id", + fullStudio, + args{ + id: idStr, + excluded: excludeAll, + endpoint: existingEndpoint, + existingStashIDs: existingStashIDs, + }, + StudioPartial{ + ID: id, + StashIDs: &UpdateStashIDs{ + StashIDs: []StashID{{ + Endpoint: existingEndpoint, + StashID: remoteSiteID, + }}, + Mode: RelationshipUpdateModeSet, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := tt.o + got := s.ToPartial(tt.args.id, tt.args.endpoint, tt.args.excluded, tt.args.existingStashIDs) + + // unset updatedAt - we don't need to compare it + got.UpdatedAt = OptionalTime{} + + assert.Equal(t, tt.want, got) + }) + } +} From a1fc14f8c4361433b3025479321949d7826bc721 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Wed, 19 Jun 2024 20:00:30 +1000 Subject: [PATCH 021/103] Fix join function for studio scenes_filter handler (#4994) --- pkg/sqlite/studio_filter.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/sqlite/studio_filter.go b/pkg/sqlite/studio_filter.go index 040fc185818..c514364c4ff 100644 --- a/pkg/sqlite/studio_filter.go +++ b/pkg/sqlite/studio_filter.go @@ -90,7 +90,7 @@ func (qb *studioFilterHandler) criterionHandler() criterionHandler { relatedRepo: sceneRepository.repository, relatedHandler: &sceneFilterHandler{studioFilter.ScenesFilter}, joinFn: func(f *filterBuilder) { - sceneRepository.innerJoin(f, "", "studios.id") + studioRepository.scenes.innerJoin(f, "", "studios.id") }, }, From a7e5ccd08004c4a8a86f2ecc8a886439d2dabd9a Mon Sep 17 00:00:00 2001 From: "Weblate (bot)" Date: Wed, 19 Jun 2024 22:07:09 +0200 Subject: [PATCH 022/103] Translations update from Hosted Weblate (#4930) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Translated using Weblate (Thai) Currently translated at 77.1% (887 of 1149 strings) Translation: stashapp/stash Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/th/ * Translated using Weblate (Korean) Currently translated at 100.0% (1149 of 1149 strings) Translation: stashapp/stash Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/ko/ * Translated using Weblate (Thai) Currently translated at 85.6% (984 of 1149 strings) Translation: stashapp/stash Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/th/ * Translated using Weblate (Thai) Currently translated at 99.0% (1138 of 1149 strings) Translation: stashapp/stash Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/th/ * Translated using Weblate (Russian) Currently translated at 99.9% (1148 of 1149 strings) Translation: stashapp/stash Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/ru/ * Translated using Weblate (Czech) Currently translated at 100.0% (1149 of 1149 strings) Translation: stashapp/stash Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/cs/ * Translated using Weblate (Chinese (Simplified)) Currently translated at 100.0% (1152 of 1152 strings) Translation: stashapp/stash Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/zh_Hans/ * Translated using Weblate (Chinese (Simplified)) Currently translated at 100.0% (1153 of 1153 strings) Translation: stashapp/stash Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/zh_Hans/ * Translated using Weblate (Chinese (Simplified)) Currently translated at 100.0% (1155 of 1155 strings) Translation: stashapp/stash Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/zh_Hans/ --------- Co-authored-by: PZKL48 Co-authored-by: 이예찬 Co-authored-by: Alexusfree (alexusfree) Co-authored-by: Nymeria Co-authored-by: wql219 <160428035+wql219@users.noreply.github.com> Co-authored-by: Hansi --- ui/v2.5/src/locales/cs-CZ.json | 8 +- ui/v2.5/src/locales/ko-KR.json | 64 ++- ui/v2.5/src/locales/ru-RU.json | 4 +- ui/v2.5/src/locales/th-TH.json | 882 +++++++++++++++++++++++++++++++-- ui/v2.5/src/locales/zh-CN.json | 12 +- 5 files changed, 898 insertions(+), 72 deletions(-) diff --git a/ui/v2.5/src/locales/cs-CZ.json b/ui/v2.5/src/locales/cs-CZ.json index 38678f0c913..76571cf17c3 100644 --- a/ui/v2.5/src/locales/cs-CZ.json +++ b/ui/v2.5/src/locales/cs-CZ.json @@ -247,7 +247,9 @@ "successfully_cancelled_temporary_behaviour": "Úspěšně zrušeno dočasné chování", "until_restart": "pouze do restartu", "video_sort_order": "Výchozí řazení videa", - "video_sort_order_desc": "Nastav řazení videí na výchozí." + "video_sort_order_desc": "Nastav řazení videí na výchozí.", + "server_port": "Port serveru", + "server_port_desc": "Port, na kterém poběží DLNA server. Po změně, vyžaduje DLNA restart." }, "general": { "auth": { @@ -701,7 +703,7 @@ "heading": "(Výchozí nastavení) Pokračovat v playlistu" }, "show_scrubber": "Zobrazit Scrubber", - "track_activity": "Sledování činností", + "track_activity": "Povolit historii přehrávání scén", "disable_mobile_media_auto_rotate": "Zakázat automatické otáčení médií na celou obrazovku v mobilu", "enable_chromecast": "Povolit Chromecast", "show_ab_loop_controls": "Zobrazit ovládací prvky pluginu AB Loop", @@ -770,7 +772,7 @@ "heading": "Zobrazení tagů" }, "use_stash_hosted_funscript": { - "description": "Je-li povoleno, budou funscripty poskytovány přímo ze Stash do vašeho zařízení Handy bez použití serveru Handy třetí strany. Vyžaduje, aby byl Stash dostupný z vašeho zařízení Handy.", + "description": "Je-li povoleno, budou funscripty poskytovány přímo ze Stash do vašeho zařízení Handy bez použití serveru Handy třetí strany. Vyžaduje, aby byl Stash dostupný z vašeho zařízení Handy a vygenerovaný API klíč, pokud má stash nakonfigurované údaje.", "heading": "Funscripty podávejte přímo" } }, diff --git a/ui/v2.5/src/locales/ko-KR.json b/ui/v2.5/src/locales/ko-KR.json index 05e5df37db4..70ba5295b5e 100644 --- a/ui/v2.5/src/locales/ko-KR.json +++ b/ui/v2.5/src/locales/ko-KR.json @@ -57,9 +57,9 @@ "import_from_file": "파일 불러오기", "logout": "로그아웃", "make_primary": "첫 번째로 만들기", - "merge": "합치기", - "merge_from": "...에서 합치기", - "merge_into": "...로 합치기", + "merge": "병합", + "merge_from": "...에서 병합", + "merge_into": "...로 병합", "migrate_blobs": "Blob 마이그레이션", "migrate_scene_screenshots": "영상 스크린샷 마이그레이션", "next_action": "다음", @@ -134,7 +134,8 @@ "clear_date_data": "날짜 데이터 삭제", "copy_to_clipboard": "클립보드에 복사", "reload": "새로고침", - "remove_date": "날짜 삭제" + "remove_date": "날짜 삭제", + "view_history": "기록 보기" }, "actions_name": "액션", "age": "나이", @@ -178,7 +179,7 @@ "query_mode_path_desc": "전체 파일 경로 사용", "set_cover_desc": "영상 커버가 있다면 그 이미지로 교체합니다.", "set_cover_label": "영상 커버 이미지 설정", - "set_tag_desc": "영상에 이미 존재하는 태그들을 덮어쓰기/합치기 함으로써 태그를 영상에 추가합니다.", + "set_tag_desc": "영상에 이미 존재하는 태그들을 덮어쓰거나 병합함으로써 태그를 영상에 추가합니다.", "set_tag_label": "태그 설정", "show_male_desc": "남성 배우들의 태그 가능 여부 설정을 켜거나 끕니다.", "show_male_label": "남성 배우 보여주기", @@ -257,7 +258,9 @@ "successfully_cancelled_temporary_behaviour": "임시 설정을 취소하는 데에 성공했습니다", "until_restart": "재시작 전까지", "video_sort_order": "기본 비디오 정렬 순서", - "video_sort_order_desc": "비디오를 정렬할 기본값 순서입니다." + "video_sort_order_desc": "비디오를 정렬할 기본값 순서입니다.", + "server_port": "서버 포트", + "server_port_desc": "DLNA 서버를 동작시킬 포트입니다. 변경 이후 DLNA 재시작이 필요합니다." }, "general": { "auth": { @@ -493,7 +496,15 @@ "source": "소스", "source_options": "{source} 옵션", "sources": "소스", - "strategy": "방법" + "strategy": "방법", + "skip_single_name_performers_tooltip": "만약 이 옵션이 설정되지 않은 경우, 'Samantha' 혹은 'Olga'와 같은 흔한 이름들이 매칭될 것입니다", + "tag_skipped_performer_tooltip": "이 옵션에 해당하는 배우들에 대해, 나중에 영상 태거 뷰에서 배우 정보를 원하는 대로 다룰 수 있도록, '식별: 한 단어 이름 배우' 등과 같은 태그를 만듭니다", + "skip_multiple_matches_tooltip": "만약 이 옵션이 설정되지 않은 상태에서 여러 개의 결과가 도출된 경우, 여러 개의 결과 중 무작위로 하나가 선택될 것입니다", + "skip_single_name_performers": "다른 배우의 이름과 겹치지 않으면서도 한 단어의 이름으로 이뤄진 배우의 경우, 처리하지 않고 건너뛰기", + "skip_multiple_matches": "여러 개의 매칭 결과가 나왔을 때, 처리하지 않고 건너뛰기", + "tag_skipped_matches": "처리하지 않고 건너뛴 항목들에 대해 다음과 같이 태그하기", + "tag_skipped_matches_tooltip": "다수 식별 결과가 도출된 항목들을 대상으로, 실제로 일치하는 식별 결과를 영상 태거 뷰에서 직접 고를 수 있도록, '식별: 다수 매칭' 등과 같은 태그를 만듭니다", + "tag_skipped_performers": "처리하지 않고 건너뛴 배우들에 대해 다음과 같이 태그하기" }, "import_from_exported_json": "메타데이터 폴더에서 내보낸 JSON 파일에서 가져오기 작업을 합니다. 기존 데이터베이스를 지웁니다.", "incremental_import": "내보낸 zip 파일에서 증가한 부분만 가져옵니다.", @@ -708,7 +719,7 @@ "heading": "플레이리스트 이어보기" }, "show_scrubber": "스크러버 보이기", - "track_activity": "활동 트래킹", + "track_activity": "영상 재생 기록 활성화", "vr_tag": { "description": "VR 버튼은 이 태그를 가진 영상에서만 보여질 것입니다.", "heading": "VR 태그" @@ -758,7 +769,7 @@ "title": "UI", "use_stash_hosted_funscript": { "heading": "funscript 직접 전달", - "description": "활성화되면, 서드 파티 Handy 서버를 사용하지 않고 Stash로부터 Handy 디바이스로 곧바로 funscript가 전달될 것입니다. Stash가 Handy 디바이스에 접근 가능한 상태여야 합니다." + "description": "활성화되면, 서드 파티 Handy 서버를 사용하지 않고 Stash로부터 Handy 디바이스로 곧바로 funscript가 전달될 것입니다. Stash가 Handy 디바이스에 접근 가능한 상태여야 하고, Stash에서 인증이 설정된 상태라면 API 키가 생성되어 있어야 합니다." }, "detail": { "enable_background_image": { @@ -812,7 +823,10 @@ "not_between": "구간 밖", "not_equals": "≠", "not_matches_regex": "정규표현식 불일치", - "not_null": "값 존재함" + "not_null": "값 존재함", + "format_string_excludes": "{criterion} {modifierString} {valueString} ({excludedString} 제외)", + "format_string_excludes_depth": "{criterion} {modifierString} {valueString} ({excludedString} 제외) (+{depth, plural, =-1 {all} other {{depth}}})", + "format_string_depth": "{criterion} {modifierString} {valueString} (+{depth, plural, =-1 {all} other {{depth}}})" }, "custom": "커스텀", "date": "날짜", @@ -925,7 +939,15 @@ "unsaved_changes": "저장되지 않은 변경 사항들이 있습니다. 그래도 나가겠습니까?", "performers_found": "{count} 명의 배우들을 찾았습니다", "clear_o_history_confirm": "정말 싸버린 기록을 삭제하시겠습니까?", - "clear_play_history_confirm": "정말 재생 기록을 삭제하시겠습니까?" + "clear_play_history_confirm": "정말 재생 기록을 삭제하시겠습니까?", + "merge": { + "destination": "~으로 병합 (병합 결과)", + "source": "~을 (병합 대상)", + "empty_results": "병합 결과 값이 바뀌지 않을 것입니다." + }, + "reassign_files": { + "destination": "~으로 재지정" + } }, "dimensions": "해상도", "director": "감독", @@ -1057,7 +1079,7 @@ "interactive": "인터렉티브", "interactive_speed": "인터랙티브 속도", "isMissing": "데이터 누락됨", - "last_played_at": "마지막으로 재생", + "last_played_at": "마지막 재생 날짜", "library": "라이브러리", "loading": { "generic": "로드 중…" @@ -1192,7 +1214,8 @@ "database_file_path": "데이터베이스 파일 경로", "generated_directory": "생성된 컨텐츠 폴더", "nearly_there": "거의 끝났습니다!", - "stash_library_directories": "Stash 라이브러리 폴더" + "stash_library_directories": "Stash 라이브러리 폴더", + "blobs_use_database": "<데이터베이스 사용 중>" }, "creating": { "creating_your_system": "시스템 생성 중" @@ -1382,7 +1405,8 @@ "to_use_the_studio_tagger": "스튜디오 태거를 사용하려면, stash-box 인스턴스가 설정되어야 합니다.", "update_studios": "스튜디오 업데이트", "untagged_studios": "태그되지 않은 스튜디오", - "update_studio": "스튜디오 업데이트" + "update_studio": "스튜디오 업데이트", + "studio_selection": "스튜디오 선택" }, "audio_codec": "오디오 코덱", "connection_monitor": { @@ -1422,10 +1446,12 @@ "no_sources": "패키지 소스 없음", "version": "버전", "show_all": "모두 보여주기", - "update": "업데이트" + "update": "업데이트", + "selected_only": "선택된 것만", + "required_by": "{packages}로 인해 요구됨" }, "o_count": "싼 횟수", - "orientation": "", + "orientation": "방향", "parent_studio": "부모 스튜디오", "subsidiary_studio_count": "자회사 스튜디오 개수", "time": "시간", @@ -1435,5 +1461,9 @@ "play_history": "재생 기록", "primary_tag": "주 태그", "unknown_date": "날짜 미상", - "urls": "URL" + "urls": "URL", + "distance": "거리", + "studio_and_parent": "스튜디오 & 모회사", + "tag_parent_tooltip": "상위 태그 존재 여부", + "tag_sub_tag_tooltip": "하위 태그 존재 여부" } diff --git a/ui/v2.5/src/locales/ru-RU.json b/ui/v2.5/src/locales/ru-RU.json index 7696b1f6d7a..6df3cfec18e 100644 --- a/ui/v2.5/src/locales/ru-RU.json +++ b/ui/v2.5/src/locales/ru-RU.json @@ -252,7 +252,9 @@ "successfully_cancelled_temporary_behaviour": "Временно запущенный сервис DLNA, успешно отключен", "until_restart": "до перезагрузки", "video_sort_order": "Порядок сортировки видео по умолчанию", - "video_sort_order_desc": "Порядок сортировки видео по умолчанию." + "video_sort_order_desc": "Порядок сортировки видео по умолчанию.", + "server_port_desc": "Порт DLNA-сервера. После изменения требуется перезапуск DLNA.", + "server_port": "Порт DLNA" }, "general": { "auth": { diff --git a/ui/v2.5/src/locales/th-TH.json b/ui/v2.5/src/locales/th-TH.json index 84e2915cb0a..1716dd4372f 100644 --- a/ui/v2.5/src/locales/th-TH.json +++ b/ui/v2.5/src/locales/th-TH.json @@ -1,7 +1,7 @@ { "actions": { "add": "เพิ่ม", - "add_directory": "เพิ่มโฟลเดอร์", + "add_directory": "เพิ่มไดเร็กทอรี", "add_entity": "เพิ่ม{entityType}", "add_to_entity": "เพิ่มไปยัง{entityType}", "allow": "อนุญาต", @@ -13,15 +13,15 @@ "cancel": "ยกเลิก", "clean": "เก็บกวาด", "clear": "ล้างค่า", - "clear_back_image": "ล้างค่ารูปภาพเบื้องหลัง", - "clear_front_image": "ล้างค่ารูปภาพด้านหน้า", - "clear_image": "ล้างค่ารูป", + "clear_back_image": "ล้างค่าภาพปกหลัง", + "clear_front_image": "ล้างค่าภาพปกหน้า", + "clear_image": "ล้างค่ารูปภาพ", "close": "ปิด", "confirm": "ยืนยัน", "continue": "ถัดไป", "create": "สร้าง", "create_entity": "สร้าง{entityType}", - "create_marker": "สร้างจุดมาร์ค", + "create_marker": "สร้างมาร์คเกอร์", "created_entity": "สร้าง{entity_type}: {entity_name}", "customise": "ปรับแต่ง", "delete": "ลบ", @@ -114,7 +114,7 @@ "download_anonymised": "ดาวน์โหลดข้อมูลที่ปิดบังตัวตนแล้ว", "optimise_database": "ปรับแต่งฐานข้อมูลให้ดีขึ้น", "assign_stashid_to_parent_studio": "ระบุ Stash ID ให้กับสตูดิโอบริษัทแม่พร้อมกับอัปเดต metadata", - "create_chapters": "สร้างแชปเตอร์", + "create_chapters": "สร้างฉาก", "swap": "สลับ", "reassign": "ตั้งค่าใหม่", "reload": "รีโหลด", @@ -122,11 +122,11 @@ "view_history": "ประวัติการดู", "add_manual_date": "เพิ่มวันที่", "add_o": "เพิ่ม O", - "add_play": "เพิ่มจำนวนการเล่น", + "add_play": "เพิ่มจำนวนครั้งที่เล่น", "anonymise": "ปิดบังตัวตน", "choose_date": "เลือกวันที่", "clean_generated": "ลบไฟล์ที่ถูกสร้าง", - "clear_date_data": "ล้างข้อมูลวันที่", + "clear_date_data": "ล้างค่าข้อมูลวันที่", "copy_to_clipboard": "ทำสำเนา", "create_parent_studio": "สร้างสตูดิโอบริษัทแม่", "disable": "ปิดใช้งาน", @@ -148,10 +148,10 @@ "birthdate": "วันเกิด", "bitrate": "บิตเรท", "captions": "แคปชัน", - "career_length": "ระยะเวลาในอาชีพ", + "career_length": "ระยะเวลาในวงการ", "component_tagger": { "config": { - "active_instance": "instant Stash-box ที่ใช้งานอยู่:", + "active_instance": "Stash-box ที่ใช้งานอยู่:", "blacklist_desc": "รายการบัญชีดำจะไม่รวมอยู่ในการสืบค้น โปรดทราบว่าเป็นนิพจน์ทั่วไปและไม่คำนึงถึงขนาดตัวพิมพ์ อักขระบางตัวต้องหลีกหนีด้วยแบ็กสแลช: {chars_require_escape}", "blacklist_label": "บัญชีดำ", "query_mode_auto": "ออโต้", @@ -179,17 +179,17 @@ "results": { "duration_off": "ระยะเวลาปิดอย่างน้อย {number}s", "duration_unknown": "ระยะเวลาไม่ทราบ", - "fp_found": "{fpCount, plural, =0 {ไม่พบลายนิ้วมือใหม่ที่ตรงกัน} other {# พบลายนิ้วมือใหม่ที่ตรงกัน}}", + "fp_found": "{fpCount, plural, =0 {ไม่พบข้อมูลอัตลักษณ์ที่ตรงกัน} other {# พบข้อมูลอัตลักษณ์ที่ตรงกัน}}", "fp_matches": "ระยะเวลาตรงกัน", - "fp_matches_multi": "ระยะเวลาตรงกัน {matchCount}/{durationsLength} fingerprint(s)", + "fp_matches_multi": "ระยะเวลาตรงกัน {matchCount}/{durationsLength} ชุดข้อมูล", "hash_matches": "{hash_type} ตรงกัน", - "match_failed_already_tagged": "ฉากถูกแท็กแล้ว", + "match_failed_already_tagged": "มีข้อมูลซีนนี้แล้ว", "match_failed_no_result": "ไม่พบผลลัพธ์", - "match_success": "ฉากนี้ถูกแท็กเรียบร้อยแล้ว", + "match_success": "เพิ่มข้อมูลซีนสำเร็จแล้ว", "phash_matches": "{count} PHashes ตรงกัน", "unnamed": "ไม่มีชื่อ" }, - "verb_match_fp": "ลายนิ้วมือตรงกัน", + "verb_match_fp": "ข้อมูลอัตลักษณ์ตรงกัน", "verb_matched": "ตรงกัน", "verb_scrape_all": "สแครปทั้งหมด", "verb_submit_fp": "ยืนยัน {fpCount, plural, one{# Fingerprint} other{# Fingerprints}}", @@ -226,7 +226,7 @@ "system": "ระบบ", "tasks": "งาน", "tools": "เครื่องมือ", - "changelog": "Changelog" + "changelog": "บันทึกความเปลี่ยนแปลง" }, "dlna": { "allow_temp_ip": "อนุญาต {tempIP}", @@ -245,8 +245,8 @@ "server_display_name_desc": "ชื่อที่แสดงสำหรับเซิร์ฟเวอร์ DLNA ค่าเริ่มต้นเป็น {server_name} เป็นค่าว่าง", "successfully_cancelled_temporary_behaviour": "ยกเลิกพฤติกรรมชั่วคราวเรียบร้อยแล้ว", "until_restart": "จนกว่าจะรีสตาร์ท", - "video_sort_order_desc": "เลือกชนิดลำดับที่ต้องการเรียงลำดับวีดีโอเป็นค่าปริยาย", - "video_sort_order": "ค่าปริยายการเรียงลำดับวีดีโอ", + "video_sort_order_desc": "เลือกชนิดลำดับที่ต้องการเรียงลำดับวิดีโอเป็นค่าปริยาย", + "video_sort_order": "ค่าปริยายการเรียงลำดับวิดีโอ", "server_port": "พอร์ตเซิร์ฟเวอร์", "server_port_desc": "พอร์ตที่จะเรียกใช้เซิร์ฟเวอร์ DLNA ต้องรีสตาร์ท DLNA เมื่อเปลี่ยนแปลงค่า" }, @@ -326,7 +326,7 @@ "heading": "พาร์ธ Scrapers" }, "scraping": "การ Scrap", - "sqlite_location": "ตำแหน่งไฟล์สำหรับฐานข้อมูล SQLite (ต้องรีสตาร์ท)\nคำเตือน: ไม่รองรับการจัดเก็บฐานข้อมูลในที่อื่นๆ ที่ไม่ใช่เครื่องเซิร์ฟเวอร์ Stash (เช่นในไดรฟ์เครือข่าย)", + "sqlite_location": "ตำแหน่งไฟล์สำหรับฐานข้อมูล SQLite (ต้องรีสตาร์ท)
คำเตือน: ไม่รองรับการจัดเก็บฐานข้อมูลในที่อื่นๆ ที่ไม่ใช่เครื่องเซิร์ฟเวอร์ Stash (เช่นในไดรฟ์เครือข่าย)!", "video_ext_desc": "รายการนามสกุลไฟล์ซึ่งจะถูกระบุว่าเป็นวิดีโอ คั่นด้วยเครื่องหมายจุลภาค", "video_ext_head": "นามสกุลไฟล์วิดีโอ", "video_head": "วิดีโอ", @@ -345,12 +345,12 @@ }, "transcode": { "input_args": { - "heading": "การตั้งค่า FFmpeg เพิ่มเติม สำหรับก่อนการสร้างวีดีโอ", - "desc": "ขั้นสูง: ระบุการตั้งค่า FFmpeg เพิ่มเติม สำหรับก่อนการสร้างวีดีโอ" + "heading": "การตั้งค่า FFmpeg เพิ่มเติม สำหรับก่อนการสร้างวิดีโอ", + "desc": "ขั้นสูง: ระบุการตั้งค่า FFmpeg เพิ่มเติม สำหรับก่อนการสร้างวิดีโอ" }, "output_args": { - "desc": "ขั้นสูง: ระบุการตั้งค่า FFmpeg เพิ่มเติม สำหรับหลังการสร้างวีดีโอ", - "heading": "การตั้งค่า FFmpeg เพิ่มเติม สำหรับหลังการสร้างวีดีโอ" + "desc": "ขั้นสูง: ระบุการตั้งค่า FFmpeg เพิ่มเติม สำหรับหลังการสร้างวิดีโอ", + "heading": "การตั้งค่า FFmpeg เพิ่มเติม สำหรับหลังการสร้างวิดีโอ" } }, "download_ffmpeg": { @@ -372,8 +372,8 @@ } }, "hardware_acceleration": { - "desc": "ใช้ฮาร์ดแวร์เข้ารหัสวีดีโอสำหรับการ live transcode", - "heading": "ใช้ฮาร์ดแวร์เข้ารหัสวีดีโอสำหรับ FFmpeg" + "desc": "ใช้ฮาร์ดแวร์เข้ารหัสวิดีโอสำหรับการ live transcode", + "heading": "ใช้ฮาร์ดแวร์เข้ารหัสวิดีโอสำหรับ FFmpeg" } }, "funscript_heatmap_draw_range_desc": "ระบุค่าสูงต่ำ (ตามแกน y) ในการสร้าง heatmaps เมื่อเปลี่ยนแปลงค่านี้ต้องทำการสร้าง heatmaps ขึ้นใหม่", @@ -448,12 +448,12 @@ "empty_queue": "ไม่มีงานใดๆ ในคิว", "export_to_json": "ส่งออกฐานข้อมูลในรูปแบบ JSON ในไดเร็กทอรี metadata", "generate_thumbnails_during_scan": "สร้างภาพขนาดเล็กสำหรับไฟล์ภาพ", - "generate_previews_during_scan_tooltip": "สร้างภาพเคลื่อนไหวตัวอย่างเพิ่มเติม (webp) ต้องเปิดใช้งานเมื่อตั้งค่า Scene/Marker Wall Preview Type เป็นภาพเคลื่อนไหวเท่านั้น มีข้อดีคือใช้งาน CPU น้อยกว่าวีดีโอตัวอย่าง แต่มีข้อเสียคือต้องสร้างไฟล์เพิ่มเติมและมีขนาดใหญ่กว่า", + "generate_previews_during_scan_tooltip": "สร้างภาพเคลื่อนไหวตัวอย่างเพิ่มเติม (webp) ต้องเปิดใช้งานเมื่อตั้งค่า Scene/Marker Wall Preview Type เป็นภาพเคลื่อนไหวเท่านั้น มีข้อดีคือใช้งาน CPU น้อยกว่าวิดีโอตัวอย่าง แต่มีข้อเสียคือต้องสร้างไฟล์เพิ่มเติมและมีขนาดใหญ่กว่า", "anonymise_database": "ทำสำเนาฐานข้อมูลแบบปิดบังตัวตนและบันทึกไว้ในไดเร็กทอรีสำรองข้อมูล สามารถแจกจ่ายไฟล์ฐานข้อมูลแก้ผู้อื่นเพื่อใช้ในการแก้ไขปัญหาต่างๆ และง่ายต่อการ debug และไม่กระทบฐานข้อมูลหลัก โดยมีรูปแบบชื่อไฟล์ {filename_format}", "clean_generated": { "markers": "ภาพตัวอย่างมาร์คเกอร์", "previews_desc": "ภาพตัวอย่าง scene และภาพขนาดเล็ก", - "transcodes": "ไฟล์วีดีโอพร้อมใช้ของ scene", + "transcodes": "ไฟล์วิดีโอพร้อมใช้ของ scene", "blob_files": "ไฟล์ blob", "description": "ลบไฟล์ที่ถูกสร้างขึ้นที่ไม่มีอยู่ในฐานข้อมูล", "previews": "ภาพตัวอย่าง scene", @@ -465,7 +465,7 @@ "generating_from_paths": "กำลังสร้างไฟล์สำหรับ scene ตามตำแหน่งไฟล์ดังนี้", "generating_scenes": "กำลังสร้างไฟล์สำหรับ {num} {scene}" }, - "generate_desc": "กำลังสร้างภาพอื่นๆ sprite วีดีโอ vtt และไฟล์อื่นๆ", + "generate_desc": "กำลังสร้างภาพอื่นๆ sprite วิดีโอ vtt และไฟล์อื่นๆ", "generate_phashes_during_scan": "กำลังสร้าง perceptual hashes", "generate_previews_during_scan": "กำลังสร้างภาพเคลื่อนไหวตัวอย่าง", "identify": { @@ -475,29 +475,29 @@ "heading": "ค้นหาและระบุข้อมูล", "set_cover_images": "ตั้งค่าภาพปก", "set_organized": "ตั้งค่าเป็น organized", - "skip_single_name_performers_tooltip": "เมื่อไม่เปิดใช้งานตัวเลือกนี้ นักแสดงที่มีชื่อทั่วไปเช่น Samantha หรือ Olga จะถูกเลือกเป็นผลลัพธ์", + "skip_single_name_performers_tooltip": "เมื่อปิดใช้งานตัวเลือกนี้ นักแสดงที่มีชื่อทั่วไปเช่น Samantha หรือ Olga จะถูกเลือกเป็นผลลัพธ์", "sources": "แหล่งข้อมูล", "identifying_from_paths": "ระบุ scene จากตำแหน่งต่อไปนี้", - "skip_multiple_matches_tooltip": "เมื่อไม่เปิดใช้งานตัวเลือกนี้ ผลการจับคู่จะเป็นแบบสุ่มหากมีผลลัพธ์มากกว่าหนึ่ง", + "skip_multiple_matches_tooltip": "เมื่อปิดใช้งานตัวเลือกนี้ ผลการเปรียบเทียบข้อมูลจะเป็นแบบสุ่มหากมีผลลัพธ์มากกว่าหนึ่ง", "tag_skipped_matches_tooltip": "สร้างแท็กพิเศษ (เช่น 'Identify: Multiple Matches') เพื่อง่ายในการค้นหาและตั้งแท็กที่เหมาะสมด้วยตนเอง", "description": "ตั้งค่า scene metadata อัตโนมัติโดยใช้แหล่งข้อมูลจาก stash-box และ scraper", "and_create_missing": "และสร้างเนื้อหาที่ไม่มี", "create_missing": "สร้างขึ้นใหม่หากไม่มี", "identifying_scenes": "กำลังระบุ {num} {scene}", "include_male_performers": "ระบุนักแสดงชายด้วย", - "skip_multiple_matches": "ข้ามการจับคู่ข้อมูลหากมีผลลัพธ์มากกว่าหนึ่ง", + "skip_multiple_matches": "ข้ามการเปรียบเทียบข้อมูลหากมีผลลัพธ์มากกว่าหนึ่ง", "skip_single_name_performers": "ข้ามนักแสดงที่มีเพียงชื่อเดียวและไม่มีการระบุตัวตน", "source": "แหล่งข้อมูล", "source_options": "ตัวเลือก {source}", "strategy": "วิธีการ", - "tag_skipped_matches": "แท็กการข้ามการจับคู่ด้วยแท็ก", + "tag_skipped_matches": "แท็กไฟล์ที่ถูกข้ามการเปรียบเทียบข้อมูลด้วยแท็กพิเศษ", "tag_skipped_performers": "แท็กการข้ามการจับคู่นักแสดงด้วยแท็ก", - "field": "Field", + "field": "ประเภทข้อมูล", "field_behaviour": "{strategy} {field}", "tag_skipped_performer_tooltip": "สร้างแท็กพิเศษ (เช่น 'Identify: Single Name Performer') เพื่อง่ายในการค้นหาและเลือกชื่อที่เหมาะสมด้วยตนเอง" }, - "generate_sprites_during_scan_tooltip": "ชุดภาพนิ่งใต้ตัวเล่นวีดีโอ ช่วยให้การกรอวีดีโอง่ายขึ้น", - "generate_video_previews_during_scan": "สร้างวีดีโอตัวอย่าง", + "generate_sprites_during_scan_tooltip": "ชุดภาพนิ่งใต้ตัวเล่นวิดีโอ ช่วยให้การกรอวิดีโอง่ายขึ้น", + "generate_video_previews_during_scan": "สร้างวิดีโอตัวอย่าง", "scan_for_content_desc": "สแกนเนื้อหาใหม่เพื่อเพิ่มเข้าสู่ฐานข้อมูล", "incremental_import": "นำเข้าข้อมูลบางส่วนจากไฟล์ zip ที่เลือก", "migrate_hash_files": "ใช้เครื่องมือนี้หลังจากเปลี่ยนค่ารูปแบบชื่อไฟล์ hash เพื่อให้ตรงกับรูปแบบใหม่", @@ -511,7 +511,7 @@ "set_name_date_details_from_metadata_if_present": "ตั้งค่าชื่อ วันที่ และรายละเอียดจากข้อมูล metadata ที่แนบมาด้วย", "generate_sprites_during_scan": "สร้าง scrubber sprites", "anonymising_database": "กำลังปิดบังตัวตนฐานข้อมูล", - "generate_video_previews_during_scan_tooltip": "สร้างวีดีโอตัวอย่างที่จะเล่นอัตโนมัติเมื่อวางเคอร์เซอร์ไว้บน scene", + "generate_video_previews_during_scan_tooltip": "สร้างวิดีโอตัวอย่างที่จะเล่นอัตโนมัติเมื่อวางเคอร์เซอร์ไว้บน scene", "anonymise_and_download": "ทำสำเนาฐานข้อมูลแบบปิดบังตัวตนและดาวน์โหลด", "generate_clip_previews_during_scan": "กำลังสร้างภาพตัวอย่างสำหรับคลิปภาพ", "generate_phashes_during_scan_tooltip": "มีประโยชน์ช่วยลดไฟล์ซ้ำซ้อนและระบุ scene", @@ -536,10 +536,15 @@ "images": { "options": { "create_image_clips_from_videos": { - "description": "หากตัวเลือกวีดีโอถูกปิดใช้งานในไลบราลี ไฟล์วีดีโอจะถูกสแกนเป็นคลิปภาพแทน", - "heading": "สแกนไฟล์วีดีโอเป็นคลิปภาพ" + "description": "หากตัวเลือกวิดีโอถูกปิดใช้งานในไลบราลี ไฟล์วิดีโอจะถูกสแกนเป็นคลิปภาพแทน", + "heading": "สแกนไฟล์วิดีโอเป็นคลิปภาพ" + }, + "write_image_thumbnails": { + "heading": "บันทึกภาพตัวอย่าง", + "description": "บันทึกภาพตัวอย่างที่ถูกสร้างขึ้นลงบนดิสก์" } - } + }, + "heading": "ภาพ" }, "custom_css": { "description": "ต้องรีโหลดหน้าเพจใหม่ทุกครั้งเพื่อแสดงผลการตั้งค่า ไม่รับรองความเข้ากันได้ระหว่าง custom CSS และอัพเดตใหม่ของ Stash", @@ -618,6 +623,137 @@ "delete_file": "ลบไฟล์จากเครื่องคอมพิวเตอร์ของคุณเสมอ", "delete_generated_supporting_files": "ลบไฟล์เนื้อหาที่ถูกสร้างขึ้นเสมอ" } + }, + "menu_items": { + "description": "ซ่อนหรือแสดงเมนูเนื้อหาต่างๆ บนแถบนำทาง", + "heading": "รายการเมนู" + }, + "interactive_options": "ตัวเลือกอุปกรณ์อินเตอร์แอ็คทีฟ", + "preview_type": { + "description": "ค่าปริยายชนิดภาพตัวอย่างคือไฟล์วิดีโอ mp4 เลือกใช้ภาพเคลื่อนไหว (webp) หากต้องการลดการใช้ทรัพยากรระบบ โดยภาพเคลื่อนไหวจะถูกสร้างขึ้นเพิ่มเติมจากไฟล์วิดีโอและจะมีขนาดใหญ่กว่า", + "heading": "ชนิดภาพตัวอย่าง", + "options": { + "animated": "ภาพเคลื่อนไหว", + "video": "วิดีโอ", + "static": "ภาพนิ่ง" + } + }, + "scene_player": { + "options": { + "always_start_from_beginning": "เริ่มเล่นวิดีโอจากจุดเริ่มต้นเสมอ", + "auto_start_video": "เริ่มเล่นวิดีโออัตโนมัติ", + "auto_start_video_on_play_selected": { + "description": "เริ่มเล่นวิดีโอโดยอัตโนมัติเมื่อสั่งเล่นจากคิว จากไฟล์ที่เลือก หรือจากการสุ่ม", + "heading": "เริ่มเล่นวิดีโอโดยอัตโนมัติเมื่อสั่งเล่นจากไฟล์ที่เลือก" + }, + "continue_playlist_default": { + "heading": "เล่นไฟล์ถัดไปในเพลย์ลิสต์เสมอ", + "description": "เล่นซีนถัดไปในคิวเมื่อจบวิดีโอ" + }, + "disable_mobile_media_auto_rotate": "ปิดไม่ให้หมุนหน้าจออัตโนมัติเมื่อใช้งานบนอุปกรณ์พกพาแบบขยายเต็มจอ", + "show_ab_loop_controls": "แสดงแผงควบคุมปลั๊กอิน AB Loop", + "show_scrubber": "แสดงแผงกรอวิดีโอ", + "enable_chromecast": "เปิดใช้งาน Chromecast", + "track_activity": "เปิดใช้งานประวัติการดูซีน", + "vr_tag": { + "description": "แสดงปุ่ม VR เมื่อซีนมีแท็กเหล่านี้", + "heading": "ปุ่ม VR" + } + }, + "heading": "เครื่องเล่นซีน" + }, + "scroll_attempts_before_change": { + "description": "จำนวนครั้งที่ต้อง scroll ก่อนจะเปลี่ยนภาพ มีผลเฉพาะเมื่อเปิดใช้ตัวเลือก Pan Y", + "heading": "จำนวนครั้งที่ต้อง scroll ก่อนเปลี่ยนภาพ" + }, + "slideshow_delay": { + "heading": "ระยะเวลาเปลี่ยนภาพสไลด์โชว์ (วินาที)", + "description": "ใช้งานสไลด์โชว์ในหน้าแกลเลอรีได้เมื่ออยู่ในโหมดกำแพงภาพ" + }, + "tag_panel": { + "options": { + "show_child_tagged_content": { + "heading": "แสดงเนื้อหาจากแท็กย่อย", + "description": "ในหน้ามุมมองแท็ก ให้แสดงเนื้อหาจากแท็กย่อยด้วย" + } + }, + "heading": "มุมมองแท็ก" + }, + "use_stash_hosted_funscript": { + "heading": "เปิดใช้เรียกใช้ funscripts ได้โดยตรง", + "description": "เมื่อเปิดใช้งานตัวเลือกนี้ อุปกรณ์ Handy จะสามารถเรียกใช้ funscripts จาก Stash ได้โดยตรงโดยไม่ต้องผ่านเซิร์ฟเวอร์เจ้าอื่น โดย Stash จำเป็นต้องเข้าถึงได้จากอุปกรณ์ และต้องใช้กุญแจ API หากตั้งค่าความปลอดภัยไว้" + }, + "title": "ส่วนติดต่อผู้ใช้", + "studio_panel": { + "heading": "มุมมองสตูดิโอ", + "options": { + "show_child_studio_content": { + "description": "ในมุมมองสตูดิโอ ให้แสดงเนื้อหาจากสตูดิโอลูกด้วย", + "heading": "แสดงเนื้อหาจากสตูดิโอลูก" + } + } + }, + "image_wall": { + "direction": "ทิศทาง", + "heading": "กำแพงภาพ", + "margin": "ขนาดขอบ (พิกเซล)" + }, + "language": { + "heading": "ภาษา" + }, + "minimum_play_percent": { + "description": "ร้อยละของเวลาที่ผ่านไปของซีนที่กำลังดูที่จะถูกนับจำนวนครั้งที่เล่นไฟล์", + "heading": "ร้อยละของเวลาดูวิดีโอ" + }, + "performers": { + "options": { + "image_location": { + "description": "กำหนดตำแหน่งจัดเก็บภาพถ่ายนักแสดงด้วยตัวเอง หรือเว้นว่างเพื่อใช้ค่าปริยาย", + "heading": "กำหนดตำแหน่งจัดเก็บภาพถ่ายนักแสดง" + } + } + }, + "image_lightbox": { + "heading": "กล่องภาพ" + }, + "max_loop_duration": { + "description": "ระยะเวลามากสุดก่อนที่ตัวเล่นวิดีโอจะวนลูป - ใส่ 0 เพื่อปิดใช้งาน", + "heading": "ระยะเวลาก่อนวนลูป" + }, + "scene_list": { + "heading": "มุมมองกริด", + "options": { + "show_studio_as_text": "แสดงชื่อสตูดิโอบนหน้าปกเป็นข้อความ" + } + }, + "scene_wall": { + "heading": "กำแพงซีน / มาร์คเกอร์", + "options": { + "display_title": "แสดงชื่อเรื่องและแท็ก", + "toggle_sound": "เปิดเสียง" + } + }, + "show_tag_card_on_hover": { + "heading": "กล่องแท็ก", + "description": "แสดงกล่องแท็กเมื่อวางเคอร์เซอร์บนแท็ก" + }, + "funscript_offset": { + "heading": "หน่วงเวลา Funscript", + "description": "หน่วงเวลาการเล่นสคริปต์อินเตอร์แอ็คทีฟ หน่วยมิลลิวินาที" + }, + "handy_connection": { + "connect": "เชื่อมต่อ", + "server_offset": { + "heading": "หน่วงเวลาเซิร์ฟเวอร์" + }, + "status": { + "heading": "สถานะการเชื่อมต่ออุปกรณ์ Handy" + }, + "sync": "ซิงค์" + }, + "handy_connection_key": { + "description": "ระบุคีย์สำหรับเชื่อมต่ออุปกรณ์ Handy และแบ่งปันข้อมูลการเล่นซีนกับเว็บไซต์ handyfeeling.com", + "heading": "คีย์สำหรับเชื่อมต่อ Handy" } }, "tools": { @@ -633,7 +769,8 @@ "filename": "ชื่อไฟล์", "filename_pattern": "รูปแบบชื่อไฟล์", "select_parser_recipe": "เลือกสูตรการตั้งชื่อ", - "whitespace_chars_desc": "อักขระที่จะใช้แทนที่อักขระ whitespace ในชื่อไฟล์" + "whitespace_chars_desc": "อักขระที่จะใช้แทนที่อักขระ whitespace ในชื่อไฟล์", + "matches_with": "เปรียบเทียบคำโดยตรง" }, "scene_duplicate_checker": "เครื่องมือตรวจสอบ scene ซ้ำ", "scene_tools": "เครื่องมือเกี่ยวกับ scene" @@ -644,10 +781,39 @@ "updating_untagged_studios_description": "อัพเดตสตูดิโอที่ยังไม่มีข้อมูลโดยการค้นหาสตูดิโอที่ยังไม่มี stashid และทำการอัปเดตข้อมูล metadata", "config": { "create_parent_label": "สร้างสตูดิโอบริษัทแม่", - "create_parent_desc": "สร้างสตูดิโอบริษัทแม่หรือแท็กที่เกี่ยวข้องหากยังไม่มี พร้อมอัปเดตข้อมูลและรูปภาพให้กับรายการที่มีอยู่แล้ว" + "create_parent_desc": "สร้างสตูดิโอบริษัทแม่หรือแท็กที่เกี่ยวข้องหากยังไม่มี พร้อมอัปเดตข้อมูลและรูปภาพให้กับรายการที่มีอยู่แล้ว", + "active_stash-box_instance": "Stash-box instance ที่ใช้งาน:", + "excluded_fields": "ข้อมูลที่ไม่ต้องการใช้งาน:", + "edit_excluded_fields": "แก้ไขประเภทข้อมูลที่ไม่ต้องการใช้งาน", + "no_fields_are_excluded": "ไม่มีข้อมูลที่ไม่ต้องการใช้งาน", + "no_instances_found": "ไม่พบแหล่งข้อมูล", + "these_fields_will_not_be_changed_when_updating_studios": "ข้อมูลประเภทที่เลือกไว้จะไม่ถูกเปลี่ยนแปลงหรืออัปเดต" }, "create_or_tag_parent_studios": "สร้างสตูดิโอบริษัทแม่หรือแท็กที่เกียวข้องหากยังไม่มี", - "untagged_studios": "สตูดิโอที่ยังไม่มีข้อมูล" + "untagged_studios": "สตูดิโอที่ยังไม่มีข้อมูล", + "failed_to_save_studio": "บันทึกสตูดิโอ \"{studio}\" ไม่สำเร็จ", + "network_error": "พบข้อผิดพลาดทางเน็ตเวิร์ค", + "refresh_tagged_studios": "รีเฟรชสตูดิโอที่มีข้อมูลแล้ว", + "batch_add_studios": "เพิ่มสตูดิโอพร้อมกันหลายแห่ง", + "any_names_entered_will_be_queried": "รายชื่อสตูดิโอจะถูกเรียกหาข้อมูลจาก stash-box ที่เลือกไว้ โดยจะเลือกเฉพาะข้อมูลที่ตรงกันเท่านั้น", + "batch_update_studios": "อัพเดตสตูดิโอพร้อมกันหลายแห่ง", + "no_results_found": "ไม่พบผลลัพธ์", + "status_tagging_job_queued": "สถานะ: เพิ่มงานเพิ่มข้อมูลแล้ว", + "studio_names_separated_by_comma": "ชื่อสตูดิโอคั่นแต่ละแห่ง คั่นด้วยเครื่องหมายคอมมา (,)", + "to_use_the_studio_tagger": "ต้องทำการตั้งค่า stash-box ก่อนถึงจะใช้งานเครื่องมือเพิ่มข้อมูลสตูดิโอได้", + "status_tagging_studios": "สถานะ: กำลังเพิ่มข้อมูลสตูดิโอ", + "studio_already_tagged": "มีข้อมูลสตูดิโอนี้แล้ว", + "add_new_studios": "เพิ่มสตูดิโอ", + "current_page": "หน้าปัจจุบัน", + "name_already_exists": "พบสตูดิโอที่ใช้ชื่อนี้แล้ว", + "number_of_studios_will_be_processed": "จะอัปเดตสตูดิโอจำนวน {studio_count} แห่ง", + "query_all_studios_in_the_database": "สตูดิโอทั้งหมดในฐานข้อมูล", + "refreshing_will_update_the_data": "การรีเฟรชจะอัปเดตสตูดิโอที่มีข้อมูลแล้วด้วยข้อมูลจาก stash-box ที่เลือก", + "update_studio": "อัพเดตสตูดิโอ", + "studio_selection": "การเลือกสตูดิโอ", + "studio_successfully_tagged": "เพิ่มข้อมูลสตูดิโอสำเร็จแล้ว", + "tag_status": "สถานะการเพิ่มข้อมูล", + "update_studios": "อัพเดตสตูดิโอ" }, "circumcised_types": { "UNCUT": "ยกเลิกการตัด", @@ -655,12 +821,96 @@ }, "setup": { "paths": { - "where_can_stash_store_its_database_description": "Stash ใช้ระบบฐานข้อมูล SQLite เพื่อจัดเก็บข้อมูล metadata ของกรุหนังของคุณ หากไม่ได้ระบุเป็นอย่างอื่น ไฟล์ stash-go.sqlite จะถูกสร้างขึ้นในไดเร็กทอรีเดียวกันกับไฟล์ config หากต้องการระบุค่าเอง ให้ระบุตำแหน่งและชื่อไฟล์ที่ต้องการอย่าง absolute หรือ relative (อ้างอิงกับไดเร็กทอรีปัจจุบัน)", - "stash_alert": "ไม่ได้ตั้งค่าตำแหน่งไลบรารีไว้ จะไม่มีการเพิ่มเนื้อหาใดๆ เข้าสู่ Stash คุณแน่ใจหรือไม่?" + "where_can_stash_store_its_database_description": "Stash ใช้ระบบฐานข้อมูล SQLite เพื่อจัดเก็บข้อมูล metadata ของกรุหนังของคุณ หากไม่ได้ระบุเป็นอย่างอื่น ไฟล์ stash-go.sqlite จะถูกสร้างขึ้นในไดเร็กทอรีเดียวกันกับไฟล์การตั้งค่า หากต้องการระบุค่าเองให้ระบุตำแหน่งและชื่อไฟล์ที่ต้องการแบบ absolute หรือ relative", + "stash_alert": "ไม่ได้ตั้งค่าตำแหน่งไลบรารีไว้ จะไม่มีการเพิ่มเนื้อหาใดๆ เข้าสู่ Stash คุณแน่ใจหรือไม่?", + "database_filename_empty_for_default": "ชื่อฐานข้อมูล (เว้นว่างไว้เพื่อใช้ค่าปริยาย)", + "path_to_cache_directory_empty_for_default": "ไดเร็กทอรีสำหรับไฟล์แคช (เว้นว่างไว้เพื่อใช้ค่าปริยาย)", + "description": "ถัดไปเป็นการตั้งค่าตำแหน่งที่ต้องการเก็บไฟล์หนังของคุณ และตำแหน่งสำหรับบันทึกฐานข้อมูล ไฟล์ที่ถูกสร้าง และไฟล์แคช โดยคุณสามารถเปลี่ยนแปลงค่าเหล่านี้ได้ภายหลัง", + "set_up_your_paths": "ระบุตำแหน่งไฟล์", + "path_to_generated_directory_empty_for_default": "ไดเร็กทอรีสำหรับไฟล์ที่ถูกสร้างขึ้น (เว้นว่างไว้เพื่อใช้ค่าปริยาย)", + "path_to_blobs_directory_empty_for_default": "ไดเร็กทอรีสำหรับไฟล์ blob (เว้นว่างไว้เพื่อใช้ค่าปริยาย)", + "store_blobs_in_database": "จัดเก็บ blob ในฐานข้อมูล", + "where_can_stash_store_blobs": "คุณต้องการให้ Stash จัดเก็บฐานข้อมูลที่ไหน?", + "where_can_stash_store_its_database": "ต้องการให้ Stash เก็บไฟล์ฐานข้อมูลที่ไหน?", + "where_can_stash_store_cache_files": "คุณต้องการให้ Stash เก็บไฟล์แคชที่ไหน?", + "where_can_stash_store_blobs_description_addendum": "อีกทางเลือกหนึ่งคือคุณสามารถตั้งค่าให้เก็บบันทึกข้อมูลในฐานข้อมูลก็ได้เช่นกัน หมายเหตุ: ทางเลือกนี้จะทำให้ขนาดไฟล์ฐานข้อมูลใหญ่ขึ้นและใช้เวลาในการโยกย้ายนานขึ้นด้วย", + "where_can_stash_store_cache_files_description": "Stash ต้องการที่เก็บแคชสำหรับไฟล์ใช้งานชั่วคราวเพื่อให้ฟังก์ชันการแปลงวิดีโอสดแบบ HSL/DASH ทำงานได้ หากไม่ได้ระบุเป็นอย่างอื่น Stash จะสร้างไดเร็กทอรี cache ไว้ในไดเร็กทอรีเดียวกันกับไฟล์การตั้งค่า หากต้องการระบุค่าเองให้ระบุตำแหน่งไดเร็กทอรีแบบ absolute หรือ relative หากไม่มีไดเร็กทอรีนี้อยู่ Stash จะสร้างใหม่ให้", + "where_can_stash_store_blobs_description": "Stash สามารถบันทึกข้อมูลไบนารี เช่น หน้าปกซีน นักแสดง สตูดิโอ และภาพประกอบแท็ก ในฐานข้อมูลหรือในไฟล์ระบบก็ได้ หากไม่ได้ระบุเป็นอย่างอื่น Stash จะจัดเก็บในไฟล์ระบบที่ไดเร็กทอรีย่อย blobs ซึ่งอยู่ในไดเร็กทอรีเดียวกันกับไฟล์การตั้งค่า หากต้องการระบุค่าเองให้ระบุตำแหน่งไดเร็กทอรีในรูปแบบ absolute หรือ relative หากไม่มีไดเร็กทอรีนี้อยู่ Stash จะสร้างใหม่ให้", + "where_can_stash_store_its_generated_content": "ต้องการให้ Stash เก็บเนื้อหาที่ถูกสร้างขึ้นที่ไหน?", + "where_can_stash_store_its_generated_content_description": "Stash จะสร้างไฟล์รูปภาพและวิดีโอขึ้นเพื่อใช้เป็นไฟล์ตัวอย่างสำหรับเนื้อหา รวมถึงการแปลงไฟล์จากรูปแบบที่ไม่รองรับด้วย หากไม่ได้ระบุเป็นอย่างอื่น Stash จะสร้างไดเร็กทอรี generated ขึ้นในไดเร็กทอรีเดียวกับไฟล์การตั้งค่า หากต้องการระบุค่าเองให้ระบุตำแหน่งไดเร็กทอรีในรูปแบบ absolute หรือ relative หากไม่มีไดเร็กทอรีนี้อยู่ Stash จะสร้างใหม่ให้", + "where_is_your_porn_located": "ไฟล์หนังชมพูของคุณอยู่ที่ไหน?", + "where_can_stash_store_its_database_warning": "คำเตือน: Stash ไม่รองรับการจัดเก็บฐานข้อมูลในที่อื่นๆ ที่ไม่ใช่เซิร์ฟเวอร์ Stash! (เช่นจัดเก็บฐานข้อมูลบน NAS แต่เซิร์ฟเวอร์อยู่ที่คอมพิวเตอร์เครื่องอื่น) ระบบฐานข้อมูล SQLite ไม่ได้ถูกออกแบบมาสำหรับการทำงานบนเน็ตเวิร์คและอาจทำให้ฐานข้อมูลพังได้", + "where_is_your_porn_located_description": "เพิ่มไดเร็กทอรีที่มีไฟล์หนังชมพูและรูปภาพชมพูของคุณ Stash จะสแกนและเพิ่มไฟล์เหล่านั้นเข้าสู่ฐานข้อมูล" }, "confirm": { - "stash_library_directories": "ไดเร็กทอรีสำหรับไลบรารี Stash" - } + "stash_library_directories": "ไดเร็กทอรีสำหรับไลบรารี Stash", + "almost_ready": "การตั้งค่าใกล้สำเร็จ กรุณายืนยันค่าต่อไปนี้ คุณสามารถคลิกย้อนกลับเพื่อกลับไปเปลี่ยนแปลงค่าได้ หากข้อมูลทุกอย่างถูกต้องคลิดยืนยันเพื่อเริ่มสร้างระบบ", + "blobs_use_database": "", + "nearly_there": "เกือบเสร็จแล้ว!", + "cache_directory": "ไดเร็กทอรีสำหรับแคช", + "blobs_directory": "ไดเร็กทอรีสำหรับข้อมูล binary", + "configuration_file_location": "ตำแหน่งไฟล์บันทึกการตั้งค่า:", + "database_file_path": "ตำแหน่งไฟล์ฐานข้อมูล", + "generated_directory": "ไดเร็กทอรีสำหรับไฟล์ที่ถูกสร้างขึ้น" + }, + "migrate": { + "migration_irreversible_warning": "การย้ายฐานข้อมูลแบบ schema เป็นการย้ายแบบถาวร เมื่อย้ายสำเร็จฐานข้อมูลนี้จะไม่สามารถใช้งานกับ stash รุ่นก่อนหน้าได้", + "migration_notes": "Migration Notes", + "migration_required": "จำเป็นต้องย้ายฐานข้อมูล", + "migration_failed_error": "พบเจอปัญหาต่อไปนี้ระหว่างการย้ายฐานข้อมูล:", + "schema_too_old": "ฐานข้อมูลปัจจุบันของคุณเป็นรุ่น {databaseSchema} ซึ่งจำเป็นต้องทำการย้ายขึ้นไปเป็นรุ่น {appSchema} คุณไม่สามารถใช้งาน Stash รุ่นนี้ได้โดยไม่ทำการย้ายฐานข้อมูล หากไม่ต้องการคุณสามารถดาวน์เกรดกลับไปใช้รุ่นก่อนหน้าที่เข้ากันได้กับฐานข้อมูลของคุณ", + "backup_recommended": "การสำรองไฟล์ฐานข้อมูลก่อนทำการย้ายฐานข้อมูลเป็นแนวทางปฏิบัติที่ดิ เราช่วยทำให้คุณได้โดยจะบันทึกไฟล์สำรองไว้ที่ {defaultBackupPath}", + "backup_database_path_leave_empty_to_disable_backup": "ตำแหน่งสำหรับสำรองไฟล์ฐานข้อมูล (เว้นว่างไว้หากไม่ต้องการสำรองข้อมูล):", + "migrating_database": "กำลังย้ายฐานข้อมูล", + "migration_failed": "การย้ายฐานข้อมูลไม่สำเร็จ", + "migration_failed_help": "กรุณาแก้ไขก่อนลองอีกครั้ง หากไม่สำเร็จคุณสามารถรายงานบั๊กได้ที่ {githubLink} หรือขอความช่วยเหลือได้ที่ {discordLink}", + "perform_schema_migration": "เริ่มการย้ายฐานข้อมูลแบบ schema" + }, + "creating": { + "creating_your_system": "กำลังสร้างระบบ" + }, + "errors": { + "something_went_wrong": "ไม่นะ! เกิดข้อผิดพลาดบางอย่าง!", + "something_went_wrong_description": "หากปัญหาเกิดขึ้นจากการตั้งค่าไม่ถูกต้อง คลิกย้อนกลับเพื่อกลับไปแก้ไขให้ถูกต้อง หากเป็นกรณีอื่นคุณสามารถรายงานบั๊กได้ที่ {githubLink} หรือขอความช่วยเหลือได้ที่ {discordLink}", + "something_went_wrong_while_setting_up_your_system": "เกิดข้อผิดพลาดระหว่างสร้างระบบ ข้อมูลความผิดพลาดคือ: {error}" + }, + "folder": { + "up_dir": "ย้อนไดเร็กทอรีขึ้นไปหนึ่งระดับ", + "file_path": "ตำแหน่งไฟล์" + }, + "github_repository": "Github repository", + "success": { + "support_us": "สนับสนุนเรา", + "thanks_for_trying_stash": "ขอบคุณที่เลือกใช้ Stash!", + "help_links": "หากพบเจอปัญหา มีข้อสงสัย หรือข้อเสนอแนะ สามารถรายงานปัญหาได้ที่ {githubLink} หรือขอความช่วยเหลือได้ที่ {discordLink}", + "in_app_manual_explained": "เราแนะนำให้ศึกษาคู่มือการใช้งานภายใน Stash โดยคลิกที่ไอคอน {icon} ทางด้านขวาบนของหน้าจอ", + "missing_ffmpeg": "ไม่พบไฟล์ ffmpeg คุณสามารถดาวน์โหลดและติดตั้งในไดเร็กทอรีการตั้งค่าได้ทันทีโดยติ๊กถูกที่กล่องด้านล่างนี้ หรือคุณสามารถระบุตำแหน่งไฟล์ ffmpeg และ ffprobe ได้ด้วยตนเองในหน้าระบบ", + "next_config_step_one": "หน้าถัดไปเป็นหน้าการตั้งค่าต่างๆ ของระบบ เช่นปรับแต่งการค้นหาไฟล์ ตั้งรหัสล็อกอิน เป็นต้น", + "next_config_step_two": "หากตั้งค่าเสร็จสิ้นแล้ว คุณสามารถเริ่มสแกนเนื้อหาได้โดยไปที่หน้า {localized_task} แล้วคลิก {localized_scan}", + "welcome_contrib": "เรายินดีต้อนรับผู้อยากสนับสนุนเราในทุกรูปแบบเสมอ ไม่ว่าจะเป็นการโค้ด (แก้บั๊ก การปรับปรุงโค้ด หรือการเพิ่มความสามารถระบบ) ร่วมทดสอบระบบ รายงานบั๊ก เสนอข้อปรับปรุง เสนอขอความสามารถใหม่ๆ หรือการขอความช่วยเหลือก็ตาม โดยสามารถดูรายละเอียดเพิ่มเติมได้ในคู่มือภายใน Stash", + "your_system_has_been_created": "การติดตั้งสำเร็จ! ระบบถูกสร้างเรียบร้อยแล้ว!", + "download_ffmpeg": "ดาวน์โหลด FFmpeg", + "getting_help": "ขอความช่วยเหลือ", + "open_collective": "อย่าลืมแวะไปที่ {open_collective_link} เพื่อร่วมเป็นส่วนหนึ่งในการพัฒนา Stash ของเรา" + }, + "welcome_specific_config": { + "config_path": "Stash จะใช้ตำแหน่งเก็บไฟล์การตั้งค่าดังนี้: {path}", + "unable_to_locate_specified_config": "Stash ไม่พบไฟล์การตั้งค่าที่ตำแหน่งที่ระบุไว้ ตัวช่วยการติดตั้งนี้จะช่วยดำเนินการปรับแต่งการตั้งค่าให้คุณ", + "next_step": "คลิกต่อไปเมื่อพร้อมดำเนินการต่อ" + }, + "welcome": { + "in_the_current_working_directory": "ภายในไดเร็กทอรีที่ทำงานอยู่ {path}:", + "in_the_current_working_directory_disabled_macos": "ไม่สามารถทำงานได้ในขณะที่ Stash.app ทำงานอยู่

เรียกใช้ stash-macos เพื่อติดตั้งในไดเร็กทอรีที่ทำงานอยู่", + "in_the_current_working_directory_disabled": "ภายในไดเร็กทอรีที่ทำงานอยู่ {path}:", + "store_stash_config": "ต้องการเก็บไฟล์การตั้งค่าไว้ที่ไหน?", + "config_path_logic_explained": "Stash จะมองหาไฟล์การตั้งค่า (config.yml) จากไดเร็กทอรีที่ทำงานอยู่ก่อนเสมอ หากไม่เจอจึงจะใช้ค่า {fallback_path} แทน คุณสามารถตั้งให้ Stash มองหาไฟล์จากตำแหน่งที่ต้องการได้โดยการระบุ -c '' or --config ''", + "unable_to_locate_config": "ไม่พบไฟล์การตั้งค่าที่มีอยู่ ตัวช่วยการติดตั้งนี้จะช่วยดำเนินการปรับแต่งการตั้งค่าให้คุณ", + "in_current_stash_directory": "ภายในไดเร็กทอรี {path}:", + "next_step": "กรุณาระบุตำแหน่งสำหรับจัดเก็บไฟล์การตั้งค่า", + "unexpected_explained": "พบปัญหาบางอย่าง กรุณารีสตาร์ท Stash ในไดเร็กทอรีที่ถูกต้องด้วยธง -c" + }, + "welcome_to_stash": "ยินดีต้อนรับสู่ Stash", + "stash_setup_wizard": "ตัวช่วยการติดตั้ง Stash" }, "chapters": "ฉาก", "studio_and_parent": "สตูดิโอและบริษัทแม่", @@ -668,7 +918,38 @@ "parent_studio": "สตูดิโอบริษัทแม่", "performer_tagger": { "updating_untagged_performers_description": "อัปเดตนักแสดงที่ยังไม่มีข้อมูลโดยการค้นหานักแสดงที่ไม่มี stashid และทำการอัพเดตข้อมูล metadata", - "untagged_performers": "นักแสดงที่ยังไม่มีข้อมูล" + "untagged_performers": "นักแสดงที่ยังไม่มีข้อมูล", + "add_new_performers": "เพิ่มนักแสดง", + "performer_selection": "การเลือกนักแสดง", + "performer_successfully_tagged": "เพิ่มข้อมูลนักแสดงสำเร็จแล้ว:", + "tag_status": "สถานะการเพิ่มข้อมูล", + "refresh_tagged_performers": "รีเฟรชนักแสดงที่มีข้อมูลแล้ว", + "batch_add_performers": "เพิ่มนักแสดงพร้อมกันหลายคน", + "config": { + "active_stash-box_instance": "แหล่งข้อมูล stash-box ที่เลือกใช้:", + "excluded_fields": "ข้อมูลที่ไม่ต้องการใช้งาน:", + "no_fields_are_excluded": "ไม่มีข้อมูลที่ไม่ต้องการใช้งาน", + "these_fields_will_not_be_changed_when_updating_performers": "ข้อมูลประเภทที่เลือกไว้จะไม่ถูกเปลี่ยนแปลงหรืออัปเดต", + "no_instances_found": "ไม่พบแหล่งข้อมูล", + "edit_excluded_fields": "แก้ไขประเภทข้อมูลที่ไม่ต้องการใช้งาน" + }, + "batch_update_performers": "อัปเดตข้อมูลนักแสดงพร้อมกันหลายคน", + "network_error": "พบปัญหาเน็ตเวิร์ค", + "any_names_entered_will_be_queried": "รายชื่อนักแสดงจะถูกเรียกหาข้อมูลจาก stash-box ที่เลือกไว้ โดยจะเลือกเฉพาะข้อมูลที่ตรงกันเท่านั้น", + "failed_to_save_performer": "ไม่สามารถบันทึกข้อมูลนักแสดง \"{performer}\" ได้", + "no_results_found": "ไม่พบผลลัพธ์", + "update_performer": "เครื่องมืออัปเดตข้อมูลนักแสดง", + "to_use_the_performer_tagger": "ต้องทำการตั้งค่า stash-box ก่อนถึงจะใช้งานเครื่องมือเพิ่มข้อมูลนักแสดงได้", + "performer_names_separated_by_comma": "ชื่อนักแสดงแต่ละคน คั่นด้วยเครื่องหมายคอมมา (,)", + "refreshing_will_update_the_data": "การรีเฟรชจะอัปเดตนักแสดงที่มีข้อมูลแล้วด้วยข้อมูลจาก stash-box ที่เลือก", + "name_already_exists": "พบนักแสดงที่ใช้ชื่อนี้แล้ว", + "number_of_performers_will_be_processed": "จะอัปเดตนักแสดงจำนวน {performer_count} คน", + "status_tagging_performers": "สถานะ: กำลังเพิ่มข้อมูลนักแสดง", + "update_performers": "เครื่องมืออัปเดตข้อมูลนักแสดง", + "status_tagging_job_queued": "สถานะ: งานเพิ่มข้อมูลถูกเพิ่มเข้าคิว", + "current_page": "หน้าปัจจุบัน", + "performer_already_tagged": "มีข้อมูลนักแสดงคนนี้แล้ว", + "query_all_performers_in_the_database": "นักแสดงทั้งหมดในฐานข้อมูล" }, "library": "ไลบราลี", "audio_codec": "โคเด็คเสียง", @@ -679,5 +960,510 @@ "circumcised": "ขลิบ", "appears_with": "แสดงร่วมกับ", "parent_studios": "สตูดิโอบริษัทแม่", - "between_and": "และ" + "between_and": "และ", + "criterion_modifier": { + "greater_than": "มากกว่า", + "not_null": "ไม่ถูกเว้นว่าง", + "is_null": "ถูกเว้นว่าง", + "matches_regex": "ตรงกับ regex", + "equals": "คือ", + "between": "ระหว่าง", + "includes": "มี", + "includes_all": "มีทั้งหมด", + "less_than": "น้อยกว่า", + "not_between": "ไม่อยู่ระหว่าง", + "not_equals": "ไม่ใช่", + "not_matches_regex": "ไม่ตรงกับ regex", + "excludes": "ยกเว้น", + "format_string": "{criterion} {modifierString} {valueString}", + "format_string_depth": "{criterion} {modifierString} {valueString} (+{depth, plural, =-1 {all} other {{depth}}})", + "format_string_excludes": "{criterion} {modifierString} {valueString} (excludes {excludedString})", + "format_string_excludes_depth": "{criterion} {modifierString} {valueString} (excludes {excludedString}) (+{depth, plural, =-1 {all} other {{depth}}})" + }, + "country": "ประเทศ", + "dialogs": { + "clear_play_history_confirm": "คุณแน่ใจว่าต้องการล้างประวัติการเล่นใช่หรือไม่?", + "clear_o_history_confirm": "คุณแน่ใจว่าต้องการล้างประวัติ O ใช่หรือไม่?", + "edit_entity_title": "แก้ไข{count, plural, one {{singularEntity}} other {{pluralEntity}}}", + "lightbox": { + "delay": "การหน่วงเวลา (วินาที)", + "scroll_mode": { + "pan_y": "Pan Y", + "label": "โหมดการเลื่อนเปลี่ยนภาพ", + "zoom": "ซูม", + "description": "กดปุ่ม shift ค้างไว้เพื่อใช้สลับใช้งานอีกโหมดชั่วคราว" + }, + "display_mode": { + "label": "โหมดแสดงภาพ", + "fit_to_screen": "ขนาดพอดีขนาดจอ", + "original": "ขนาดตามจริง", + "fit_horizontally": "ขนาดพอดีความกว้างจอ" + }, + "options": "ตัวเลือก", + "page_header": "หน้าที่ {page} / {total}", + "reset_zoom_on_nav": "รีเซ็ตระดับการซูมเมื่อเปลี่ยนภาพ", + "scale_up": { + "description": "ขยายภาพที่มีขนาดเล็กให้พอดีหน้าจอ", + "label": "ขยายภาพให้พอดีจอ" + } + }, + "scene_gen": { + "image_previews_tooltip": "สร้างภาพเคลื่อนไหวตัวอย่าง (webp) เปิดใช้งานเฉพาะเมื่อเลือกประเภทของภาพตัวอย่างสำหรับกำแพงซีน/มาร์คเกอร์เป็นแบบภาพเคลื่อนไหวเท่านั้น ใช้ทรัพยากร CPU น้อยกว่าวิดีโอตัวอย่าง แต่จะใช้เนื้อที่เพิ่มขึ้น", + "marker_image_previews_tooltip": "สร้างภาพเคลื่อนไหวตัวอย่าง (webp) เปิดใช้งานเฉพาะเมื่อเลือกประเภทของภาพตัวอย่างสำหรับกำแพงซีน/มาร์คเกอร์เป็นแบบภาพเคลื่อนไหวเท่านั้น ใช้ทรัพยากร CPU น้อยกว่าวิดีโอตัวอย่าง แต่จะใช้เนื้อที่เพิ่มขึ้น", + "preview_exclude_start_time_desc": "ไม่รวม x วินาทีแรกของวิดีโอในพรีวิว สามารถระบุค่าเป็นวินาทีหรือร้อยละของระยะเวลาทั้งหมดของซีน", + "preview_preset_desc": "พรีเซ็ตมีผลกับขนาด คุณภาพ และระยะเวลาในการเข้ารหัสไฟล์พรีวิว ไม่แนะนำให้ใช้พรีเซ็ตที่ช้ากว่า \"ช้า\" เพราะแทบไม่เห็นความแตกต่างในคุณภาพ", + "transcodes_tooltip": "วิดีโอทั้งหมดจะถูกแปลงเป็น MP4 ไว้ล่วงหน้า มีประโยชน์กับคอมพิวเตอร์ที่มี CPU ไม่แรง แต่กินพื้นที่ดิสก์มาก", + "overwrite": "เขียนทับไฟล์", + "clip_previews": "คลิปภาพตัวอย่าง", + "force_transcodes_tooltip": "โดยทั่วไปไฟล์จะถูก transcode เมื่อบราวเซอร์ไม่รองรับประเภทไฟล์ที่ต้องการเล่น เมื่อเปิดใช้งานตัวเลือกนี้ไฟล์ที่เล่นจะถูก transcode เสมอแม้บราวเซอร์จะรองรับ", + "image_thumbnails": "ภาพตัวอย่างขนาดเล็ก", + "marker_screenshots_tooltip": "สร้างภาพนิ่ง JPG สำหรับมาร์คเกอร์ ใช้เมื่อตั้งค่ารูปแบบการพรีวิวเป็นภาพนิ่ง", + "markers": "การพรีวิวมาร์คเกอร์", + "override_preview_generation_options": "ไม่สนใจการตั้งค่าตัวเลือกการสร้างพรีวิวของระบบ", + "preview_exclude_end_time_desc": "ไม่รวม x วินาทีสุดท้ายของวิดีโอในพรีวิว สามารถระบุค่าเป็นวินาทีหรือร้อยละของระยะเวลาทั้งหมดของซีน", + "preview_exclude_end_time_head": "ไม่รวมช่วงท้ายวิดีโอ", + "preview_options": "ตัวเลือกพรีวิว", + "transcodes": "แปลงไฟล์", + "preview_seg_duration_desc": "ระยะเวลาของแต่ละตอนในพรีวิว หน่วยเป็นวินาที", + "preview_seg_duration_head": "ระยะเวลาของตอน", + "sprites": "ภาพนิ่งในแถบกรอวิดีโอ", + "phash": "Perceptual hashes", + "preview_exclude_start_time_head": "ไม่รวมช่วงต้นวิดีโอ", + "covers": "หน้าปกซีน", + "image_previews": "ภาพเคลื่อนไหวตัวอย่าง", + "interactive_heatmap_speed": "สร้าง heatmaps และกราฟความเร็วสำหรับซีนอินเตอร์แอ็คทีฟ", + "marker_image_previews": "สร้างภาพเคลื่อนไหวตัวอย่างสำหรับมาร์คเกอร์", + "marker_screenshots": "ภาพหน้าจอมาร์คเกอร์", + "override_preview_generation_options_desc": "ไม่สนใจการตั้งค่าตัวเลือกการสร้างพรีวิวของระบบ เปลี่ยนค่าปริยายที่หน้า ระบบ -> การสร้างพริวิว", + "preview_generation_options": "ตัวเลือกการสร้างพรีวิว", + "preview_preset_head": "พรีเซ็ตการเข้ารหัสพรีวิว", + "preview_seg_count_desc": "จำนวนตอนในไฟล์พรีวิว", + "preview_seg_count_head": "จำนวนตอนในไฟล์พรีวิว", + "sprites_tooltip": "กลุ่มภาพนิ่งที่ใช้แสดงในแถบกรอวิดีโอเพื่อช่วยในการกรอ", + "video_previews": "พรีวิว", + "video_previews_tooltip": "วิดีโอพรีวิวที่จะเล่นเมื่อวางเคอร์เซอร์บนซีน", + "force_transcodes": "บังคับให้ transcode", + "markers_tooltip": "วิดีโอตัวอย่างยาว 20 วินาที", + "phash_tooltip": "ช่วยค้นหาไฟล์ซ้ำและการจำแนกซีน" + }, + "scrape_entity_query": "คำค้นหาเพื่อ scrape {entity_type}", + "dont_show_until_updated": "ไม่แสดงผลอีกจนถึงอัปเดตถัดไป", + "merge": { + "destination": "ปลายทาง", + "empty_results": "ค่าปลายทางจะไม่มีการเปลี่ยนแปลง", + "source": "ต้นทาง" + }, + "delete_alert": "ไฟล์{count, plural, one {{singularEntity}} other {{pluralEntity}}}ต่อไปนี้จะถูกลบอย่างถาวร:", + "delete_entity_desc": "{count, plural, one {คุณแน่ใจว่าต้องการลบ{singularEntity}ใช่หรือไม่? หากไม่เลือกให้ลบไฟล์ทิ้งด้วย {singularEntity}เหล่านี้จะถูกเพิ่มกลับเข้ามาใหม่เมื่อทำการสแกนอีกครั้ง} other {คุณแน่ใจว่าต้องการลบ{pluralEntity}ใช่หรือไม่? หากไม่เลือกให้ลบไฟล์ทิ้งด้วย {pluralEntity} เหล่านี้จะถูกเพิ่มกลับเข้ามาใหม่เมื่อทำการสแกนอีกครั้ง}}", + "delete_confirm": "คุณแน่ใจว่าต้องการลบ{entityName}ใช่หรือไม่?", + "delete_entity_simple_desc": "{count, plural, one {คุณแน่ใจว่าต้องการลบ{singularEntity}ใช่หรือไม่?} other {คุณแน่ใจว่าต้องการลบ{pluralEntity}ใช่หรือไม่?}}", + "delete_entity_title": "{count, plural, one {ลบ{singularEntity}} other {ลบ{pluralEntity}}}", + "export_include_related_objects": "รวมไฟล์อื่นๆ ที่เกี่ยวข้องในการส่งออกข้อมูลด้วย", + "export_title": "ส่งออกข้อมูล", + "imagewall": { + "direction": { + "description": "ทิศทางเลย์เอาท์ทางตั้งหรือทางนอน", + "column": "ทางตั้ง", + "row": "ทางนอน" + }, + "margin_desc": "ขนาดพื้นที่ขอบรอบรูปภาพ" + }, + "delete_object_desc": "คุณแน่ใจว่าต้องการลบ{count, plural, one {{singularEntity}} other {{pluralEntity}}}นี้ใช่หรือไม่?", + "delete_object_title": "ลบ{count, plural, one {{singularEntity}} other {{pluralEntity}}}", + "create_new_entity": "สร้าง {entity} ใหม่", + "delete_galleries_extra": "…รวมถึงไฟล์ภาพต่างๆ ที่ไม่เกี่ยวข้องกับแกลเลอรีอื่นๆ ด้วย", + "delete_gallery_files": "ลบโฟลเดอร์แกลเลอรี/ไฟล์ซิปและรูปภาพอื่นๆ ที่ไม่เกี่ยวข้องกับแกลเลอรีอื่นๆ", + "delete_object_overflow": "…และอีก {count} {count, plural, one {{singularEntity}} other {{pluralEntity}}}", + "merge_tags": { + "destination": "ปลายทาง", + "source": "ต้นทาง" + }, + "overwrite_filter_confirm": "คุณแน่ใจว่าต้องการเขียนทับเงื่อนไขการค้นหา{entityName}ใช่หรือไม่?", + "reassign_files": { + "destination": "ย้ายไปที่" + }, + "scenes_found": "พบซีน {count} ซีน", + "scrape_entity_title": "ผลการค้นหาการ scape {entity_type}", + "scrape_results_existing": "ข้อมูลที่มีอยู่แล้ว", + "scrape_results_scraped": "ข้อมูลที่พบ", + "set_image_url_title": "URL ภาพ", + "unsaved_changes": "ยังไม่ได้บันทึกการเปลี่ยนแปลง คุณแน่ใจว่าต้องการออกจากหน้านี้ใช่หรือไม่?", + "performers_found": "พบนักแสดง {count} คน", + "reassign_entity_title": "{count, plural, one {ย้าย{singularEntity}} other {ย้าย{pluralEntity}}}" + }, + "cover_image": "ภาพหน้าปก", + "dimensions": "ขนาด", + "director": "ผู้กำกับ", + "dupe_check": { + "duration_diff": "ความต่างของระยะเวลาวิดีโอสูงสุด", + "duration_options": { + "equal": "ระยะเวลาต้องเท่ากัน", + "any": "เท่าไหร่ก็ได้" + }, + "only_select_matching_codecs": "ทำการเลือกถ้าโคเด็คของทุกไฟล์ในกลุ่มตรงกัน", + "select_none": "ไม่เลือกไฟล์ใดๆ", + "select_all_but_largest_resolution": "เลือกทุกไฟล์ยกเว้นไฟล์ที่มีความละเอียดสูงสุด", + "options": { + "low": "ต่ำ", + "medium": "ปานกลาง", + "exact": "แม่นยำ", + "high": "สูง" + }, + "title": "ซีนที่ซ้ำ", + "description": "ตัวเลือกที่ต่ำกว่า \"แม่นยำ\" จะใช้เวลาในการประมวลผลมากขึ้น และจะได้ผลลัพธ์ที่ไม่ตรงมากขึ้นเช่นกัน", + "found_sets": "{setCount, plural, one{พบไฟล์ซ้ำ # กลุ่ม} other {พบไฟล์ซ้ำ # กลุ่ม}}", + "search_accuracy_label": "ความแม่นยำในการค้นหา", + "select_all_but_largest_file": "เลือกทุกไฟล์ยกเว้นไฟล์ที่มีขนาดใหญ่สุด", + "select_oldest": "เลือกไฟล์ที่เก่าที่สุด", + "select_options": "ตัวเลือกการเลือกไฟล์…", + "select_youngest": "เลือกไฟล์ที่ใหม่ที่สุด" + }, + "effect_filters": { + "rotate_right_and_scale": "หมุนขวาและปรับขนาด", + "blur": "เบลอ", + "brightness": "ความสว่าง", + "contrast": "คอนทราสต์", + "aspect": "ขนาดภาพ", + "saturation": "ความอิ่มสี", + "scale": "ขนาด", + "hue": "เนื้อสี", + "name": "ฟิลเตอร์", + "name_transforms": "ปรับขนาด", + "rotate_left_and_scale": "หมุนซ้ายและปรับขนาด", + "gamma": "แกมมา", + "green": "เขียว", + "red": "แดง", + "reset_filters": "รีเซ็ตฟิลเตอร์", + "reset_transforms": "รีเซ็ตการปรับขนาด", + "warmth": "โทนอุ่น", + "blue": "น้ำเงิน", + "rotate": "หมุน" + }, + "empty_server": "เพิ่มเนื้อหาเข้าสู่เซิร์ฟเวอร์เพื่อแสดงผลไฟล์แนะนำในหน้านี้", + "existing_value": "ค่าที่มีอยู่", + "errors": { + "image_index_greater_than_zero": "สารบัญภาพต้องมากกว่า 0", + "something_went_wrong": "มีบางอย่างผิดพลาด", + "lazy_component_error_help": "หากคุณอัปเดต Stash เมื่อไม่นานมานี้ กรุณารีโหลดหน้านี้หรือล้างแคชของบราวเซอร์", + "header": "ข้อผิดพลาด", + "loading_type": "พบข้อผิดพลาดในการโหลด{type}" + }, + "eye_color": "สีตา", + "fake_tits": "หน้าอก", + "false": "ปลอม", + "favourite": "ชอบ", + "file_count": "จำนวนไฟล์", + "gender": "เพศสภาพ", + "gender_types": { + "MALE": "ชาย", + "INTERSEX": "กำกวม", + "TRANSGENDER_FEMALE": "หญิงข้ามเพศ", + "FEMALE": "หญิง", + "NON_BINARY": "นอน-ไบนารี", + "TRANSGENDER_MALE": "ชายข้ามเพศ" + }, + "images": "รูปภาพ", + "filesize": "ขนาดไฟล์", + "markers": "มาร์คเกอร์", + "marker_count": "จำนวนมาร์คเกอร์", + "media_info": { + "checksum": "Checksum", + "audio_codec": "โคเด็คเสียง", + "phash": "PHash", + "play_count": "จำนวนครั้งที่เล่น", + "o_count": "จำนวน O", + "performer_card": { + "age": "อายุ {age} {years_old}", + "age_context": "อายุ {age} {years_old} ในเรื่องนี้" + }, + "downloaded_from": "ดาวน์โหลดมาจาก", + "hash": "Hash", + "play_duration": "ระยะเวลาที่เล่น", + "stream": "สตรีม", + "video_codec": "โคเด็ควิดีโอ", + "interactive_speed": "ความเร็วอินเตอร์แอ็คทีฟ" + }, + "package_manager": { + "add_source": "เพิ่มแหล่งข้อมูล", + "source": { + "url": "URL แหล่งข้อมูล", + "name": "ชื่อเรื่อง", + "local_path": { + "description": "ตำแหน่งไฟล์แบบ relative เพื่อจัดเก็บแพ็กเกจของแหล่งข้อมูลนี้ หากเปลี่ยนแปลงค่าต้องทำการย้ายไฟล์แพ็กเกจด้วยตนเอง", + "heading": "ตำแหน่งในเครื่องคอมพิวเตอร์" + } + }, + "edit_source": "แก้ไขแหล่งข้อมูล", + "install": "ติดตั้ง", + "confirm_uninstall": "คุณแน่ใจหรือไม่ว่าต้องการยกเลิกการติดตั้งแพ็กเกจ {number} แพ็กเกจ?", + "description": "คำอธิบาย", + "show_all": "แสดงทั้งหมด", + "uninstall": "ถอนการติดตั้ง", + "unknown": "", + "no_upgradable": "ไม่มีแพ็กเกจที่ต้องอัปเดต", + "required_by": "ถูกใช้งานโดย {packages}", + "selected_only": "เฉพาะที่เลือกไว้", + "update": "อัปเดต", + "check_for_updates": "ตรวจสอบอัปเดต", + "confirm_delete_source": "คุณแน่ใจว่าต้องการลบแหล่งข้อมูล {name} ({url}) ใช่หรือไม่?", + "hide_unselected": "ซ่อนรายการที่ไม่ได้เลือก", + "installed_version": "เวอร์ชันที่ติดตั้ง", + "latest_version": "เวอร์ชันล่าสุด", + "no_packages": "ไม่พบแพ็กเกจ", + "no_sources": "ไม่ได้ตั้งค่าแหล่งข้อมูล", + "package": "แพ็กเกจ", + "version": "เวอร์ชัน" + }, + "perceptual_similarity": "Perceptual Similarity (pHash)", + "performer_age": "อายุ", + "performer_image": "รูปถ่ายนักแสดง", + "configuration": "ปรับแต่งการตั้งค่า", + "developmentVersion": "เวอร์ชันของการพัฒนา", + "file_info": "ข้อมูลไฟล์", + "file_mod_time": "เวลาแก้ไขไฟล์ล่าสุด", + "front_page": { + "types": { + "saved_filter": "ฟิลเตอร์ที่บันทึกไว้", + "premade_filter": "ฟิลเตอร์สำเร็จรูป" + } + }, + "help": "ความช่วยเหลือ", + "last_played_at": "เล่นครั้งสุดท้ายเมื่อ", + "measurements": "สัดส่วน", + "folder": "โฟลเดอร์", + "framerate": "อัตราเฟรม", + "frames_per_second": "{value} เฟรมต่อวินาที", + "penis_length_cm": "ความยาวองคชาต (ซม.)", + "connection_monitor": { + "websocket_connection_failed": "ไม่สามารถเชื่อมต่อ websocket ได้ ตรวจสอบรายละเอียดเพิ่มเติมที่คอนโซลของบราวเซอร์", + "websocket_connection_reestablished": "เชื่อมต่อ websocket สำเร็จ" + }, + "created_at": "เวลาที่สร้าง", + "criterion": { + "greater_than": "มากกว่า", + "less_than": "น้อยกว่า", + "value": "ข้อความ" + }, + "custom": "กำหนดเอง", + "date": "วันที่", + "date_format": "YYYY-MM-DD", + "datetime_format": "YYYY-MM-DD HH:MM", + "death_date": "วันที่เสียชีวิต", + "death_year": "ปีที่เสียชีวิต", + "descending": "เรียกจากมากไปน้อย", + "description": "คำอธิบาย", + "detail": "รายละเอียด", + "details": "รายละเอียด", + "display_mode": { + "unknown": "ไม่มีข้อมูล", + "grid": "กริด", + "list": "รายการ", + "tagger": "เครื่องมือแท็ก", + "wall": "กำแพง" + }, + "duplicated_phash": "ไฟล์ที่ซ้ำ (pHash)", + "filter_name": "ชื่อฟิลเตอร์", + "filters": "ฟิลเตอร์", + "height_cm": "ความสูง (ซม.)", + "history": "ประวัติ", + "image": "รูปภาพ", + "image_count": "จำนวนรูปภาพ", + "image_index": "รูปภาพที่ #", + "megabits_per_second": "{value} mbps", + "movie": "หนัง", + "o_count": "จำนวน O", + "orientation": "ทิศทาง", + "parent_of": "บริษัทแม่ของ {children}", + "pagination": { + "previous": "ก่อนหน้า", + "first": "หน้าแรก", + "last": "หน้าสุดท้าย", + "next": "ถัดไป" + }, + "gallery": "แกลเลอรี", + "gallery_count": "จำนวนแกลเลอรี", + "handy_connection_status": { + "missing": "ไม่พบอุปกรณ์", + "connecting": "กำลังเชื่อมต่อ", + "disconnected": "ตัดการเชื่อมต่อ", + "error": "พบข้อผิดพลาดในการติดต่อ Handy", + "ready": "พร้อมใช้งาน", + "syncing": "กำลังซิงค์กับเซิร์ฟเวอร์", + "uploading": "กำลังอัปโหลดสคริปท์" + }, + "index_of_total": "รายการที่ {index} จาก {total}", + "galleries": "แกลเลอรี", + "interactive_speed": "ความเร็วอินเตอร์แอ็คทีฟ", + "include_sub_studios": "นับรวมสตูดิโอลูกด้วย", + "isMissing": "ที่ไม่มี", + "include_sub_tags": "นับรวมแท็กย่อยด้วย", + "last_o_at": "O ครั้งสุดท้ายเมื่อ", + "instagram": "อินสตาแกรม", + "interactive": "อินเตอร์แอ็คทีฟ", + "performer": "นักแสดง", + "movie_scene_number": "ลำดับซีน", + "movies": "หนัง", + "name": "ชื่อเรื่อง", + "new": "เพิ่ม", + "none": "ไม่มี", + "o_counter": "O-Counter", + "o_history": "ประวัติ O", + "organized": "จัดระเบียบแล้ว", + "disambiguation": "แก้ความกำกวม", + "distance": "ระยะทาง", + "duration": "ความยาว", + "ethnicity": "เชื้อชาติ", + "file": "ไฟล์", + "files": "ไฟล์", + "files_amount": "{value} ไฟล์", + "filter": "ฟิลเตอร์", + "hair_color": "สีผม", + "hasChapters": "มีฉาก", + "hasMarkers": "มีมาร์คเกอร์", + "height": "ความสูง", + "ignore_auto_tag": "ไม่สนใจการแท็กอัตโนมัติ", + "include_parent_tags": "นับรวมแท็กหลักด้วย", + "loading": { + "generic": "กำลังโหลด…" + }, + "odate_recorded_no": "ไม่มีประวัติ O", + "parent_tag_count": "จำนวนแท็กย่อย", + "parent_tags": "แท็กหลัก", + "part_of": "เป็นส่วนหนึ่งของ {parent}", + "path": "ตำแหน่ง", + "penis": "องคชาต", + "penis_length": "ความยาวองคชาต", + "performer_count": "จำนวนนักแสดง", + "donate": "บริจาค", + "search_filter": { + "update_filter": "อัปเดตฟิลเตอร์", + "name": "ฟิลเตอร์ค้นหา", + "saved_filters": "ฟิลเตอร์ที่บันทึกไว้", + "edit_filter": "แก้ไขฟิลเตอร์ค้นหา" + }, + "seconds": "วินาที", + "piercings": "การเจาะ", + "play_count": "จำนวนครั้งที่เล่น", + "play_duration": "รายะเวลาที่เล่น", + "play_history": "ประวัติการเล่น", + "scene_created_at": "เวลาที่ถูกสร้าง", + "resume_time": "เวลาที่จะเริ่มเล่น", + "rating": "คะแนน", + "recently_added_objects": "{objects} ที่เพิ่งถูกเพิ่ม", + "random": "สุ่ม", + "scene": "ซีน", + "scenes": "ซีน", + "second": "วินาที", + "settings": "การตั้งค่า", + "recently_released_objects": "{objects} ที่เพิ่งวางขาย", + "release_notes": "Release Notes", + "sceneTagger": "เครื่องมือเพิ่มข้อมูลซีน", + "scene_tags": "แท็กของซีน", + "performer_favorite": "ชอบใจ", + "performer_tags": "แท็กของนักแสดง", + "performers": "นักแสดง", + "photographer": "ช่างภาพ", + "playdate_recorded_no": "ไม่พบบันทึกการเล่น", + "plays": "เล่นแล้ว {value} ครั้ง", + "primary_file": "ไฟล์หลัก", + "primary_tag": "แท็กหลัก", + "queue": "คิว", + "resolution": "ความละเอียด", + "scene_code": "รหัสไฟล์", + "scene_count": "จำนวนซีน", + "scene_id": "รหัสซีน", + "scene_updated_at": "วันที่อัปเดตซีน", + "scenes_updated_at": "วันที่อัปเดตซีน", + "scene_date": "วันที่ปล่อยซีน", + "stash_ids": "Stash ID", + "stash_id_endpoint": "Stash ID Endpoint", + "stats": { + "total_play_duration": "ระยะเวลาที่เล่นรวม", + "total_o_count": "จำนวน O-Count ทั้งหมด", + "total_play_count": "จำนวนครั้งที่เล่นรวม", + "scenes_played": "จำนวนซีนที่เล่นแล้ว", + "image_size": "ขนาดรูปภาพ", + "scenes_duration": "ระยะเวลารวมทุกซีน", + "scenes_size": "ขนาดซีนทั้งหมด" + }, + "studio": "สตูดิโอ", + "studios": "สตูดิโอ", + "true": "จริง", + "twitter": "ทวิตเตอร์", + "video_codec": "โคเด็ควิดีโอ", + "weight_kg": "น้ำหนัก (กก.)", + "years_old": "ปี", + "zip_file_count": "จำนวนไฟล์ซิป", + "countables": { + "images": "{count, plural, one {รูปภาพ} other {รูปภาพ}}", + "scenes": "{count, plural, one {ซีน} other {ซีน}}", + "studios": "{count, plural, one {สตูดิโอ} other {สตูดิโอ}}", + "tags": "{count, plural, one {แท็ก} other {แท็ก}}", + "files": "{count, plural, one {ไฟล์} other {ไฟล์}}", + "galleries": "{count, plural, one {แกลเลอรี} other {แกลเลอรี}}", + "markers": "{count, plural, one {มาร์คเกอร์} other {มาร์คเกอร์}}", + "movies": "{count, plural, one {หนัง} other {หนัง}}", + "performers": "{count, plural, one {นักแสดง} other {นักแสดง}}" + }, + "sub_tags": "แท็กย่อย", + "synopsis": "เรื่องย่อ", + "tag": "แท็ก", + "toast": { + "merged_scenes": "รวมซีนแล้ว", + "delete_past_tense": "ลบ{count, plural, one {{singularEntity}} other {{pluralEntity}}}แล้ว", + "added_generation_job_to_queue": "เพิ่มงานสร้างไฟล์เพิ่มเติมในคิวแล้ว", + "generating_screenshot": "กำลังสร้างภาพหน้าจอ…", + "rescanning_entity": "กำลังสแกน{count, plural, one {{singularEntity}} other {{pluralEntity}}}…", + "started_auto_tagging": "เริ่มงานเพิ่มข้อมูลอัตโนมัติแล้ว", + "started_importing": "เริ่มการนำเข้าข้อมูลแล้ว", + "added_entity": "เพิ่ม{count, plural, one {{singularEntity}} other {{pluralEntity}}}แล้ว", + "created_entity": "สร้าง{entity}แล้ว", + "default_filter_set": "ชุดฟิลเตอร์พื้นฐาน", + "image_index_too_large": "พบความผิดพลาด: index รูปภาพมีขนาดใหญ่กว่าจำนวนรูปภาพในแกลเลอรี", + "merged_tags": "รวมแท็กแล้ว", + "reassign_past_tense": "ย้ายไฟล์แล้ว", + "removed_entity": "ลบ{count, plural, one {{singularEntity}} other {{pluralEntity}}}แล้ว", + "saved_entity": "บันทึก{entity}แล้ว", + "started_generating": "เริ่มสร้างไฟล์เพิ่มเติมแล้ว", + "updated_entity": "อัปเดต{entity}แล้ว" + }, + "stashbox": { + "submit_update": "มีอยู่แล้วที่ {endpoint_name}", + "go_review_draft": "ไปที่ {endpoint_name} เพื่อตรวจสอบการตั้งค่า", + "selected_stash_box": "Stash-Box endpoint ที่เลือกไว้", + "source": "แหล่งข้อมูล Stash-Box", + "submission_failed": "การส่งข้อมูลไม่สำเร็จ", + "submission_successful": "การส่งข้อมูลสำเร็จ" + }, + "studio_depth": "ระดับ (เว้นว่างเพื่อนับทั้งหมด)", + "type": "ประเภท", + "url": "URL", + "urls": "URLs", + "validation": { + "date_invalid_form": "${path} ต้องอยู่ในรูปแบบ YYYY-MM-DD", + "required": "จำเป็นต้องระบุ ${path}", + "blank": "${path} ต้องไม่เว้นว่างไว้", + "unique": "${path} ต้องไม่ซ้ำ" + }, + "operations": "ปฏิบัติการ", + "statistics": "สถิติ", + "status": "สถานะ: {statusText}", + "subsidiary_studio_count": "จำนวนสตูดิโอย่อย", + "subsidiary_studios": "สตูดิโอย่อย", + "tag_count": "จำนวนแท็ก", + "tag_parent_tooltip": "มีแท็กหลัก", + "sub_tag_count": "จำนวนแท็กย่อย", + "sub_tag_of": "เป็นแท็กย่อยของ {parent}", + "tag_sub_tag_tooltip": "มีแท็กย่อย", + "tags": "แท็ก", + "tattoos": "รอยสัก", + "time": "เวลา", + "title": "ชื่อเรื่อง", + "updated_at": "อัปเดตเมื่อ", + "total": "ทั้งหมด", + "unknown_date": "ไม่ระบุวันที่", + "view_all": "ดูทั้งหมด", + "weight": "น้ำหนัก", + "videos": "วิดีโอ", + "stash_id": "Stash ID" } diff --git a/ui/v2.5/src/locales/zh-CN.json b/ui/v2.5/src/locales/zh-CN.json index f972a74f206..97613a7e90b 100644 --- a/ui/v2.5/src/locales/zh-CN.json +++ b/ui/v2.5/src/locales/zh-CN.json @@ -1013,7 +1013,9 @@ "lazy_component_error_help": "如果您最近升级了 Stash,请重新加载页面或清除浏览器缓存。", "something_went_wrong": "出了些问题。", "header": "错误", - "loading_type": "加载 {type} 出错" + "loading_type": "加载 {type} 出错", + "invalid_javascript_string": "无效的javascript代码:{error}", + "invalid_json_string": "无效的JSON字符串:{error}" }, "ethnicity": "人种", "existing_value": "现值", @@ -1119,7 +1121,8 @@ "first": "首页", "last": "尾页", "next": "下一页", - "previous": "上一页" + "previous": "上一页", + "current_total": "第{current}页, 共 {total}页" }, "parent_of": "{children}的上级", "parent_studios": "上级工作室", @@ -1465,5 +1468,8 @@ "websocket_connection_reestablished": "Websocket连接已重新建立", "websocket_connection_failed": "无法建立websocket连接:有关详细信息,请参阅浏览器控制台" }, - "o_count": "高潮次数" + "o_count": "高潮次数", + "movie_count": "影片数量", + "studio_tags": "工作室标签", + "studio_count": "工作室计数" } From 2b1a57c6d079436d07f302d4cfabc4d772d87498 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Fri, 21 Jun 2024 16:15:59 +1000 Subject: [PATCH 023/103] Fix key for tagger scenes (#5000) --- ui/v2.5/src/components/Tagger/scenes/SceneTagger.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/v2.5/src/components/Tagger/scenes/SceneTagger.tsx b/ui/v2.5/src/components/Tagger/scenes/SceneTagger.tsx index c8b1c43d9ff..ab6bd226e33 100755 --- a/ui/v2.5/src/components/Tagger/scenes/SceneTagger.tsx +++ b/ui/v2.5/src/components/Tagger/scenes/SceneTagger.tsx @@ -264,7 +264,7 @@ export const Tagger: React.FC = ({ scenes, queue }) => {
{filteredScenes.map((s, i) => ( Date: Fri, 21 Jun 2024 16:16:16 +1000 Subject: [PATCH 024/103] Fix save default filter not clearing criteria (#4999) --- .../src/components/List/SavedFilterList.tsx | 21 ++++++++----------- 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/ui/v2.5/src/components/List/SavedFilterList.tsx b/ui/v2.5/src/components/List/SavedFilterList.tsx index e10ca5fd63b..63654c028ce 100644 --- a/ui/v2.5/src/components/List/SavedFilterList.tsx +++ b/ui/v2.5/src/components/List/SavedFilterList.tsx @@ -10,7 +10,7 @@ import { Tooltip, } from "react-bootstrap"; import { - useConfigureUI, + useConfigureUISetting, useFindSavedFilters, useSavedFilterDestroy, useSaveFilter, @@ -51,7 +51,7 @@ export const SavedFilterList: React.FC = ({ const [saveFilter] = useSaveFilter(); const [destroyFilter] = useSavedFilterDestroy(); - const [saveUI] = useConfigureUI(); + const [saveUISetting] = useConfigureUISetting(); const savedFilters = data?.findSavedFilters ?? []; @@ -136,17 +136,14 @@ export const SavedFilterList: React.FC = ({ try { setSaving(true); - await saveUI({ + await saveUISetting({ variables: { - partial: { - defaultFilters: { - [view.toString()]: { - mode: filter.mode, - find_filter: filterCopy.makeFindFilter(), - object_filter: filterCopy.makeSavedFilter(), - ui_options: filterCopy.makeSavedUIOptions(), - }, - }, + key: `defaultFilters.${view.toString()}`, + value: { + mode: filter.mode, + find_filter: filterCopy.makeFindFilter(), + object_filter: filterCopy.makeSavedFilter(), + ui_options: filterCopy.makeSavedUIOptions(), }, }, }); From 6775a28ec7ef6046bed417c962dc43b0b62a66db Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Mon, 24 Jun 2024 13:15:54 +1000 Subject: [PATCH 025/103] Add schema migration to fix view_date format (#4992) Also adds index on scene_id and adds a not null constraint to scene_id --- pkg/sqlite/database.go | 2 +- pkg/sqlite/migrations/64_fixes.up.sql | 49 +++++++++++++ pkg/sqlite/migrations/64_postmigrate.go | 92 +++++++++++++++++++++++++ 3 files changed, 142 insertions(+), 1 deletion(-) create mode 100644 pkg/sqlite/migrations/64_fixes.up.sql create mode 100644 pkg/sqlite/migrations/64_postmigrate.go diff --git a/pkg/sqlite/database.go b/pkg/sqlite/database.go index 6436efee873..84220b39811 100644 --- a/pkg/sqlite/database.go +++ b/pkg/sqlite/database.go @@ -30,7 +30,7 @@ const ( dbConnTimeout = 30 ) -var appSchemaVersion uint = 63 +var appSchemaVersion uint = 64 //go:embed migrations/*.sql var migrationsBox embed.FS diff --git a/pkg/sqlite/migrations/64_fixes.up.sql b/pkg/sqlite/migrations/64_fixes.up.sql new file mode 100644 index 00000000000..6128c292d3c --- /dev/null +++ b/pkg/sqlite/migrations/64_fixes.up.sql @@ -0,0 +1,49 @@ +PRAGMA foreign_keys=OFF; + +-- recreate scenes_view_dates adding not null to scene_id and adding indexes +CREATE TABLE `scenes_view_dates_new` ( + `scene_id` integer not null, + `view_date` datetime not null, + foreign key(`scene_id`) references `scenes`(`id`) on delete CASCADE +); + +INSERT INTO `scenes_view_dates_new` + ( + `scene_id`, + `view_date` + ) + SELECT + `scene_id`, + `view_date` + FROM `scenes_view_dates` + WHERE `scenes_view_dates`.`scene_id` IS NOT NULL; + +DROP INDEX IF EXISTS `index_scenes_view_dates`; +DROP TABLE `scenes_view_dates`; +ALTER TABLE `scenes_view_dates_new` rename to `scenes_view_dates`; +CREATE INDEX `index_scenes_view_dates` ON `scenes_view_dates` (`scene_id`); + +-- recreate scenes_o_dates adding not null to scene_id and adding indexes +CREATE TABLE `scenes_o_dates_new` ( + `scene_id` integer not null, + `o_date` datetime not null, + foreign key(`scene_id`) references `scenes`(`id`) on delete CASCADE +); + +INSERT INTO `scenes_o_dates_new` + ( + `scene_id`, + `o_date` + ) + SELECT + `scene_id`, + `o_date` + FROM `scenes_o_dates` + WHERE `scenes_o_dates`.`scene_id` IS NOT NULL; + +DROP INDEX IF EXISTS `index_scenes_o_dates`; +DROP TABLE `scenes_o_dates`; +ALTER TABLE `scenes_o_dates_new` rename to `scenes_o_dates`; +CREATE INDEX `index_scenes_o_dates` ON `scenes_o_dates` (`scene_id`); + +PRAGMA foreign_keys=ON; \ No newline at end of file diff --git a/pkg/sqlite/migrations/64_postmigrate.go b/pkg/sqlite/migrations/64_postmigrate.go new file mode 100644 index 00000000000..ecf2910501a --- /dev/null +++ b/pkg/sqlite/migrations/64_postmigrate.go @@ -0,0 +1,92 @@ +package migrations + +import ( + "context" + "fmt" + "time" + + "github.com/jmoiron/sqlx" + "github.com/stashapp/stash/pkg/logger" + "github.com/stashapp/stash/pkg/sqlite" +) + +// this is a copy of the 55 post migration +// some non-UTC dates were missed, so we need to correct them + +type schema64Migrator struct { + migrator +} + +func post64(ctx context.Context, db *sqlx.DB) error { + logger.Info("Running post-migration for schema version 64") + + m := schema64Migrator{ + migrator: migrator{ + db: db, + }, + } + + return m.migrate(ctx) +} + +func (m *schema64Migrator) migrate(ctx context.Context) error { + // the last_played_at column was storing in a different format than the rest of the timestamps + // convert the play history date to the correct format + if err := m.withTxn(ctx, func(tx *sqlx.Tx) error { + query := "SELECT DISTINCT `scene_id`, `view_date` FROM `scenes_view_dates`" + + rows, err := m.db.Query(query) + if err != nil { + return err + } + defer rows.Close() + + for rows.Next() { + var ( + id int + viewDate sqlite.Timestamp + ) + + err := rows.Scan(&id, &viewDate) + if err != nil { + return err + } + + // skip if already in the correct format + if viewDate.Timestamp.Location() == time.UTC { + logger.Debugf("view date %s is already in the correct format", viewDate.Timestamp) + continue + } + + utcTimestamp := sqlite.UTCTimestamp{ + Timestamp: viewDate, + } + + // convert the timestamp to the correct format + logger.Debugf("correcting view date %q to UTC date %q for scene %d", viewDate.Timestamp, viewDate.Timestamp.UTC(), id) + r, err := m.db.Exec("UPDATE scenes_view_dates SET view_date = ? WHERE view_date = ? OR view_date = ?", utcTimestamp, viewDate.Timestamp, viewDate) + if err != nil { + return fmt.Errorf("error correcting view date %s to %s: %w", viewDate.Timestamp, viewDate, err) + } + + rowsAffected, err := r.RowsAffected() + if err != nil { + return err + } + + if rowsAffected == 0 { + return fmt.Errorf("no rows affected when updating view date %s to %s for scene %d", viewDate.Timestamp, viewDate.Timestamp.UTC(), id) + } + } + + return rows.Err() + }); err != nil { + return err + } + + return nil +} + +func init() { + sqlite.RegisterPostMigration(64, post64) +} From a4e25f32ea8e8f59fcda4fb40670e4f6450f157f Mon Sep 17 00:00:00 2001 From: NodudeWasTaken <75137537+NodudeWasTaken@users.noreply.github.com> Date: Mon, 24 Jun 2024 05:33:27 +0200 Subject: [PATCH 026/103] Add apple encoder and fix extra_hw_frames bug (#4986) * Fixes format in full hw encoding to nv12 for cuda, vaapi and qsv now * Remove extra_hw_frames * Add apple transcoder support * Up the duration to discover decoding errors * yuv420p is not supported on intel --------- Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com> --- pkg/ffmpeg/codec_hardware.go | 162 +++++++++++++++++++++++++-------- pkg/ffmpeg/ffmpeg.go | 14 +++ pkg/ffmpeg/ffmpeg_test.go | 75 +++++++++++++++ pkg/ffmpeg/filter.go | 31 +++---- pkg/ffmpeg/stream_segmented.go | 2 +- pkg/ffmpeg/stream_transcode.go | 4 +- 6 files changed, 227 insertions(+), 61 deletions(-) create mode 100644 pkg/ffmpeg/ffmpeg_test.go diff --git a/pkg/ffmpeg/codec_hardware.go b/pkg/ffmpeg/codec_hardware.go index 4c39cb3b91a..e4797a84adc 100644 --- a/pkg/ffmpeg/codec_hardware.go +++ b/pkg/ffmpeg/codec_hardware.go @@ -4,7 +4,9 @@ import ( "bytes" "context" "fmt" + "math" "regexp" + "strconv" "strings" "github.com/stashapp/stash/pkg/logger" @@ -25,7 +27,7 @@ var ( VideoCodecVVPX VideoCodec = "vp8_vaapi" ) -const minHeight int = 256 +const minHeight int = 480 // Tests all (given) hardware codec's func (f *FFMpeg) InitHWSupport(ctx context.Context) { @@ -38,17 +40,19 @@ func (f *FFMpeg) InitHWSupport(ctx context.Context) { VideoCodecR264, VideoCodecIVP9, VideoCodecVVP9, + VideoCodecM264, } { var args Args args = append(args, "-hide_banner") args = args.LogLevel(LogLevelWarning) args = f.hwDeviceInit(args, codec, false) args = args.Format("lavfi") - args = args.Input(fmt.Sprintf("color=c=red:s=%dx%d", 1280, 720)) + vFile := &models.VideoFile{Width: 1280, Height: 720} + args = args.Input(fmt.Sprintf("color=c=red:s=%dx%d", vFile.Width, vFile.Height)) args = args.Duration(0.1) // Test scaling - videoFilter := f.hwMaxResFilter(codec, 1280, 720, minHeight, false) + videoFilter := f.hwMaxResFilter(codec, vFile, minHeight, false) args = append(args, CodecInit(codec)...) args = args.VideoFilter(videoFilter) @@ -93,9 +97,9 @@ func (f *FFMpeg) hwCanFullHWTranscode(ctx context.Context, codec VideoCodec, vf args = args.XError() args = f.hwDeviceInit(args, codec, true) args = args.Input(vf.Path) - args = args.Duration(0.1) + args = args.Duration(1) - videoFilter := f.hwMaxResFilter(codec, vf.Width, vf.Height, reqHeight, true) + videoFilter := f.hwMaxResFilter(codec, vf, reqHeight, true) args = append(args, CodecInit(codec)...) args = args.VideoFilter(videoFilter) @@ -128,12 +132,12 @@ func (f *FFMpeg) hwDeviceInit(args Args, toCodec VideoCodec, fullhw bool) Args { args = append(args, "-hwaccel_device") args = append(args, "0") if fullhw { + args = append(args, "-threads") + args = append(args, "1") args = append(args, "-hwaccel") args = append(args, "cuda") args = append(args, "-hwaccel_output_format") args = append(args, "cuda") - args = append(args, "-extra_hw_frames") - args = append(args, "5") } case VideoCodecV264, VideoCodecVVP9: @@ -158,6 +162,16 @@ func (f *FFMpeg) hwDeviceInit(args Args, toCodec VideoCodec, fullhw bool) Args { args = append(args, "-filter_hw_device") args = append(args, "hw") } + case VideoCodecM264: + if fullhw { + args = append(args, "-hwaccel") + args = append(args, "videotoolbox") + args = append(args, "-hwaccel_output_format") + args = append(args, "videotoolbox_vld") + } else { + args = append(args, "-init_hw_device") + args = append(args, "videotoolbox=vt") + } } return args @@ -175,7 +189,7 @@ func (f *FFMpeg) hwFilterInit(toCodec VideoCodec, fullhw bool) VideoFilter { } case VideoCodecN264: if !fullhw { - videoFilter = videoFilter.Append("format=nv12") + videoFilter = videoFilter.Append("format=yuv420p") videoFilter = videoFilter.Append("hwupload_cuda") } case VideoCodecI264, @@ -184,80 +198,146 @@ func (f *FFMpeg) hwFilterInit(toCodec VideoCodec, fullhw bool) VideoFilter { videoFilter = videoFilter.Append("hwupload=extra_hw_frames=64") videoFilter = videoFilter.Append("format=qsv") } + case VideoCodecM264: + if !fullhw { + videoFilter = videoFilter.Append("format=nv12") + videoFilter = videoFilter.Append("hwupload") + } } return videoFilter } -var scaler_re = regexp.MustCompile(`scale=(?P[-\d]+:[-\d]+)`) +var scaler_re = regexp.MustCompile(`scale=(?P([-\d]+):([-\d]+))`) -func templateReplaceScale(input string, template string, match []int, minusonehack bool) string { +func templateReplaceScale(input string, template string, match []int, vf *models.VideoFile, minusonehack bool) string { result := []byte{} - res := string(scaler_re.ExpandString(result, template, input, match)) - - // BUG: [scale_qsv]: Size values less than -1 are not acceptable. - // Fix: Replace all instances of -2 with -1 in a scale operation if minusonehack { - res = strings.ReplaceAll(res, "-2", "-1") + // Parse width and height + w, err := strconv.Atoi(input[match[4]:match[5]]) + if err != nil { + logger.Error("failed to parse width") + return input + } + h, err := strconv.Atoi(input[match[6]:match[7]]) + if err != nil { + logger.Error("failed to parse height") + return input + } + + // Calculate ratio + ratio := float64(vf.Width) / float64(vf.Height) + if w < 0 { + w = int(math.Round(float64(h) * ratio)) + } else if h < 0 { + h = int(math.Round(float64(w) / ratio)) + } + + // Fix not divisible by 2 errors + if w%2 != 0 { + w++ + } + if h%2 != 0 { + h++ + } + + template = strings.ReplaceAll(template, "$value", fmt.Sprintf("%d:%d", w, h)) } + res := string(scaler_re.ExpandString(result, template, input, match)) + matchStart := match[0] matchEnd := match[1] return input[0:matchStart] + res + input[matchEnd:] } -// Replace video filter scaling with hardware scaling for full hardware transcoding -func (f *FFMpeg) hwCodecFilter(args VideoFilter, codec VideoCodec, fullhw bool) VideoFilter { +// Replace video filter scaling with hardware scaling for full hardware transcoding (also fixes the format) +func (f *FFMpeg) hwCodecFilter(args VideoFilter, codec VideoCodec, vf *models.VideoFile, fullhw bool) VideoFilter { sargs := string(args) match := scaler_re.FindStringSubmatchIndex(sargs) if match == nil { - return args + return f.hwApplyFullHWFilter(args, codec, fullhw) } + return f.hwApplyScaleTemplate(sargs, codec, match, vf, fullhw) +} + +// Apply format switching if applicable +func (f *FFMpeg) hwApplyFullHWFilter(args VideoFilter, codec VideoCodec, fullhw bool) VideoFilter { switch codec { case VideoCodecN264: - template := "scale_cuda=$value" - // In 10bit inputs you might get an error like "10 bit encode not supported" - if fullhw && f.version.major >= 5 { - template += ":format=nv12" + if fullhw && f.version.Gteq(FFMpegVersion{major: 5}) { // Added in FFMpeg 5 + args = args.Append("scale_cuda=format=yuv420p") + } + case VideoCodecV264, VideoCodecVVP9: + if fullhw && f.version.Gteq(FFMpegVersion{major: 3, minor: 1}) { // Added in FFMpeg 3.1 + args = args.Append("scale_vaapi=format=nv12") + } + case VideoCodecI264, VideoCodecIVP9: + if fullhw && f.version.Gteq(FFMpegVersion{major: 3, minor: 3}) { // Added in FFMpeg 3.3 + args = args.Append("scale_qsv=format=nv12") } - args = VideoFilter(templateReplaceScale(sargs, template, match, false)) - case VideoCodecV264, - VideoCodecVVP9: - template := "scale_vaapi=$value" - args = VideoFilter(templateReplaceScale(sargs, template, match, false)) - case VideoCodecI264, - VideoCodecIVP9: - template := "scale_qsv=$value" - args = VideoFilter(templateReplaceScale(sargs, template, match, true)) } return args } +// Switch scaler +func (f *FFMpeg) hwApplyScaleTemplate(sargs string, codec VideoCodec, match []int, vf *models.VideoFile, fullhw bool) VideoFilter { + var template string + + switch codec { + case VideoCodecN264: + template = "scale_cuda=$value" + if fullhw && f.version.Gteq(FFMpegVersion{major: 5}) { // Added in FFMpeg 5 + template += ":format=yuv420p" + } + case VideoCodecV264, VideoCodecVVP9: + template = "scale_vaapi=$value" + if fullhw && f.version.Gteq(FFMpegVersion{major: 3, minor: 1}) { // Added in FFMpeg 3.1 + template += ":format=nv12" + } + case VideoCodecI264, VideoCodecIVP9: + template = "scale_qsv=$value" + if fullhw && f.version.Gteq(FFMpegVersion{major: 3, minor: 3}) { // Added in FFMpeg 3.3 + template += ":format=nv12" + } + case VideoCodecM264: + template = "scale_vt=$value" + default: + return VideoFilter(sargs) + } + + // BUG: [scale_qsv]: Size values less than -1 are not acceptable. + isIntel := codec == VideoCodecI264 || codec == VideoCodecIVP9 + // BUG: scale_vt doesn't call ff_scale_adjust_dimensions, thus cant accept negative size values + isApple := codec == VideoCodecM264 + return VideoFilter(templateReplaceScale(sargs, template, match, vf, isIntel || isApple)) +} + // Returns the max resolution for a given codec, or a default -func (f *FFMpeg) hwCodecMaxRes(codec VideoCodec, dW int, dH int) (int, int) { +func (f *FFMpeg) hwCodecMaxRes(codec VideoCodec) (int, int) { switch codec { case VideoCodecN264, VideoCodecI264: return 4096, 4096 } - return dW, dH + return 0, 0 } // Return a maxres filter -func (f *FFMpeg) hwMaxResFilter(toCodec VideoCodec, width int, height int, reqHeight int, fullhw bool) VideoFilter { - if width == 0 || height == 0 { +func (f *FFMpeg) hwMaxResFilter(toCodec VideoCodec, vf *models.VideoFile, reqHeight int, fullhw bool) VideoFilter { + if vf.Width == 0 || vf.Height == 0 { return "" } videoFilter := f.hwFilterInit(toCodec, fullhw) - maxWidth, maxHeight := f.hwCodecMaxRes(toCodec, width, height) - videoFilter = videoFilter.ScaleMaxLM(width, height, reqHeight, maxWidth, maxHeight) - return f.hwCodecFilter(videoFilter, toCodec, fullhw) + maxWidth, maxHeight := f.hwCodecMaxRes(toCodec) + videoFilter = videoFilter.ScaleMaxLM(vf.Width, vf.Height, reqHeight, maxWidth, maxHeight) + return f.hwCodecFilter(videoFilter, toCodec, vf, fullhw) } // Return if a hardware accelerated for HLS is available @@ -267,7 +347,8 @@ func (f *FFMpeg) hwCodecHLSCompatible() *VideoCodec { case VideoCodecN264, VideoCodecI264, VideoCodecV264, - VideoCodecR264: + VideoCodecR264, + VideoCodecM264: // Note that the Apple encoder sucks at startup, thus HLS quality is crap return &element } } @@ -279,7 +360,8 @@ func (f *FFMpeg) hwCodecMP4Compatible() *VideoCodec { for _, element := range f.hwCodecSupport { switch element { case VideoCodecN264, - VideoCodecI264: + VideoCodecI264, + VideoCodecM264: return &element } } diff --git a/pkg/ffmpeg/ffmpeg.go b/pkg/ffmpeg/ffmpeg.go index e929cc7f8dc..5ee98a87332 100644 --- a/pkg/ffmpeg/ffmpeg.go +++ b/pkg/ffmpeg/ffmpeg.go @@ -195,6 +195,20 @@ type FFMpegVersion struct { patch int } +// Gteq returns true if the version is greater than or equal to the other version. +func (v FFMpegVersion) Gteq(other FFMpegVersion) bool { + if v.major > other.major { + return true + } + if v.major == other.major && v.minor > other.minor { + return true + } + if v.major == other.major && v.minor == other.minor && v.patch >= other.patch { + return true + } + return false +} + // FFMpeg provides an interface to ffmpeg. type FFMpeg struct { ffmpeg string diff --git a/pkg/ffmpeg/ffmpeg_test.go b/pkg/ffmpeg/ffmpeg_test.go new file mode 100644 index 00000000000..3e9151ed9f9 --- /dev/null +++ b/pkg/ffmpeg/ffmpeg_test.go @@ -0,0 +1,75 @@ +// Package ffmpeg provides a wrapper around the ffmpeg and ffprobe executables. +package ffmpeg + +import "testing" + +func TestFFMpegVersion_GreaterThan(t *testing.T) { + tests := []struct { + name string + this FFMpegVersion + other FFMpegVersion + want bool + }{ + { + "major greater, minor equal, patch equal", + FFMpegVersion{2, 0, 0}, + FFMpegVersion{1, 0, 0}, + true, + }, + { + "major greater, minor less, patch less", + FFMpegVersion{2, 1, 1}, + FFMpegVersion{1, 0, 0}, + true, + }, + { + "major equal, minor greater, patch equal", + FFMpegVersion{1, 1, 0}, + FFMpegVersion{1, 0, 0}, + true, + }, + { + "major equal, minor equal, patch greater", + FFMpegVersion{1, 0, 1}, + FFMpegVersion{1, 0, 0}, + true, + }, + { + "major equal, minor equal, patch equal", + FFMpegVersion{1, 0, 0}, + FFMpegVersion{1, 0, 0}, + true, + }, + { + "major less, minor equal, patch equal", + FFMpegVersion{1, 0, 0}, + FFMpegVersion{2, 0, 0}, + false, + }, + { + "major equal, minor less, patch equal", + FFMpegVersion{1, 0, 0}, + FFMpegVersion{1, 1, 0}, + false, + }, + { + "major equal, minor equal, patch less", + FFMpegVersion{1, 0, 0}, + FFMpegVersion{1, 0, 1}, + false, + }, + { + "major less, minor less, patch less", + FFMpegVersion{1, 0, 0}, + FFMpegVersion{2, 1, 1}, + false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.this.Gteq(tt.other); got != tt.want { + t.Errorf("FFMpegVersion.GreaterThan() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/pkg/ffmpeg/filter.go b/pkg/ffmpeg/filter.go index 52be57c9c06..dd6ecc10699 100644 --- a/pkg/ffmpeg/filter.go +++ b/pkg/ffmpeg/filter.go @@ -59,33 +59,28 @@ func (f VideoFilter) ScaleMax(inputWidth, inputHeight, maxSize int) VideoFilter return f.ScaleDimensions(maxSize, -2) } -// ScaleMaxLM returns a VideoFilter scaling to maxSize with respect to a max size. +// ScaleMaxLM scales an image to fit within specified maximum dimensions while maintaining its aspect ratio. func (f VideoFilter) ScaleMaxLM(width int, height int, reqHeight int, maxWidth int, maxHeight int) VideoFilter { - // calculate the aspect ratio of the current resolution - aspectRatio := width / height + if maxWidth == 0 || maxHeight == 0 { + return f.ScaleMax(width, height, reqHeight) + } - // find the max height + aspectRatio := float64(width) / float64(height) desiredHeight := reqHeight if desiredHeight == 0 { desiredHeight = height } + desiredWidth := int(float64(desiredHeight) * aspectRatio) - // calculate the desired width based on the desired height and the aspect ratio - desiredWidth := int(desiredHeight * aspectRatio) - - // check which dimension to scale based on the maximum resolution - if desiredHeight > maxHeight || desiredWidth > maxWidth { - if desiredHeight-maxHeight > desiredWidth-maxWidth { - // scale the height down to the maximum height - return f.ScaleDimensions(-2, maxHeight) - } else { - // scale the width down to the maximum width - return f.ScaleDimensions(maxWidth, -2) - } + if desiredHeight <= maxHeight && desiredWidth <= maxWidth { + return f.ScaleMax(width, height, reqHeight) } - // the current resolution can be scaled to the desired height without exceeding the maximum resolution - return f.ScaleMax(width, height, reqHeight) + if float64(desiredHeight-maxHeight) > float64(desiredWidth-maxWidth) { + return f.ScaleDimensions(-2, maxHeight) + } else { + return f.ScaleDimensions(maxWidth, -2) + } } // Fps returns a VideoFilter setting the frames per second. diff --git a/pkg/ffmpeg/stream_segmented.go b/pkg/ffmpeg/stream_segmented.go index 1058fb8eb27..56ef392f152 100644 --- a/pkg/ffmpeg/stream_segmented.go +++ b/pkg/ffmpeg/stream_segmented.go @@ -342,7 +342,7 @@ func (s *runningStream) makeStreamArgs(sm *StreamManager, segment int) Args { videoOnly := ProbeAudioCodec(s.vf.AudioCodec) == MissingUnsupported - videoFilter := sm.encoder.hwMaxResFilter(codec, s.vf.Width, s.vf.Height, s.maxTranscodeSize, fullhw) + videoFilter := sm.encoder.hwMaxResFilter(codec, s.vf, s.maxTranscodeSize, fullhw) args = append(args, s.streamType.Args(codec, segment, videoFilter, videoOnly, s.outputDir)...) diff --git a/pkg/ffmpeg/stream_transcode.go b/pkg/ffmpeg/stream_transcode.go index ce56e07956c..71465247055 100644 --- a/pkg/ffmpeg/stream_transcode.go +++ b/pkg/ffmpeg/stream_transcode.go @@ -60,7 +60,7 @@ func CodecInit(codec VideoCodec) (args Args) { ) case VideoCodecM264: args = append(args, - "-prio_speed", "1", + "-realtime", "1", ) case VideoCodecO264: args = append(args, @@ -198,7 +198,7 @@ func (o TranscodeOptions) makeStreamArgs(sm *StreamManager) Args { videoOnly := ProbeAudioCodec(o.VideoFile.AudioCodec) == MissingUnsupported - videoFilter := sm.encoder.hwMaxResFilter(codec, o.VideoFile.Width, o.VideoFile.Height, maxTranscodeSize, fullhw) + videoFilter := sm.encoder.hwMaxResFilter(codec, o.VideoFile, maxTranscodeSize, fullhw) args = append(args, o.StreamType.Args(codec, videoFilter, videoOnly)...) From 1f5377da1ccb95c2e341abfaa48f13cd07d28b3c Mon Sep 17 00:00:00 2001 From: CJ <72030708+cj12312021@users.noreply.github.com> Date: Sun, 23 Jun 2024 22:39:32 -0500 Subject: [PATCH 027/103] Added path column to tables in list view (#5005) --- .../components/Galleries/GalleryListTable.tsx | 15 ++++++++ ui/v2.5/src/components/List/styles.scss | 35 ++++++++++++++++++- .../src/components/Scenes/SceneListTable.tsx | 17 ++++++++- 3 files changed, 65 insertions(+), 2 deletions(-) diff --git a/ui/v2.5/src/components/Galleries/GalleryListTable.tsx b/ui/v2.5/src/components/Galleries/GalleryListTable.tsx index 7e6a561886b..68f926587e2 100644 --- a/ui/v2.5/src/components/Galleries/GalleryListTable.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryListTable.tsx @@ -134,6 +134,16 @@ export const GalleryListTable: React.FC = ( ); + const PathCell = (scene: GQL.SlimGalleryDataFragment) => ( +
    + {scene.files.map((file) => ( +
  • + {file.path} +
  • + ))} +
+ ); + interface IColumnSpec { value: string; label: string; @@ -211,6 +221,11 @@ export const GalleryListTable: React.FC = ( label: intl.formatMessage({ id: "photographer" }), render: (s) => <>{s.photographer}, }, + { + value: "path", + label: intl.formatMessage({ id: "path" }), + render: PathCell, + }, ]; const defaultColumns = allColumns diff --git a/ui/v2.5/src/components/List/styles.scss b/ui/v2.5/src/components/List/styles.scss index 5b1e3b845b6..eeaa8527a65 100644 --- a/ui/v2.5/src/components/List/styles.scss +++ b/ui/v2.5/src/components/List/styles.scss @@ -444,13 +444,42 @@ input[type="range"].zoom-slider { } } + .newline-list { + list-style: none; + margin: 0; + padding: 4px 2px; + + li { + display: inline; + white-space: pre-wrap; + } + + li::after { + content: "\A"; + } + + li:last-child::after { + content: ""; + } + } + + .newline-list.overflowable, .comma-list.overflowable { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + } + + .comma-list.overflowable { width: 190px; } + .newline-list.overflowable { + -webkit-line-clamp: 1; + width: 700px; + } + + .newline-list.overflowable:hover, .comma-list.overflowable:hover { background: #28343c; border: 1px solid #414c53; @@ -459,7 +488,6 @@ input[type="range"].zoom-slider { height: auto; margin-left: -0.4rem; margin-top: -0.9rem; - max-width: 40rem; overflow: hidden; padding: 0.1rem 0.5rem; position: absolute; @@ -469,6 +497,11 @@ input[type="range"].zoom-slider { z-index: 100; } + .comma-list.overflowable:hover { + max-width: 40rem; + } + + .newline-list.overflowable li .ellips-data:hover, .comma-list.overflowable li .ellips-data:hover { max-width: fit-content; } diff --git a/ui/v2.5/src/components/Scenes/SceneListTable.tsx b/ui/v2.5/src/components/Scenes/SceneListTable.tsx index 33581baa47b..0365e768793 100644 --- a/ui/v2.5/src/components/Scenes/SceneListTable.tsx +++ b/ui/v2.5/src/components/Scenes/SceneListTable.tsx @@ -226,7 +226,7 @@ export const SceneListTable: React.FC = ( ); const AudioCodecCell = (scene: GQL.SlimSceneDataFragment) => ( -
    +
      {scene.files.map((file) => (
    • {file.audio_codec} @@ -245,6 +245,16 @@ export const SceneListTable: React.FC = (
    ); + const PathCell = (scene: GQL.SlimSceneDataFragment) => ( +
      + {scene.files.map((file) => ( +
    • + {file.path} +
    • + ))} +
    + ); + interface IColumnSpec { value: string; label: string; @@ -343,6 +353,11 @@ export const SceneListTable: React.FC = ( label: intl.formatMessage({ id: "resolution" }), render: ResolutionCell, }, + { + value: "path", + label: intl.formatMessage({ id: "path" }), + render: PathCell, + }, { value: "filesize", label: intl.formatMessage({ id: "filesize" }), From 593207866ffc74fe0a3db59a4bdfcfea71105d0b Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Mon, 24 Jun 2024 16:02:18 +1000 Subject: [PATCH 028/103] Adjust 64 post-migrate where logic I think not including the scene_id meant that a date could be corrected earlier, meaning the rows affected would be 0. Adding scene_id means that each row should be migrated one by one. --- pkg/sqlite/migrations/64_postmigrate.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/sqlite/migrations/64_postmigrate.go b/pkg/sqlite/migrations/64_postmigrate.go index ecf2910501a..5b0f31a256d 100644 --- a/pkg/sqlite/migrations/64_postmigrate.go +++ b/pkg/sqlite/migrations/64_postmigrate.go @@ -64,7 +64,7 @@ func (m *schema64Migrator) migrate(ctx context.Context) error { // convert the timestamp to the correct format logger.Debugf("correcting view date %q to UTC date %q for scene %d", viewDate.Timestamp, viewDate.Timestamp.UTC(), id) - r, err := m.db.Exec("UPDATE scenes_view_dates SET view_date = ? WHERE view_date = ? OR view_date = ?", utcTimestamp, viewDate.Timestamp, viewDate) + r, err := m.db.Exec("UPDATE scenes_view_dates SET view_date = ? WHERE scene_id = ? AND (view_date = ? OR view_date = ?)", utcTimestamp, id, viewDate.Timestamp, viewDate) if err != nil { return fmt.Errorf("error correcting view date %s to %s: %w", viewDate.Timestamp, viewDate, err) } From 3156191b837c046717d2a91967fd914d846877d6 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Mon, 24 Jun 2024 16:02:46 +1000 Subject: [PATCH 029/103] Fix scene marker query (#5014) --- pkg/sqlite/scene_marker.go | 1 + pkg/sqlite/scene_marker_test.go | 21 +++++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/pkg/sqlite/scene_marker.go b/pkg/sqlite/scene_marker.go index 158916a828a..87a849d2084 100644 --- a/pkg/sqlite/scene_marker.go +++ b/pkg/sqlite/scene_marker.go @@ -301,6 +301,7 @@ func (qb *SceneMarkerStore) makeQuery(ctx context.Context, sceneMarkerFilter *mo distinctIDs(&query, sceneMarkerTable) if q := findFilter.Q; q != nil && *q != "" { + query.join(sceneTable, "", "scenes.id = scene_markers.scene_id") query.join(tagTable, "", "scene_markers.primary_tag_id = tags.id") searchColumns := []string{"scene_markers.title", "scenes.title", "tags.name"} query.parseQueryString(searchColumns, *q) diff --git a/pkg/sqlite/scene_marker_test.go b/pkg/sqlite/scene_marker_test.go index fffd0b88f08..0a8343a8bfc 100644 --- a/pkg/sqlite/scene_marker_test.go +++ b/pkg/sqlite/scene_marker_test.go @@ -74,6 +74,27 @@ func TestMarkerCountByTagID(t *testing.T) { }) } +func TestMarkerQueryQ(t *testing.T) { + withTxn(func(ctx context.Context) error { + q := getSceneTitle(sceneIdxWithMarkers) + m, _, err := db.SceneMarker.Query(ctx, nil, &models.FindFilterType{ + Q: &q, + }) + + if err != nil { + t.Errorf("Error querying scene markers: %s", err.Error()) + } + + if !assert.Greater(t, len(m), 0) { + return nil + } + + assert.Equal(t, sceneIDs[sceneIdxWithMarkers], m[0].SceneID) + + return nil + }) +} + func TestMarkerQuerySortBySceneUpdated(t *testing.T) { withTxn(func(ctx context.Context) error { sort := "scenes_updated_at" From d986a9eb4f600315eb743cb10255c11cffbf3675 Mon Sep 17 00:00:00 2001 From: CJ <72030708+cj12312021@users.noreply.github.com> Date: Mon, 24 Jun 2024 01:03:29 -0500 Subject: [PATCH 030/103] Address resize loop (#5004) --- .../src/components/Shared/GridCard/GridCard.tsx | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/ui/v2.5/src/components/Shared/GridCard/GridCard.tsx b/ui/v2.5/src/components/Shared/GridCard/GridCard.tsx index 911064a9402..1d1a37528d9 100644 --- a/ui/v2.5/src/components/Shared/GridCard/GridCard.tsx +++ b/ui/v2.5/src/components/Shared/GridCard/GridCard.tsx @@ -42,9 +42,9 @@ interface IDimension { height: number; } -export const useContainerDimensions = < - T extends HTMLElement = HTMLDivElement ->(): [MutableRefObject, IDimension] => { +export const useContainerDimensions = ( + sensitivityThreshold = 20 +): [MutableRefObject, IDimension] => { const target = useRef(null); const [dimension, setDimension] = useState({ width: 0, @@ -53,7 +53,14 @@ export const useContainerDimensions = < useResizeObserver(target, (entry) => { const { inlineSize: width, blockSize: height } = entry.contentBoxSize[0]; - setDimension({ width, height }); + let difference = Math.abs(dimension.width - width); + // Only adjust when width changed by a significant margin. This addresses the cornercase that sees + // the dimensions toggle back and forward when the window is adjusted perfectly such that overflow + // is trigger then immediable disabled because of a resize event then continues this loop endlessly. + // the scrollbar size varies between platforms. Windows is apparently around 17 pixels. + if (difference > sensitivityThreshold) { + setDimension({ width, height }); + } }); return [target, dimension]; From af6841be49279746481af45c216438c5222007c5 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Wed, 26 Jun 2024 11:39:31 +1000 Subject: [PATCH 031/103] Rename Movie to Group in UI (#4963) * Replace movies with groups in the UI * Massage menu items * Change view names * Rename Movie components to Group * Refactor movie to group variable names * Rename movie class names to group --- ui/v2.5/src/App.tsx | 4 +- ui/v2.5/src/components/FrontPage/Control.tsx | 4 +- .../components/FrontPage/FrontPageConfig.tsx | 2 +- ui/v2.5/src/components/FrontPage/styles.scss | 4 +- .../src/components/List/EditFilterDialog.tsx | 2 +- .../List/Filters/LabeledIdFilter.tsx | 2 +- ui/v2.5/src/components/List/views.ts | 8 +- ui/v2.5/src/components/MainNavbar.tsx | 48 +++++---- .../components/Movies/EditMoviesDialog.tsx | 6 +- ui/v2.5/src/components/Movies/MovieCard.tsx | 48 ++++----- .../src/components/Movies/MovieCardGrid.tsx | 16 +-- .../components/Movies/MovieDetails/Movie.tsx | 100 +++++++++--------- .../Movies/MovieDetails/MovieCreate.tsx | 24 ++--- .../Movies/MovieDetails/MovieDetailsPanel.tsx | 42 ++++---- .../Movies/MovieDetails/MovieEditPanel.tsx | 80 +++++++------- .../Movies/MovieDetails/MovieScenesPanel.tsx | 16 +-- .../Movies/MovieDetails/MovieScrapeDialog.tsx | 50 ++++----- ui/v2.5/src/components/Movies/MovieList.tsx | 38 +++---- .../Movies/MovieRecommendationRow.tsx | 12 +-- ui/v2.5/src/components/Movies/MovieSelect.tsx | 62 +++++------ ui/v2.5/src/components/Movies/Movies.tsx | 22 ++-- ui/v2.5/src/components/Movies/styles.scss | 26 ++--- .../components/Performers/PerformerCard.tsx | 10 +- .../Performers/PerformerDetails/Performer.tsx | 16 +-- .../PerformerDetails/PerformerMoviesPanel.tsx | 8 +- .../SceneDuplicateChecker.tsx | 16 +-- .../components/Scenes/EditScenesDialog.tsx | 50 ++++----- ui/v2.5/src/components/Scenes/SceneCard.tsx | 26 ++--- .../components/Scenes/SceneDetails/Scene.tsx | 10 +- .../Scenes/SceneDetails/SceneEditPanel.tsx | 30 +++--- .../Scenes/SceneDetails/SceneMoviePanel.tsx | 20 ++-- .../Scenes/SceneDetails/SceneMovieTable.tsx | 54 +++++----- .../Scenes/SceneDetails/SceneScrapeDialog.tsx | 44 ++++---- .../src/components/Scenes/SceneListTable.tsx | 16 +-- .../components/Scenes/SceneMergeDialog.tsx | 28 ++--- ui/v2.5/src/components/Scenes/styles.scss | 14 +-- .../SettingsInterfacePanel.tsx | 30 ++++-- .../Settings/SettingsScrapingPanel.tsx | 18 ++-- ui/v2.5/src/components/Shared/Link.tsx | 6 +- ui/v2.5/src/components/Shared/MultiSet.tsx | 2 +- .../components/Shared/PopoverCountButton.tsx | 10 +- .../Shared/ScrapeDialog/ScrapedObjectsRow.tsx | 14 +-- .../Shared/ScrapeDialog/createObjects.ts | 16 +-- ui/v2.5/src/components/Shared/Select.tsx | 12 +-- ui/v2.5/src/components/Shared/TagLink.tsx | 20 ++-- ui/v2.5/src/components/Stats.tsx | 2 +- ui/v2.5/src/components/Studios/StudioCard.tsx | 10 +- .../Studios/StudioDetails/Studio.tsx | 20 ++-- .../StudioDetails/StudioMoviesPanel.tsx | 10 +- ui/v2.5/src/components/Tags/TagCard.tsx | 10 +- .../src/components/Tags/TagDetails/Tag.tsx | 20 ++-- .../Tags/TagDetails/TagMoviesPanel.tsx | 6 +- ui/v2.5/src/core/config.ts | 6 +- ui/v2.5/src/core/movies.ts | 2 +- ui/v2.5/src/index.scss | 16 +-- ui/v2.5/src/locales/en-GB.json | 10 +- .../models/list-filter/criteria/criterion.ts | 2 +- .../src/models/list-filter/criteria/movies.ts | 4 +- ui/v2.5/src/models/list-filter/scenes.ts | 5 +- ui/v2.5/src/models/list-filter/tags.ts | 4 +- ui/v2.5/src/pluginApi.d.ts | 6 +- ui/v2.5/src/utils/bulkUpdate.ts | 4 +- ui/v2.5/src/utils/navigation.ts | 32 +++--- 63 files changed, 643 insertions(+), 612 deletions(-) diff --git a/ui/v2.5/src/App.tsx b/ui/v2.5/src/App.tsx index b3ff5e10fae..7aba652cd80 100644 --- a/ui/v2.5/src/App.tsx +++ b/ui/v2.5/src/App.tsx @@ -66,7 +66,7 @@ const Galleries = lazyComponent( () => import("./components/Galleries/Galleries") ); -const Movies = lazyComponent(() => import("./components/Movies/Movies")); +const Groups = lazyComponent(() => import("./components/Movies/Movies")); const Tags = lazyComponent(() => import("./components/Tags/Tags")); const Images = lazyComponent(() => import("./components/Images/Images")); const Setup = lazyComponent(() => import("./components/Setup/Setup")); @@ -312,7 +312,7 @@ export const App: React.FC = () => { - + = ({ mode, filter, header }) => { ); case GQL.FilterMode.Movies: return ( - = ({ const FilterModeToConfigKey = { [FilterMode.Galleries]: "galleries", [FilterMode.Images]: "images", - [FilterMode.Movies]: "movies", + [FilterMode.Movies]: "groups", [FilterMode.Performers]: "performers", [FilterMode.SceneMarkers]: "sceneMarkers", [FilterMode.Scenes]: "scenes", diff --git a/ui/v2.5/src/components/List/Filters/LabeledIdFilter.tsx b/ui/v2.5/src/components/List/Filters/LabeledIdFilter.tsx index 78f271c0fb2..792c4a7e712 100644 --- a/ui/v2.5/src/components/List/Filters/LabeledIdFilter.tsx +++ b/ui/v2.5/src/components/List/Filters/LabeledIdFilter.tsx @@ -24,7 +24,7 @@ export const LabeledIdFilter: React.FC = ({ inputType !== "performer_tags" && inputType !== "tags" && inputType !== "scenes" && - inputType !== "movies" && + inputType !== "groups" && inputType !== "galleries" ) { return null; diff --git a/ui/v2.5/src/components/List/views.ts b/ui/v2.5/src/components/List/views.ts index 7e8880f9d2e..2b4179014e1 100644 --- a/ui/v2.5/src/components/List/views.ts +++ b/ui/v2.5/src/components/List/views.ts @@ -2,7 +2,7 @@ export enum View { Galleries = "galleries", Images = "images", Scenes = "scenes", - Movies = "movies", + Groups = "groups", Performers = "performers", Tags = "tags", SceneMarkers = "scene_markers", @@ -17,7 +17,7 @@ export enum View { PerformerScenes = "performer_scenes", PerformerGalleries = "performer_galleries", PerformerImages = "performer_images", - PerformerMovies = "performer_movies", + PerformerGroups = "performer_groups", PerformerAppearsWith = "performer_appears_with", StudioGalleries = "studio_galleries", @@ -26,9 +26,9 @@ export enum View { GalleryImages = "gallery_images", StudioScenes = "studio_scenes", - StudioMovies = "studio_movies", + StudioGroups = "studio_groups", StudioPerformers = "studio_performers", StudioChildren = "studio_children", - MovieScenes = "movie_scenes", + GroupScenes = "group_scenes", } diff --git a/ui/v2.5/src/components/MainNavbar.tsx b/ui/v2.5/src/components/MainNavbar.tsx index 89e3563e826..98bbc26c659 100644 --- a/ui/v2.5/src/components/MainNavbar.tsx +++ b/ui/v2.5/src/components/MainNavbar.tsx @@ -1,4 +1,10 @@ -import React, { useEffect, useRef, useState, useCallback } from "react"; +import React, { + useEffect, + useRef, + useState, + useCallback, + useMemo, +} from "react"; import { defineMessages, FormattedMessage, @@ -52,9 +58,9 @@ const messages = defineMessages({ id: "images", defaultMessage: "Images", }, - movies: { - id: "movies", - defaultMessage: "Movies", + groups: { + id: "groups", + defaultMessage: "Groups", }, markers: { id: "markers", @@ -107,9 +113,9 @@ const allMenuItems: IMenuItem[] = [ hotkey: "g i", }, { - name: "movies", - message: messages.movies, - href: "/movies", + name: "groups", + message: messages.groups, + href: "/groups", icon: faFilm, hotkey: "g v", userCreatable: true, @@ -179,20 +185,26 @@ export const MainNavbar: React.FC = () => { const { configuration, loading } = React.useContext(ConfigurationContext); const { openManual } = React.useContext(ManualStateContext); - // Show all menu items by default, unless config says otherwise - const [menuItems, setMenuItems] = useState(allMenuItems); - const [expanded, setExpanded] = useState(false); - useEffect(() => { - const iCfg = configuration?.interface; - if (iCfg?.menuItems) { - setMenuItems( - allMenuItems.filter((menuItem) => - iCfg.menuItems!.includes(menuItem.name) - ) - ); + // Show all menu items by default, unless config says otherwise + const menuItems = useMemo(() => { + let cfgMenuItems = configuration?.interface.menuItems; + if (!cfgMenuItems) { + return allMenuItems; } + + // translate old movies menu item to groups + cfgMenuItems = cfgMenuItems.map((item) => { + if (item === "movies") { + return "groups"; + } + return item; + }); + + return allMenuItems.filter((menuItem) => + cfgMenuItems!.includes(menuItem.name) + ); }, [configuration]); // react-bootstrap typing bug diff --git a/ui/v2.5/src/components/Movies/EditMoviesDialog.tsx b/ui/v2.5/src/components/Movies/EditMoviesDialog.tsx index af48cbeaf35..800bad04471 100644 --- a/ui/v2.5/src/components/Movies/EditMoviesDialog.tsx +++ b/ui/v2.5/src/components/Movies/EditMoviesDialog.tsx @@ -24,7 +24,7 @@ interface IListOperationProps { onClose: (applied: boolean) => void; } -export const EditMoviesDialog: React.FC = ( +export const EditGroupsDialog: React.FC = ( props: IListOperationProps ) => { const intl = useIntl(); @@ -69,7 +69,7 @@ export const EditMoviesDialog: React.FC = ( intl.formatMessage( { id: "toast.updated_entity" }, { - entity: intl.formatMessage({ id: "movies" }).toLocaleLowerCase(), + entity: intl.formatMessage({ id: "groups" }).toLocaleLowerCase(), } ) ); @@ -126,7 +126,7 @@ export const EditMoviesDialog: React.FC = ( icon={faPencilAlt} header={intl.formatMessage( { id: "actions.edit_entity" }, - { entityType: intl.formatMessage({ id: "movies" }) } + { entityType: intl.formatMessage({ id: "groups" }) } )} accept={{ onClick: onSave, diff --git a/ui/v2.5/src/components/Movies/MovieCard.tsx b/ui/v2.5/src/components/Movies/MovieCard.tsx index 1f763649e2d..739761251c3 100644 --- a/ui/v2.5/src/components/Movies/MovieCard.tsx +++ b/ui/v2.5/src/components/Movies/MovieCard.tsx @@ -12,7 +12,7 @@ import { faPlayCircle, faTag } from "@fortawesome/free-solid-svg-icons"; import ScreenUtils from "src/utils/screen"; interface IProps { - movie: GQL.MovieDataFragment; + group: GQL.MovieDataFragment; containerWidth?: number; sceneIndex?: number; selecting?: boolean; @@ -20,8 +20,8 @@ interface IProps { onSelectedChanged?: (selected: boolean, shiftKey: boolean) => void; } -export const MovieCard: React.FC = ({ - movie, +export const GroupCard: React.FC = ({ + group, sceneIndex, containerWidth, selecting, @@ -47,7 +47,7 @@ export const MovieCard: React.FC = ({ return ( <>
    - + #{sceneIndex} @@ -55,9 +55,9 @@ export const MovieCard: React.FC = ({ } function maybeRenderScenesPopoverButton() { - if (movie.scenes.length === 0) return; + if (group.scenes.length === 0) return; - const popoverContent = movie.scenes.map((scene) => ( + const popoverContent = group.scenes.map((scene) => ( )); @@ -69,31 +69,31 @@ export const MovieCard: React.FC = ({ > ); } function maybeRenderTagPopoverButton() { - if (movie.tags.length <= 0) return; + if (group.tags.length <= 0) return; - const popoverContent = movie.tags.map((tag) => ( - + const popoverContent = group.tags.map((tag) => ( + )); return ( ); } function maybeRenderPopoverButtonGroup() { - if (sceneIndex || movie.scenes.length > 0 || movie.tags.length > 0) { + if (sceneIndex || group.scenes.length > 0 || group.tags.length > 0) { return ( <> {maybeRenderSceneNumber()} @@ -109,28 +109,28 @@ export const MovieCard: React.FC = ({ return ( {movie.name - + } details={ -
    - {movie.date} +
    + {group.date}
    diff --git a/ui/v2.5/src/components/Movies/MovieCardGrid.tsx b/ui/v2.5/src/components/Movies/MovieCardGrid.tsx index 4475a0c8a8f..52cbc0f53d6 100644 --- a/ui/v2.5/src/components/Movies/MovieCardGrid.tsx +++ b/ui/v2.5/src/components/Movies/MovieCardGrid.tsx @@ -1,27 +1,27 @@ import React from "react"; import * as GQL from "src/core/generated-graphql"; -import { MovieCard } from "./MovieCard"; +import { GroupCard } from "./MovieCard"; import { useContainerDimensions } from "../Shared/GridCard/GridCard"; -interface IMovieCardGrid { - movies: GQL.MovieDataFragment[]; +interface IGroupCardGrid { + groups: GQL.MovieDataFragment[]; selectedIds: Set; onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void; } -export const MovieCardGrid: React.FC = ({ - movies, +export const GroupCardGrid: React.FC = ({ + groups, selectedIds, onSelectChange, }) => { const [componentRef, { width }] = useContainerDimensions(); return (
    - {movies.map((p) => ( - ( + 0} selected={selectedIds.has(p.id)} onSelectedChanged={(selected: boolean, shiftKey: boolean) => diff --git a/ui/v2.5/src/components/Movies/MovieDetails/Movie.tsx b/ui/v2.5/src/components/Movies/MovieDetails/Movie.tsx index 69aecd20df3..686a92b39f2 100644 --- a/ui/v2.5/src/components/Movies/MovieDetails/Movie.tsx +++ b/ui/v2.5/src/components/Movies/MovieDetails/Movie.tsx @@ -17,12 +17,12 @@ import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; import { useLightbox } from "src/hooks/Lightbox/hooks"; import { ModalComponent } from "src/components/Shared/Modal"; import { useToast } from "src/hooks/Toast"; -import { MovieScenesPanel } from "./MovieScenesPanel"; +import { GroupScenesPanel } from "./MovieScenesPanel"; import { CompressedMovieDetailsPanel, - MovieDetailsPanel, + GroupDetailsPanel, } from "./MovieDetailsPanel"; -import { MovieEditPanel } from "./MovieEditPanel"; +import { GroupEditPanel } from "./MovieEditPanel"; import { faChevronDown, faChevronUp, @@ -38,14 +38,14 @@ import { useScrollToTopOnMount } from "src/hooks/scrollToTop"; import { ExternalLinksButton } from "src/components/Shared/ExternalLinksButton"; interface IProps { - movie: GQL.MovieDataFragment; + group: GQL.MovieDataFragment; } -interface IMovieParams { +interface IGroupParams { id: string; } -const MoviePage: React.FC = ({ movie }) => { +const GroupPage: React.FC = ({ group }) => { const intl = useIntl(); const history = useHistory(); const Toast = useToast(); @@ -70,35 +70,35 @@ const MoviePage: React.FC = ({ movie }) => { const [encodingImage, setEncodingImage] = useState(false); const defaultImage = - movie.front_image_path && movie.front_image_path.includes("default=true") + group.front_image_path && group.front_image_path.includes("default=true") ? true : false; const lightboxImages = useMemo(() => { const covers = [ - ...(movie.front_image_path && !defaultImage + ...(group.front_image_path && !defaultImage ? [ { paths: { - thumbnail: movie.front_image_path, - image: movie.front_image_path, + thumbnail: group.front_image_path, + image: group.front_image_path, }, }, ] : []), - ...(movie.back_image_path + ...(group.back_image_path ? [ { paths: { - thumbnail: movie.back_image_path, - image: movie.back_image_path, + thumbnail: group.back_image_path, + image: group.back_image_path, }, }, ] : []), ]; return covers; - }, [movie.front_image_path, movie.back_image_path, defaultImage]); + }, [group.front_image_path, group.back_image_path, defaultImage]); const index = lightboxImages.length; @@ -108,7 +108,7 @@ const MoviePage: React.FC = ({ movie }) => { const [updateMovie, { loading: updating }] = useMovieUpdate(); const [deleteMovie, { loading: deleting }] = useMovieDestroy({ - id: movie.id, + id: group.id, }); // set up hotkeys @@ -135,7 +135,7 @@ const MoviePage: React.FC = ({ movie }) => { await updateMovie({ variables: { input: { - id: movie.id, + id: group.id, ...input, }, }, @@ -144,7 +144,7 @@ const MoviePage: React.FC = ({ movie }) => { Toast.success( intl.formatMessage( { id: "toast.updated_entity" }, - { entity: intl.formatMessage({ id: "movie" }).toLocaleLowerCase() } + { entity: intl.formatMessage({ id: "group" }).toLocaleLowerCase() } ) ); } @@ -157,7 +157,7 @@ const MoviePage: React.FC = ({ movie }) => { } // redirect to movies page - history.push(`/movies`); + history.push(`/groups`); } function toggleEditing(value?: boolean) { @@ -187,8 +187,8 @@ const MoviePage: React.FC = ({ movie }) => { id="dialogs.delete_confirm" values={{ entityName: - movie.name ?? - intl.formatMessage({ id: "movie" }).toLocaleLowerCase(), + group.name ?? + intl.formatMessage({ id: "group" }).toLocaleLowerCase(), }} />

    @@ -216,7 +216,7 @@ const MoviePage: React.FC = ({ movie }) => { } function renderFrontImage() { - let image = movie.front_image_path; + let image = group.front_image_path; if (isEditing) { if (frontImage === null && image) { const imageURL = new URL(image); @@ -229,14 +229,14 @@ const MoviePage: React.FC = ({ movie }) => { if (image && defaultImage) { return ( -
    +
    ); } else if (image) { return (
    @@ -384,9 +384,9 @@ const MoviePage: React.FC = ({ movie }) => { }); return ( -
    +
    - {movie?.name} + {group?.name}
    @@ -399,7 +399,7 @@ const MoviePage: React.FC = ({ movie }) => { message={intl.formatMessage({ id: "actions.encoding_image" })} /> ) : ( -
    +
    {renderFrontImage()} {renderBackImage()}
    @@ -407,15 +407,15 @@ const MoviePage: React.FC = ({ movie }) => {
    -
    +

    - {movie.name} + {group.name} {maybeRenderShowCollapseButton()} {renderClickableIcons()}

    {maybeRenderAliases()} setRating(value)} clickToRate withoutContext @@ -428,8 +428,8 @@ const MoviePage: React.FC = ({ movie }) => {
    {maybeRenderCompressedDetails()}
    -
    -
    {maybeRenderTab()}
    +
    +
    {maybeRenderTab()}
    {renderDeleteAlert()} @@ -437,7 +437,7 @@ const MoviePage: React.FC = ({ movie }) => { ); }; -const MovieLoader: React.FC> = ({ +const GroupLoader: React.FC> = ({ match, }) => { const { id } = match.params; @@ -450,7 +450,7 @@ const MovieLoader: React.FC> = ({ if (!data?.findMovie) return ; - return ; + return ; }; -export default MovieLoader; +export default GroupLoader; diff --git a/ui/v2.5/src/components/Movies/MovieDetails/MovieCreate.tsx b/ui/v2.5/src/components/Movies/MovieDetails/MovieCreate.tsx index a7ab492b99c..2f65463c90a 100644 --- a/ui/v2.5/src/components/Movies/MovieDetails/MovieCreate.tsx +++ b/ui/v2.5/src/components/Movies/MovieDetails/MovieCreate.tsx @@ -5,16 +5,16 @@ import { useHistory, useLocation } from "react-router-dom"; import { useIntl } from "react-intl"; import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; import { useToast } from "src/hooks/Toast"; -import { MovieEditPanel } from "./MovieEditPanel"; +import { GroupEditPanel } from "./MovieEditPanel"; -const MovieCreate: React.FC = () => { +const GroupCreate: React.FC = () => { const history = useHistory(); const intl = useIntl(); const Toast = useToast(); const location = useLocation(); const query = useMemo(() => new URLSearchParams(location.search), [location]); - const movie = { + const group = { name: query.get("q") ?? undefined, }; @@ -30,7 +30,7 @@ const MovieCreate: React.FC = () => { variables: { input }, }); if (result.data?.movieCreate?.id) { - history.push(`/movies/${result.data.movieCreate.id}`); + history.push(`/groups/${result.data.movieCreate.id}`); Toast.success( intl.formatMessage( { id: "toast.created_entity" }, @@ -43,7 +43,7 @@ const MovieCreate: React.FC = () => { function renderFrontImage() { if (frontImage) { return ( -
    +
    Front Cover
    ); @@ -53,7 +53,7 @@ const MovieCreate: React.FC = () => { function renderBackImage() { if (backImage) { return ( -
    +
    Back Cover
    ); @@ -63,24 +63,24 @@ const MovieCreate: React.FC = () => { // TODO: CSS class return (
    -
    +
    {encodingImage ? ( ) : ( -
    +
    {renderFrontImage()} {renderBackImage()}
    )}
    - history.push("/movies")} + onCancel={() => history.push("/groups")} onDelete={() => {}} setFrontImage={setFrontImage} setBackImage={setBackImage} @@ -91,4 +91,4 @@ const MovieCreate: React.FC = () => { ); }; -export default MovieCreate; +export default GroupCreate; diff --git a/ui/v2.5/src/components/Movies/MovieDetails/MovieDetailsPanel.tsx b/ui/v2.5/src/components/Movies/MovieDetails/MovieDetailsPanel.tsx index 7c5a9cf3a72..8f911c08c76 100644 --- a/ui/v2.5/src/components/Movies/MovieDetails/MovieDetailsPanel.tsx +++ b/ui/v2.5/src/components/Movies/MovieDetails/MovieDetailsPanel.tsx @@ -7,14 +7,14 @@ import { Link } from "react-router-dom"; import { DirectorLink } from "src/components/Shared/Link"; import { TagLink } from "src/components/Shared/TagLink"; -interface IMovieDetailsPanel { - movie: GQL.MovieDataFragment; +interface IGroupDetailsPanel { + group: GQL.MovieDataFragment; collapsed?: boolean; fullWidth?: boolean; } -export const MovieDetailsPanel: React.FC = ({ - movie, +export const GroupDetailsPanel: React.FC = ({ + group, collapsed, fullWidth, }) => { @@ -22,13 +22,13 @@ export const MovieDetailsPanel: React.FC = ({ const intl = useIntl(); function renderTagsField() { - if (!movie.tags.length) { + if (!group.tags.length) { return; } return (
      - {(movie.tags ?? []).map((tag) => ( - + {(group.tags ?? []).map((tag) => ( + ))}
    ); @@ -40,7 +40,7 @@ export const MovieDetailsPanel: React.FC = ({ <> = ({ - {movie.studio?.name} + group.studio?.id ? ( + + {group.studio?.name} ) : ( "" @@ -84,8 +84,8 @@ export const MovieDetailsPanel: React.FC = ({ + group.director ? ( + ) : ( "" ) @@ -97,8 +97,8 @@ export const MovieDetailsPanel: React.FC = ({ ); }; -export const CompressedMovieDetailsPanel: React.FC = ({ - movie, +export const CompressedMovieDetailsPanel: React.FC = ({ + group, }) => { function scrollToTop() { window.scrollTo({ top: 0, behavior: "smooth" }); @@ -107,13 +107,13 @@ export const CompressedMovieDetailsPanel: React.FC = ({ return (
    - scrollToTop()}> - {movie.name} + scrollToTop()}> + {group.name} - {movie?.studio?.name ? ( + {group?.studio?.name ? ( <> / - {movie?.studio?.name} + {group?.studio?.name} ) : ( "" diff --git a/ui/v2.5/src/components/Movies/MovieDetails/MovieEditPanel.tsx b/ui/v2.5/src/components/Movies/MovieDetails/MovieEditPanel.tsx index 5cd4cda7908..0a281df513d 100644 --- a/ui/v2.5/src/components/Movies/MovieDetails/MovieEditPanel.tsx +++ b/ui/v2.5/src/components/Movies/MovieDetails/MovieEditPanel.tsx @@ -15,7 +15,7 @@ import TextUtils from "src/utils/text"; import ImageUtils from "src/utils/image"; import { useFormik } from "formik"; import { Prompt } from "react-router-dom"; -import { MovieScrapeDialog } from "./MovieScrapeDialog"; +import { GroupScrapeDialog } from "./MovieScrapeDialog"; import isEqual from "lodash-es/isEqual"; import { handleUnsavedChanges } from "src/utils/navigation"; import { formikUtils } from "src/utils/form"; @@ -27,8 +27,8 @@ import { import { Studio, StudioSelect } from "src/components/Studios/StudioSelect"; import { useTagsEdit } from "src/hooks/tagsEdit"; -interface IMovieEditPanel { - movie: Partial; +interface IGroupEditPanel { + group: Partial; onSubmit: (movie: GQL.MovieCreateInput) => Promise; onCancel: () => void; onDelete: () => void; @@ -37,8 +37,8 @@ interface IMovieEditPanel { setEncodingImage: (loading: boolean) => void; } -export const MovieEditPanel: React.FC = ({ - movie, +export const GroupEditPanel: React.FC = ({ + group, onSubmit, onCancel, onDelete, @@ -49,7 +49,7 @@ export const MovieEditPanel: React.FC = ({ const intl = useIntl(); const Toast = useToast(); - const isNew = movie.id === undefined; + const isNew = group.id === undefined; const [isLoading, setIsLoading] = useState(false); const [isImageAlertOpen, setIsImageAlertOpen] = useState(false); @@ -57,7 +57,7 @@ export const MovieEditPanel: React.FC = ({ const [imageClipboard, setImageClipboard] = useState(); const Scrapers = useListMovieScrapers(); - const [scrapedMovie, setScrapedMovie] = useState(); + const [scrapedGroup, setScrapedGroup] = useState(); const [studio, setStudio] = useState(null); @@ -76,15 +76,15 @@ export const MovieEditPanel: React.FC = ({ }); const initialValues = { - name: movie?.name ?? "", - aliases: movie?.aliases ?? "", - duration: movie?.duration ?? null, - date: movie?.date ?? "", - studio_id: movie?.studio?.id ?? null, - tag_ids: (movie?.tags ?? []).map((t) => t.id), - director: movie?.director ?? "", - urls: movie?.urls ?? [], - synopsis: movie?.synopsis ?? "", + name: group?.name ?? "", + aliases: group?.aliases ?? "", + duration: group?.duration ?? null, + date: group?.date ?? "", + studio_id: group?.studio?.id ?? null, + tag_ids: (group?.tags ?? []).map((t) => t.id), + director: group?.director ?? "", + urls: group?.urls ?? [], + synopsis: group?.synopsis ?? "", }; type InputValues = yup.InferType; @@ -97,7 +97,7 @@ export const MovieEditPanel: React.FC = ({ }); const { tags, updateTagsStateFromScraper, tagsControl } = useTagsEdit( - movie.tags, + group.tags, (ids) => formik.setFieldValue("tag_ids", ids) ); @@ -107,8 +107,8 @@ export const MovieEditPanel: React.FC = ({ } useEffect(() => { - setStudio(movie.studio ?? null); - }, [movie.studio]); + setStudio(group.studio ?? null); + }, [group.studio]); // set up hotkeys useEffect(() => { @@ -128,7 +128,7 @@ export const MovieEditPanel: React.FC = ({ }; }); - function updateMovieEditStateFromScraper( + function updateGroupEditStateFromScraper( state: Partial ) { if (state.name) { @@ -200,11 +200,11 @@ export const MovieEditPanel: React.FC = ({ return; } - // if this is a new movie, just dump the data + // if this is a new group, just dump the data if (isNew) { - updateMovieEditStateFromScraper(result.data.scrapeMovieURL); + updateGroupEditStateFromScraper(result.data.scrapeMovieURL); } else { - setScrapedMovie(result.data.scrapeMovieURL); + setScrapedGroup(result.data.scrapeMovieURL); } } catch (e) { Toast.error(e); @@ -223,25 +223,25 @@ export const MovieEditPanel: React.FC = ({ } function maybeRenderScrapeDialog() { - if (!scrapedMovie) { + if (!scrapedGroup) { return; } - const currentMovie = { - id: movie.id!, + const currentGroup = { + id: group.id!, ...formik.values, }; // Get image paths for scrape gui - currentMovie.front_image = movie?.front_image_path; - currentMovie.back_image = movie?.back_image_path; + currentGroup.front_image = group?.front_image_path; + currentGroup.back_image = group?.back_image_path; return ( - { onScrapeDialogClosed(m); }} @@ -251,9 +251,9 @@ export const MovieEditPanel: React.FC = ({ function onScrapeDialogClosed(p?: GQL.ScrapedMovieDataFragment) { if (p) { - updateMovieEditStateFromScraper(p); + updateGroupEditStateFromScraper(p); } - setScrapedMovie(undefined); + setScrapedGroup(undefined); } const encodingImage = ImageUtils.usePasteImage(showImageAlert); @@ -373,7 +373,7 @@ export const MovieEditPanel: React.FC = ({

    {intl.formatMessage( { id: "actions.add_entity" }, - { entityType: intl.formatMessage({ id: "movie" }) } + { entityType: intl.formatMessage({ id: "group" }) } )}

    )} @@ -382,14 +382,14 @@ export const MovieEditPanel: React.FC = ({ when={formik.dirty} message={(location, action) => { // Check if it's a redirect after movie creation - if (action === "PUSH" && location.pathname.startsWith("/movies/")) + if (action === "PUSH" && location.pathname.startsWith("/groups/")) return true; - return handleUnsavedChanges(intl, "movies", movie.id)(location); + return handleUnsavedChanges(intl, "groups", group.id)(location); }} /> -
    + {renderInputField("name")} {renderInputField("aliases")} {renderDurationField("duration")} @@ -402,7 +402,7 @@ export const MovieEditPanel: React.FC = ({ = ({ +export const GroupScenesPanel: React.FC = ({ active, - movie, + group, }) => { function filterHook(filter: ListFilterModel) { - const movieValue = { id: movie.id, label: movie.name }; + const movieValue = { id: group.id, label: group.name }; // if movie is already present, then we modify it, otherwise add let movieCriterion = filter.criteria.find((c) => { return c.criterionOption.type === "movies"; @@ -29,7 +29,7 @@ export const MovieScenesPanel: React.FC = ({ // add the movie if not present if ( !movieCriterion.value.find((p) => { - return p.id === movie.id; + return p.id === group.id; }) ) { movieCriterion.value.push(movieValue); @@ -46,13 +46,13 @@ export const MovieScenesPanel: React.FC = ({ return filter; } - if (movie && movie.id) { + if (group && group.id) { return ( ); } diff --git a/ui/v2.5/src/components/Movies/MovieDetails/MovieScrapeDialog.tsx b/ui/v2.5/src/components/Movies/MovieDetails/MovieScrapeDialog.tsx index b28bded5c89..64456411243 100644 --- a/ui/v2.5/src/components/Movies/MovieDetails/MovieScrapeDialog.tsx +++ b/ui/v2.5/src/components/Movies/MovieDetails/MovieScrapeDialog.tsx @@ -20,33 +20,33 @@ import { uniq } from "lodash-es"; import { Tag } from "src/components/Tags/TagSelect"; import { useScrapedTags } from "src/components/Shared/ScrapeDialog/scrapedTags"; -interface IMovieScrapeDialogProps { - movie: Partial; - movieStudio: Studio | null; - movieTags: Tag[]; +interface IGroupScrapeDialogProps { + group: Partial; + groupStudio: Studio | null; + groupTags: Tag[]; scraped: GQL.ScrapedMovie; onClose: (scrapedMovie?: GQL.ScrapedMovie) => void; } -export const MovieScrapeDialog: React.FC = ({ - movie, - movieStudio, - movieTags, +export const GroupScrapeDialog: React.FC = ({ + group, + groupStudio: groupStudio, + groupTags: groupTags, scraped, onClose, }) => { const intl = useIntl(); const [name, setName] = useState>( - new ScrapeResult(movie.name, scraped.name) + new ScrapeResult(group.name, scraped.name) ); const [aliases, setAliases] = useState>( - new ScrapeResult(movie.aliases, scraped.aliases) + new ScrapeResult(group.aliases, scraped.aliases) ); const [duration, setDuration] = useState>( new ScrapeResult( - TextUtils.secondsToTimestamp(movie.duration || 0), + TextUtils.secondsToTimestamp(group.duration || 0), // convert seconds to string if it's a number scraped.duration && !isNaN(+scraped.duration) ? TextUtils.secondsToTimestamp(parseInt(scraped.duration, 10)) @@ -54,20 +54,20 @@ export const MovieScrapeDialog: React.FC = ({ ) ); const [date, setDate] = useState>( - new ScrapeResult(movie.date, scraped.date) + new ScrapeResult(group.date, scraped.date) ); const [director, setDirector] = useState>( - new ScrapeResult(movie.director, scraped.director) + new ScrapeResult(group.director, scraped.director) ); const [synopsis, setSynopsis] = useState>( - new ScrapeResult(movie.synopsis, scraped.synopsis) + new ScrapeResult(group.synopsis, scraped.synopsis) ); const [studio, setStudio] = useState>( new ObjectScrapeResult( - movieStudio + groupStudio ? { - stored_id: movieStudio.id, - name: movieStudio.name, + stored_id: groupStudio.id, + name: groupStudio.name, } : undefined, scraped.studio?.stored_id ? scraped.studio : undefined @@ -75,17 +75,17 @@ export const MovieScrapeDialog: React.FC = ({ ); const [urls, setURLs] = useState>( new ScrapeResult( - movie.urls, + group.urls, scraped.urls - ? uniq((movie.urls ?? []).concat(scraped.urls ?? [])) + ? uniq((group.urls ?? []).concat(scraped.urls ?? [])) : undefined ) ); const [frontImage, setFrontImage] = useState>( - new ScrapeResult(movie.front_image, scraped.front_image) + new ScrapeResult(group.front_image, scraped.front_image) ); const [backImage, setBackImage] = useState>( - new ScrapeResult(movie.back_image, scraped.back_image) + new ScrapeResult(group.back_image, scraped.back_image) ); const [newStudio, setNewStudio] = useState( @@ -99,7 +99,7 @@ export const MovieScrapeDialog: React.FC = ({ }); const { tags, newTags, scrapedTagsRow } = useScrapedTags( - movieTags, + groupTags, scraped.tags ); @@ -194,13 +194,13 @@ export const MovieScrapeDialog: React.FC = ({ {scrapedTagsRow} setFrontImage(value)} /> setBackImage(value)} /> @@ -212,7 +212,7 @@ export const MovieScrapeDialog: React.FC = ({ { diff --git a/ui/v2.5/src/components/Movies/MovieList.tsx b/ui/v2.5/src/components/Movies/MovieList.tsx index 8b42a3b73ec..d4d4bf1c9aa 100644 --- a/ui/v2.5/src/components/Movies/MovieList.tsx +++ b/ui/v2.5/src/components/Movies/MovieList.tsx @@ -14,11 +14,11 @@ import { import { makeItemList, showWhenSelected } from "../List/ItemList"; import { ExportDialog } from "../Shared/ExportDialog"; import { DeleteEntityDialog } from "../Shared/DeleteEntityDialog"; -import { MovieCardGrid } from "./MovieCardGrid"; -import { EditMoviesDialog } from "./EditMoviesDialog"; +import { GroupCardGrid } from "./MovieCardGrid"; +import { EditGroupsDialog } from "./EditMoviesDialog"; import { View } from "../List/views"; -const MovieItemList = makeItemList({ +const GroupItemList = makeItemList({ filterMode: GQL.FilterMode.Movies, useResult: useFindMovies, getItems(result: GQL.FindMoviesQueryResult) { @@ -29,13 +29,13 @@ const MovieItemList = makeItemList({ }, }); -interface IMovieList { +interface IGroupList { filterHook?: (filter: ListFilterModel) => ListFilterModel; view?: View; alterQuery?: boolean; } -export const MovieList: React.FC = ({ +export const GroupList: React.FC = ({ filterHook, alterQuery, view, @@ -90,7 +90,7 @@ export const MovieList: React.FC = ({ if (singleResult.data.findMovies.movies.length === 1) { const { id } = singleResult.data.findMovies.movies[0]; // navigate to the movie page - history.push(`/movies/${id}`); + history.push(`/groups/${id}`); } } } @@ -111,7 +111,7 @@ export const MovieList: React.FC = ({ selectedIds: Set, onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void ) { - function maybeRenderMovieExportDialog() { + function maybeRenderGroupExportDialog() { if (isExportDialogOpen) { return ( = ({ } } - function renderMovies() { + function renderGroups() { if (!result.data?.findMovies) return; if (filter.displayMode === DisplayMode.Grid) { return ( - @@ -145,36 +145,36 @@ export const MovieList: React.FC = ({ } return ( <> - {maybeRenderMovieExportDialog()} - {renderMovies()} + {maybeRenderGroupExportDialog()} + {renderGroups()} ); } function renderEditDialog( - selectedMovies: GQL.MovieDataFragment[], + selectedGroups: GQL.MovieDataFragment[], onClose: (applied: boolean) => void ) { - return ; + return ; } function renderDeleteDialog( - selectedMovies: GQL.SlimMovieDataFragment[], + selectedGroups: GQL.SlimMovieDataFragment[], onClose: (confirmed: boolean) => void ) { return ( ); } return ( - = (props: IProps) => { +export const GroupRecommendationRow: React.FC = (props: IProps) => { const result = useFindMovies(props.filter); const cardCount = result.data?.findMovies.count; @@ -24,10 +24,10 @@ export const MovieRecommendationRow: React.FC = (props: IProps) => { return ( + } @@ -40,10 +40,10 @@ export const MovieRecommendationRow: React.FC = (props: IProps) => { > {result.loading ? [...Array(props.filter.itemsPerPage)].map((i) => ( -
    +
    )) : result.data?.findMovies.movies.map((m) => ( - + ))}
    diff --git a/ui/v2.5/src/components/Movies/MovieSelect.tsx b/ui/v2.5/src/components/Movies/MovieSelect.tsx index 279994d1afc..1aa791235de 100644 --- a/ui/v2.5/src/components/Movies/MovieSelect.tsx +++ b/ui/v2.5/src/components/Movies/MovieSelect.tsx @@ -30,13 +30,13 @@ import { sortByRelevance } from "src/utils/query"; import { PatchComponent, PatchFunction } from "src/patch"; import { TruncatedText } from "../Shared/TruncatedText"; -export type Movie = Pick< +export type Group = Pick< GQL.Movie, "id" | "name" | "date" | "front_image_path" | "aliases" > & { studio?: Pick | null; }; -type Option = SelectOption; +type Option = SelectOption; type FindMoviesResult = Awaited< ReturnType @@ -56,9 +56,9 @@ const movieSelectSort = PatchFunction( sortMoviesByRelevance ); -const _MovieSelect: React.FC< +const _GroupSelect: React.FC< IFilterProps & - IFilterValueProps & { + IFilterValueProps & { hoverPlacement?: Placement; excludeIds?: string[]; } @@ -94,7 +94,7 @@ const _MovieSelect: React.FC< })); } - const MovieOption: React.FC> = (optionProps) => { + const GroupOption: React.FC> = (optionProps) => { let thisOptionProps = optionProps; const { object } = optionProps.data; @@ -111,24 +111,24 @@ const _MovieSelect: React.FC< thisOptionProps = { ...optionProps, children: ( - - + + {object.front_image_path && ( )} - + {title} {alias && ( - {` (${alias})`} + {` (${alias})`} )} } @@ -136,13 +136,13 @@ const _MovieSelect: React.FC< /> {object.studio?.name && ( - + {object.studio?.name} )} {object.date && ( - {object.date} + {object.date} )} @@ -153,7 +153,7 @@ const _MovieSelect: React.FC< return ; }; - const MovieMultiValueLabel: React.FC< + const GroupMultiValueLabel: React.FC< MultiValueGenericProps > = (optionProps) => { let thisOptionProps = optionProps; @@ -168,7 +168,7 @@ const _MovieSelect: React.FC< return ; }; - const MovieValueLabel: React.FC> = ( + const GroupValueLabel: React.FC> = ( optionProps ) => { let thisOptionProps = optionProps; @@ -190,7 +190,7 @@ const _MovieSelect: React.FC< return { value: result.data!.movieCreate!.id, item: result.data!.movieCreate!, - message: "Created movie", + message: "Created group", }; }; @@ -201,7 +201,7 @@ const _MovieSelect: React.FC< }; }; - const isValidNewOption = (inputValue: string, options: Movie[]) => { + const isValidNewOption = (inputValue: string, options: Group[]) => { if (!inputValue) { return false; } @@ -221,12 +221,12 @@ const _MovieSelect: React.FC< }; return ( - + {...props} className={cx( - "movie-select", + "group-select", { - "movie-select-active": props.active, + "group-select-active": props.active, }, props.className )} @@ -234,9 +234,9 @@ const _MovieSelect: React.FC< getNamedObject={getNamedObject} isValidNewOption={isValidNewOption} components={{ - Option: MovieOption, - MultiValueLabel: MovieMultiValueLabel, - SingleValue: MovieValueLabel, + Option: GroupOption, + MultiValueLabel: GroupMultiValueLabel, + SingleValue: GroupValueLabel, }} isMulti={props.isMulti ?? false} creatable={props.creatable ?? defaultCreatable} @@ -247,7 +247,7 @@ const _MovieSelect: React.FC< { id: "actions.select_entity" }, { entityType: intl.formatMessage({ - id: props.isMulti ? "movies" : "movie", + id: props.isMulti ? "groups" : "group", }), } ) @@ -257,22 +257,22 @@ const _MovieSelect: React.FC< ); }; -export const MovieSelect = PatchComponent("MovieSelect", _MovieSelect); +export const GroupSelect = PatchComponent("GroupSelect", _GroupSelect); -const _MovieIDSelect: React.FC> = ( +const _GroupIDSelect: React.FC> = ( props ) => { const { ids, onSelect: onSelectValues } = props; - const [values, setValues] = useState([]); + const [values, setValues] = useState([]); const idsChanged = useCompare(ids); - function onSelect(items: Movie[]) { + function onSelect(items: Group[]) { setValues(items); onSelectValues?.(items); } - async function loadObjectsByID(idsToLoad: string[]): Promise { + async function loadObjectsByID(idsToLoad: string[]): Promise { const query = await queryFindMoviesByIDForSelect(idsToLoad); const { movies: loadedMovies } = query.data.findMovies; @@ -303,7 +303,7 @@ const _MovieIDSelect: React.FC> = ( load(); }, [ids, idsChanged, values]); - return ; + return ; }; -export const MovieIDSelect = PatchComponent("MovieIDSelect", _MovieIDSelect); +export const GroupIDSelect = PatchComponent("GroupIDSelect", _GroupIDSelect); diff --git a/ui/v2.5/src/components/Movies/Movies.tsx b/ui/v2.5/src/components/Movies/Movies.tsx index e93e14720b5..202d8f4945a 100644 --- a/ui/v2.5/src/components/Movies/Movies.tsx +++ b/ui/v2.5/src/components/Movies/Movies.tsx @@ -2,30 +2,30 @@ import React from "react"; import { Route, Switch } from "react-router-dom"; import { Helmet } from "react-helmet"; import { useTitleProps } from "src/hooks/title"; -import Movie from "./MovieDetails/Movie"; -import MovieCreate from "./MovieDetails/MovieCreate"; -import { MovieList } from "./MovieList"; +import Group from "./MovieDetails/Movie"; +import GroupCreate from "./MovieDetails/MovieCreate"; +import { GroupList } from "./MovieList"; import { useScrollToTopOnMount } from "src/hooks/scrollToTop"; import { View } from "../List/views"; -const Movies: React.FC = () => { +const Groups: React.FC = () => { useScrollToTopOnMount(); - return ; + return ; }; -const MovieRoutes: React.FC = () => { - const titleProps = useTitleProps({ id: "movies" }); +const GroupRoutes: React.FC = () => { + const titleProps = useTitleProps({ id: "groups" }); return ( <> - - - + + + ); }; -export default MovieRoutes; +export default GroupRoutes; diff --git a/ui/v2.5/src/components/Movies/styles.scss b/ui/v2.5/src/components/Movies/styles.scss index 58071d4b8f3..3d1868fb815 100644 --- a/ui/v2.5/src/components/Movies/styles.scss +++ b/ui/v2.5/src/components/Movies/styles.scss @@ -1,4 +1,4 @@ -.movie-card { +.group-card { width: 240px; @media (max-width: 576px) { @@ -14,7 +14,7 @@ width: 100%; } - .movie-scene-number { + .group-scene-number { text-align: center; } @@ -23,14 +23,14 @@ } } -.movie-images { +.group-images { align-items: center; display: flex; flex-direction: row; justify-content: space-evenly; max-width: 100%; - .movie-image-container { + .group-image-container { box-shadow: none; } @@ -40,17 +40,17 @@ } } -#movie-page .rating-number .text-input { +#group-page .rating-number .text-input { width: auto; } -.movie-select-option { - .movie-select-row { +.group-select-option { + .group-select-row { align-items: center; display: flex; width: 100%; - .movie-select-image { + .group-select-image { background-color: $body-bg; margin-right: 0.4em; max-height: 50px; @@ -59,26 +59,26 @@ object-position: center; } - .movie-select-details { + .group-select-details { display: flex; flex-direction: column; justify-content: flex-start; max-height: 4.1rem; overflow: hidden; - .movie-select-title { + .group-select-title { flex-shrink: 0; white-space: pre-wrap; word-break: break-all; - .movie-select-alias { + .group-select-alias { font-size: 0.8rem; font-weight: bold; } } - .movie-select-date, - .movie-select-studio { + .group-select-date, + .group-select-studio { color: $text-muted; flex-shrink: 0; font-size: 0.9rem; diff --git a/ui/v2.5/src/components/Performers/PerformerCard.tsx b/ui/v2.5/src/components/Performers/PerformerCard.tsx index 4792e452cd3..3d5765adad6 100644 --- a/ui/v2.5/src/components/Performers/PerformerCard.tsx +++ b/ui/v2.5/src/components/Performers/PerformerCard.tsx @@ -178,15 +178,15 @@ export const PerformerCard: React.FC = ({ ); } - function maybeRenderMoviesPopoverButton() { + function maybeRenderGroupsPopoverButton() { if (!performer.movie_count) return; return ( = ({
    {maybeRenderScenesPopoverButton()} - {maybeRenderMoviesPopoverButton()} + {maybeRenderGroupsPopoverButton()} {maybeRenderImagesPopoverButton()} {maybeRenderGalleriesPopoverButton()} {maybeRenderTagPopoverButton()} diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx index 85674e0231d..2c19fa7753c 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx @@ -27,7 +27,7 @@ import { } from "./PerformerDetailsPanel"; import { PerformerScenesPanel } from "./PerformerScenesPanel"; import { PerformerGalleriesPanel } from "./PerformerGalleriesPanel"; -import { PerformerMoviesPanel } from "./PerformerMoviesPanel"; +import { PerformerGroupsPanel } from "./PerformerMoviesPanel"; import { PerformerImagesPanel } from "./PerformerImagesPanel"; import { PerformerAppearsWithPanel } from "./performerAppearsWithPanel"; import { PerformerEditPanel } from "./PerformerEditPanel"; @@ -60,7 +60,7 @@ const validTabs = [ "scenes", "galleries", "images", - "movies", + "groups", "appearswith", ] as const; type TabKey = (typeof validTabs)[number]; @@ -146,7 +146,7 @@ const PerformerPage: React.FC = ({ performer, tabKey }) => { } else if (performer.image_count != 0) { ret = "images"; } else if (performer.movie_count != 0) { - ret = "movies"; + ret = "groups"; } } @@ -191,7 +191,7 @@ const PerformerPage: React.FC = ({ performer, tabKey }) => { Mousetrap.bind("e", () => toggleEditing()); Mousetrap.bind("c", () => setTabKey("scenes")); Mousetrap.bind("g", () => setTabKey("galleries")); - Mousetrap.bind("m", () => setTabKey("movies")); + Mousetrap.bind("m", () => setTabKey("groups")); Mousetrap.bind("f", () => setFavorite(!performer.favorite)); Mousetrap.bind(",", () => setCollapsed(!collapsed)); @@ -319,10 +319,10 @@ const PerformerPage: React.FC = ({ performer, tabKey }) => { /> - {intl.formatMessage({ id: "movies" })} + {intl.formatMessage({ id: "groups" })} = ({ performer, tabKey }) => { } > - diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerMoviesPanel.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerMoviesPanel.tsx index 0f1c8b7d583..f9a1f7f5a54 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerMoviesPanel.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerMoviesPanel.tsx @@ -1,6 +1,6 @@ import React from "react"; import * as GQL from "src/core/generated-graphql"; -import { MovieList } from "src/components/Movies/MovieList"; +import { GroupList } from "src/components/Movies/MovieList"; import { usePerformerFilterHook } from "src/core/performers"; import { View } from "src/components/List/views"; @@ -9,16 +9,16 @@ interface IPerformerDetailsProps { performer: GQL.PerformerDataFragment; } -export const PerformerMoviesPanel: React.FC = ({ +export const PerformerGroupsPanel: React.FC = ({ active, performer, }) => { const filterHook = usePerformerFilterHook(performer); return ( - ); }; diff --git a/ui/v2.5/src/components/SceneDuplicateChecker/SceneDuplicateChecker.tsx b/ui/v2.5/src/components/SceneDuplicateChecker/SceneDuplicateChecker.tsx index 4c3e6ee54cc..9ee04ac68c9 100644 --- a/ui/v2.5/src/components/SceneDuplicateChecker/SceneDuplicateChecker.tsx +++ b/ui/v2.5/src/components/SceneDuplicateChecker/SceneDuplicateChecker.tsx @@ -21,7 +21,7 @@ import { HoverPopover } from "../Shared/HoverPopover"; import { Icon } from "../Shared/Icon"; import { GalleryLink, - MovieLink, + GroupLink, SceneMarkerLink, TagLink, } from "../Shared/TagLink"; @@ -386,14 +386,14 @@ export const SceneDuplicateChecker: React.FC = () => { return ; } - function maybeRenderMoviePopoverButton(scene: GQL.SlimSceneDataFragment) { + function maybeRenderGroupPopoverButton(scene: GQL.SlimSceneDataFragment) { if (scene.movies.length <= 0) return; const popoverContent = scene.movies.map((sceneMovie) => ( -
    +
    { src={sceneMovie.movie.front_image_path ?? ""} /> -
    @@ -523,7 +523,7 @@ export const SceneDuplicateChecker: React.FC = () => { {maybeRenderTagPopoverButton(scene)} {maybeRenderPerformerPopoverButton(scene)} - {maybeRenderMoviePopoverButton(scene)} + {maybeRenderGroupPopoverButton(scene)} {maybeRenderSceneMarkerPopoverButton(scene)} {maybeRenderOCounter(scene)} {maybeRenderGallery(scene)} diff --git a/ui/v2.5/src/components/Scenes/EditScenesDialog.tsx b/ui/v2.5/src/components/Scenes/EditScenesDialog.tsx index fb85dbf6fc3..f4fc8a1e233 100644 --- a/ui/v2.5/src/components/Scenes/EditScenesDialog.tsx +++ b/ui/v2.5/src/components/Scenes/EditScenesDialog.tsx @@ -13,7 +13,7 @@ import { RatingSystem } from "../Shared/Rating/RatingSystem"; import { getAggregateInputIDs, getAggregateInputValue, - getAggregateMovieIds, + getAggregateGroupIds, getAggregatePerformerIds, getAggregateRating, getAggregateStudioId, @@ -42,11 +42,11 @@ export const EditScenesDialog: React.FC = ( ); const [tagIds, setTagIds] = useState(); const [existingTagIds, setExistingTagIds] = useState(); - const [movieMode, setMovieMode] = React.useState( + const [groupMode, setGroupMode] = React.useState( GQL.BulkUpdateIdMode.Add ); - const [movieIds, setMovieIds] = useState(); - const [existingMovieIds, setExistingMovieIds] = useState(); + const [groupIds, setGroupIds] = useState(); + const [existingGroupIds, setExistingGroupIds] = useState(); const [organized, setOrganized] = useState(); const [updateScenes] = useBulkSceneUpdate(getSceneInput()); @@ -62,7 +62,7 @@ export const EditScenesDialog: React.FC = ( const aggregateStudioId = getAggregateStudioId(props.selected); const aggregatePerformerIds = getAggregatePerformerIds(props.selected); const aggregateTagIds = getAggregateTagIds(props.selected); - const aggregateMovieIds = getAggregateMovieIds(props.selected); + const aggregateGroupIds = getAggregateGroupIds(props.selected); const sceneInput: GQL.BulkSceneUpdateInput = { ids: props.selected.map((scene) => { @@ -80,9 +80,9 @@ export const EditScenesDialog: React.FC = ( ); sceneInput.tag_ids = getAggregateInputIDs(tagMode, tagIds, aggregateTagIds); sceneInput.movie_ids = getAggregateInputIDs( - movieMode, - movieIds, - aggregateMovieIds + groupMode, + groupIds, + aggregateGroupIds ); if (organized !== undefined) { @@ -115,7 +115,7 @@ export const EditScenesDialog: React.FC = ( let updateStudioID: string | undefined; let updatePerformerIds: string[] = []; let updateTagIds: string[] = []; - let updateMovieIds: string[] = []; + let updateGroupIds: string[] = []; let updateOrganized: boolean | undefined; let first = true; @@ -126,14 +126,14 @@ export const EditScenesDialog: React.FC = ( .map((p) => p.id) .sort(); const sceneTagIDs = (scene.tags ?? []).map((p) => p.id).sort(); - const sceneMovieIDs = (scene.movies ?? []).map((m) => m.movie.id).sort(); + const sceneGroupIDs = (scene.movies ?? []).map((m) => m.movie.id).sort(); if (first) { updateRating = sceneRating ?? undefined; updateStudioID = sceneStudioID; updatePerformerIds = scenePerformerIDs; updateTagIds = sceneTagIDs; - updateMovieIds = sceneMovieIDs; + updateGroupIds = sceneGroupIDs; first = false; updateOrganized = scene.organized; } else { @@ -149,8 +149,8 @@ export const EditScenesDialog: React.FC = ( if (!isEqual(sceneTagIDs, updateTagIds)) { updateTagIds = []; } - if (!isEqual(sceneMovieIDs, updateMovieIds)) { - updateMovieIds = []; + if (!isEqual(sceneGroupIDs, updateGroupIds)) { + updateGroupIds = []; } if (scene.organized !== updateOrganized) { updateOrganized = undefined; @@ -162,7 +162,7 @@ export const EditScenesDialog: React.FC = ( setStudioId(updateStudioID); setExistingPerformerIds(updatePerformerIds); setExistingTagIds(updateTagIds); - setExistingMovieIds(updateMovieIds); + setExistingGroupIds(updateGroupIds); setOrganized(updateOrganized); }, [props.selected]); @@ -173,7 +173,7 @@ export const EditScenesDialog: React.FC = ( }, [organized, checkboxRef]); function renderMultiSelect( - type: "performers" | "tags" | "movies", + type: "performers" | "tags" | "groups", ids: string[] | undefined ) { let mode = GQL.BulkUpdateIdMode.Add; @@ -187,9 +187,9 @@ export const EditScenesDialog: React.FC = ( mode = tagMode; existingIds = existingTagIds; break; - case "movies": - mode = movieMode; - existingIds = existingMovieIds; + case "groups": + mode = groupMode; + existingIds = existingGroupIds; break; } @@ -205,8 +205,8 @@ export const EditScenesDialog: React.FC = ( case "tags": setTagIds(itemIDs); break; - case "movies": - setMovieIds(itemIDs); + case "groups": + setGroupIds(itemIDs); break; } }} @@ -218,8 +218,8 @@ export const EditScenesDialog: React.FC = ( case "tags": setTagMode(newMode); break; - case "movies": - setMovieMode(newMode); + case "groups": + setGroupMode(newMode); break; } }} @@ -306,11 +306,11 @@ export const EditScenesDialog: React.FC = ( {renderMultiSelect("tags", tagIds)} - + - + - {renderMultiSelect("movies", movieIds)} + {renderMultiSelect("groups", groupIds)} diff --git a/ui/v2.5/src/components/Scenes/SceneCard.tsx b/ui/v2.5/src/components/Scenes/SceneCard.tsx index b648ec4376e..694d1bdcc56 100644 --- a/ui/v2.5/src/components/Scenes/SceneCard.tsx +++ b/ui/v2.5/src/components/Scenes/SceneCard.tsx @@ -7,7 +7,7 @@ import { Icon } from "../Shared/Icon"; import { GalleryLink, TagLink, - MovieLink, + GroupLink, SceneMarkerLink, } from "../Shared/TagLink"; import { HoverPopover } from "../Shared/HoverPopover"; @@ -143,24 +143,24 @@ const SceneCardPopovers = PatchComponent( return ; } - function maybeRenderMoviePopoverButton() { + function maybeRenderGroupPopoverButton() { if (props.scene.movies.length <= 0) return; - const popoverContent = props.scene.movies.map((sceneMovie) => ( -
    + const popoverContent = props.scene.movies.map((sceneGroup) => ( +
    {sceneMovie.movie.name -
    @@ -170,7 +170,7 @@ const SceneCardPopovers = PatchComponent(
    diff --git a/ui/v2.5/src/components/Shared/Link.tsx b/ui/v2.5/src/components/Shared/Link.tsx index 7bfeef4135b..d3da7eac18d 100644 --- a/ui/v2.5/src/components/Shared/Link.tsx +++ b/ui/v2.5/src/components/Shared/Link.tsx @@ -6,14 +6,14 @@ import NavUtils from "src/utils/navigation"; export const DirectorLink: React.FC<{ director: string; - linkType: "scene" | "movie"; + linkType: "scene" | "group"; }> = ({ director: director, linkType = "scene" }) => { const link = useMemo(() => { switch (linkType) { case "scene": return NavUtils.makeDirectorScenesUrl(director); - case "movie": - return NavUtils.makeDirectorMoviesUrl(director); + case "group": + return NavUtils.makeDirectorGroupsUrl(director); } }, [director, linkType]); diff --git a/ui/v2.5/src/components/Shared/MultiSet.tsx b/ui/v2.5/src/components/Shared/MultiSet.tsx index f92b57ff313..521a2577bef 100644 --- a/ui/v2.5/src/components/Shared/MultiSet.tsx +++ b/ui/v2.5/src/components/Shared/MultiSet.tsx @@ -10,7 +10,7 @@ import { } from "../Galleries/GallerySelect"; interface IMultiSetProps { - type: "performers" | "studios" | "tags" | "movies" | "galleries"; + type: "performers" | "studios" | "tags" | "groups" | "galleries"; existingIds?: string[]; ids?: string[]; mode: GQL.BulkUpdateIdMode; diff --git a/ui/v2.5/src/components/Shared/PopoverCountButton.tsx b/ui/v2.5/src/components/Shared/PopoverCountButton.tsx index c455145fc3c..aada2fe193f 100644 --- a/ui/v2.5/src/components/Shared/PopoverCountButton.tsx +++ b/ui/v2.5/src/components/Shared/PopoverCountButton.tsx @@ -20,7 +20,7 @@ type PopoverLinkType = | "image" | "gallery" | "marker" - | "movie" + | "group" | "performer" | "studio"; @@ -52,7 +52,7 @@ export const PopoverCountButton: React.FC = ({ return faImages; case "marker": return faMapMarkerAlt; - case "movie": + case "group": return faFilm; case "performer": return faUser; @@ -83,10 +83,10 @@ export const PopoverCountButton: React.FC = ({ one: "marker", other: "markers", }; - case "movie": + case "group": return { - one: "movie", - other: "movies", + one: "group", + other: "groups", }; case "performer": return { diff --git a/ui/v2.5/src/components/Shared/ScrapeDialog/ScrapedObjectsRow.tsx b/ui/v2.5/src/components/Shared/ScrapeDialog/ScrapedObjectsRow.tsx index 7184716a744..73589ce926d 100644 --- a/ui/v2.5/src/components/Shared/ScrapeDialog/ScrapedObjectsRow.tsx +++ b/ui/v2.5/src/components/Shared/ScrapeDialog/ScrapedObjectsRow.tsx @@ -8,7 +8,7 @@ import { } from "src/components/Shared/ScrapeDialog/scrapeResult"; import { TagSelect } from "src/components/Tags/TagSelect"; import { StudioSelect } from "src/components/Studios/StudioSelect"; -import { MovieSelect } from "src/components/Movies/MovieSelect"; +import { GroupSelect } from "src/components/Movies/MovieSelect"; interface IScrapedStudioRow { title: string; @@ -196,10 +196,10 @@ export const ScrapedPerformersRow: React.FC< ); }; -export const ScrapedMoviesRow: React.FC< +export const ScrapedGroupsRow: React.FC< IScrapedObjectRowImpl > = ({ title, result, onChange, newObjects, onCreateNew }) => { - const moviesCopy = useMemo(() => { + const groupsCopy = useMemo(() => { return ( newObjects?.map((p) => { const name: string = p.name ?? ""; @@ -208,7 +208,7 @@ export const ScrapedMoviesRow: React.FC< ); }, [newObjects]); - function renderScrapedMovies( + function renderScrapedGroups( scrapeResult: ScrapeResult, isNew?: boolean, onChangeFn?: (value: GQL.ScrapedMovie[]) => void @@ -228,7 +228,7 @@ export const ScrapedMoviesRow: React.FC< }); return ( - title={title} result={result} - renderObjects={renderScrapedMovies} + renderObjects={renderScrapedGroups} onChange={onChange} - newObjects={moviesCopy} + newObjects={groupsCopy} onCreateNew={onCreateNew} getName={(value) => value.name ?? ""} /> diff --git a/ui/v2.5/src/components/Shared/ScrapeDialog/createObjects.ts b/ui/v2.5/src/components/Shared/ScrapeDialog/createObjects.ts index 009677e5940..397681483e0 100644 --- a/ui/v2.5/src/components/Shared/ScrapeDialog/createObjects.ts +++ b/ui/v2.5/src/components/Shared/ScrapeDialog/createObjects.ts @@ -9,7 +9,7 @@ import { import { ObjectScrapeResult, ScrapeResult } from "./scrapeResult"; import { useIntl } from "react-intl"; import { scrapedPerformerToCreateInput } from "src/core/performers"; -import { scrapedMovieToCreateInput } from "src/core/movies"; +import { scrapedGroupToCreateInput } from "src/core/movies"; function useCreateObject( entityTypeID: string, @@ -123,16 +123,16 @@ export function useCreateScrapedPerformer( return useCreateObject("performer", createNewPerformer); } -export function useCreateScrapedMovie( +export function useCreateScrapedGroup( props: IUseCreateNewObjectProps ) { const { scrapeResult, setScrapeResult, newObjects, setNewObjects } = props; - const [createMovie] = useMovieCreate(); + const [createGroup] = useMovieCreate(); - async function createNewMovie(toCreate: GQL.ScrapedMovie) { - const input = scrapedMovieToCreateInput(toCreate); + async function createNewGroup(toCreate: GQL.ScrapedMovie) { + const input = scrapedGroupToCreateInput(toCreate); - const result = await createMovie({ + const result = await createGroup({ variables: { input: input }, }); @@ -150,14 +150,14 @@ export function useCreateScrapedMovie( // remove the object from the list const newObjectsClone = newObjects.concat(); const pIndex = newObjectsClone.findIndex((p) => p.name === toCreate.name); - if (pIndex === -1) throw new Error("Could not find movie to remove"); + if (pIndex === -1) throw new Error("Could not find group to remove"); newObjectsClone.splice(pIndex, 1); setNewObjects(newObjectsClone); } - return useCreateObject("movie", createNewMovie); + return useCreateObject("group", createNewGroup); } export function useCreateScrapedTag( diff --git a/ui/v2.5/src/components/Shared/Select.tsx b/ui/v2.5/src/components/Shared/Select.tsx index 201bb5a3d85..e989c886de2 100644 --- a/ui/v2.5/src/components/Shared/Select.tsx +++ b/ui/v2.5/src/components/Shared/Select.tsx @@ -26,7 +26,7 @@ import { faTableColumns } from "@fortawesome/free-solid-svg-icons"; import { TagIDSelect } from "../Tags/TagSelect"; import { StudioIDSelect } from "../Studios/StudioSelect"; import { GalleryIDSelect } from "../Galleries/GallerySelect"; -import { MovieIDSelect } from "../Movies/MovieSelect"; +import { GroupIDSelect } from "../Movies/MovieSelect"; import { SceneIDSelect } from "../Scenes/SceneSelect"; export type SelectObject = { @@ -44,7 +44,7 @@ interface ITypeProps { | "scene_tags" | "performer_tags" | "scenes" - | "movies" + | "groups" | "galleries"; } interface IFilterProps { @@ -364,8 +364,8 @@ export const StudioSelect: React.FC< return ; }; -export const MovieSelect: React.FC = (props) => { - return ; +export const GroupSelect: React.FC = (props) => { + return ; }; export const TagSelect: React.FC< @@ -382,8 +382,8 @@ export const FilterSelect: React.FC = (props) => { return ; case "scenes": return ; - case "movies": - return ; + case "groups": + return ; case "galleries": return ; default: diff --git a/ui/v2.5/src/components/Shared/TagLink.tsx b/ui/v2.5/src/components/Shared/TagLink.tsx index f2fe7c49ff6..d01de3eac42 100644 --- a/ui/v2.5/src/components/Shared/TagLink.tsx +++ b/ui/v2.5/src/components/Shared/TagLink.tsx @@ -71,25 +71,25 @@ export const PerformerLink: React.FC = ({ ); }; -interface IMovieLinkProps { - movie: INamedObject; +interface IGroupLinkProps { + group: INamedObject; linkType?: "scene"; className?: string; } -export const MovieLink: React.FC = ({ - movie, +export const GroupLink: React.FC = ({ + group, linkType = "scene", className, }) => { const link = useMemo(() => { switch (linkType) { case "scene": - return NavUtils.makeMovieScenesUrl(movie); + return NavUtils.makeGroupScenesUrl(group); } - }, [movie, linkType]); + }, [group, linkType]); - const title = movie.name || ""; + const title = group.name || ""; return ( @@ -197,7 +197,7 @@ interface ITagLinkProps { | "image" | "details" | "performer" - | "movie" + | "group" | "studio"; className?: string; hoverPlacement?: Placement; @@ -225,8 +225,8 @@ export const TagLink: React.FC = ({ return NavUtils.makeTagGalleriesUrl(tag); case "image": return NavUtils.makeTagImagesUrl(tag); - case "movie": - return NavUtils.makeTagMoviesUrl(tag); + case "group": + return NavUtils.makeTagGroupsUrl(tag); case "details": return NavUtils.makeTagUrl(tag.id ?? ""); } diff --git a/ui/v2.5/src/components/Stats.tsx b/ui/v2.5/src/components/Stats.tsx index f177aa46194..608afc0e2c3 100644 --- a/ui/v2.5/src/components/Stats.tsx +++ b/ui/v2.5/src/components/Stats.tsx @@ -53,7 +53,7 @@ export const Stats: React.FC = () => {

    - +

    diff --git a/ui/v2.5/src/components/Studios/StudioCard.tsx b/ui/v2.5/src/components/Studios/StudioCard.tsx index 1c1e5e6eeac..62604555e2a 100644 --- a/ui/v2.5/src/components/Studios/StudioCard.tsx +++ b/ui/v2.5/src/components/Studios/StudioCard.tsx @@ -142,15 +142,15 @@ export const StudioCard: React.FC = ({ ); } - function maybeRenderMoviesPopoverButton() { + function maybeRenderGroupsPopoverButton() { if (!studio.movie_count) return; return ( ); } @@ -199,7 +199,7 @@ export const StudioCard: React.FC = ({
    {maybeRenderScenesPopoverButton()} - {maybeRenderMoviesPopoverButton()} + {maybeRenderGroupsPopoverButton()} {maybeRenderImagesPopoverButton()} {maybeRenderGalleriesPopoverButton()} {maybeRenderPerformersPopoverButton()} diff --git a/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx b/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx index 35052b091c4..870db812c55 100644 --- a/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx +++ b/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx @@ -31,7 +31,7 @@ import { CompressedStudioDetailsPanel, StudioDetailsPanel, } from "./StudioDetailsPanel"; -import { StudioMoviesPanel } from "./StudioMoviesPanel"; +import { StudioGroupsPanel } from "./StudioMoviesPanel"; import { faTrashAlt, faLink, @@ -63,7 +63,7 @@ const validTabs = [ "galleries", "images", "performers", - "movies", + "groups", "childstudios", ] as const; type TabKey = (typeof validTabs)[number]; @@ -108,7 +108,7 @@ const StudioPage: React.FC = ({ studio, tabKey }) => { (showAllCounts ? studio.image_count_all : studio.image_count) ?? 0; const performerCount = (showAllCounts ? studio.performer_count_all : studio.performer_count) ?? 0; - const movieCount = + const groupCount = (showAllCounts ? studio.movie_count_all : studio.movie_count) ?? 0; const populatedDefaultTab = useMemo(() => { @@ -120,8 +120,8 @@ const StudioPage: React.FC = ({ studio, tabKey }) => { ret = "images"; } else if (performerCount != 0) { ret = "performers"; - } else if (movieCount != 0) { - ret = "movies"; + } else if (groupCount != 0) { + ret = "groups"; } else if (studio.child_studios.length != 0) { ret = "childstudios"; } @@ -133,7 +133,7 @@ const StudioPage: React.FC = ({ studio, tabKey }) => { galleryCount, imageCount, performerCount, - movieCount, + groupCount, studio, ]); @@ -437,19 +437,19 @@ const StudioPage: React.FC = ({ studio, tabKey }) => { /> - {intl.formatMessage({ id: "movies" })} + {intl.formatMessage({ id: "groups" })} } > - + = ({ +export const StudioGroupsPanel: React.FC = ({ active, studio, }) => { const filterHook = useStudioFilterHook(studio); return ( - ); }; diff --git a/ui/v2.5/src/components/Tags/TagCard.tsx b/ui/v2.5/src/components/Tags/TagCard.tsx index 424f8c5f518..770e0bb9134 100644 --- a/ui/v2.5/src/components/Tags/TagCard.tsx +++ b/ui/v2.5/src/components/Tags/TagCard.tsx @@ -236,15 +236,15 @@ export const TagCard: React.FC = ({ ); } - function maybeRenderMoviesPopoverButton() { + function maybeRenderGroupsPopoverButton() { if (!tag.movie_count) return; return ( ); } @@ -258,7 +258,7 @@ export const TagCard: React.FC = ({ {maybeRenderScenesPopoverButton()} {maybeRenderImagesPopoverButton()} {maybeRenderGalleriesPopoverButton()} - {maybeRenderMoviesPopoverButton()} + {maybeRenderGroupsPopoverButton()} {maybeRenderSceneMarkersPopoverButton()} {maybeRenderPerformersPopoverButton()} {maybeRenderStudiosPopoverButton()} diff --git a/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx b/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx index c80473db8fd..51a334c11b4 100644 --- a/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx +++ b/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx @@ -42,7 +42,7 @@ import { import { DetailImage } from "src/components/Shared/DetailImage"; import { useLoadStickyHeader } from "src/hooks/detailsPanel"; import { useScrollToTopOnMount } from "src/hooks/scrollToTop"; -import { TagMoviesPanel } from "./TagMoviesPanel"; +import { TagGroupsPanel } from "./TagMoviesPanel"; interface IProps { tag: GQL.TagDataFragment; @@ -59,7 +59,7 @@ const validTabs = [ "scenes", "images", "galleries", - "movies", + "groups", "markers", "performers", "studios", @@ -105,7 +105,7 @@ const TagPage: React.FC = ({ tag, tabKey }) => { (showAllCounts ? tag.image_count_all : tag.image_count) ?? 0; const galleryCount = (showAllCounts ? tag.gallery_count_all : tag.gallery_count) ?? 0; - const movieCount = + const groupCount = (showAllCounts ? tag.movie_count_all : tag.movie_count) ?? 0; const sceneMarkerCount = (showAllCounts ? tag.scene_marker_count_all : tag.scene_marker_count) ?? 0; @@ -121,8 +121,8 @@ const TagPage: React.FC = ({ tag, tabKey }) => { ret = "images"; } else if (galleryCount != 0) { ret = "galleries"; - } else if (movieCount != 0) { - ret = "movies"; + } else if (groupCount != 0) { + ret = "groups"; } else if (sceneMarkerCount != 0) { ret = "markers"; } else if (performerCount != 0) { @@ -140,7 +140,7 @@ const TagPage: React.FC = ({ tag, tabKey }) => { sceneMarkerCount, performerCount, studioCount, - movieCount, + groupCount, ]); const setTabKey = useCallback( @@ -484,19 +484,19 @@ const TagPage: React.FC = ({ tag, tabKey }) => { - {intl.formatMessage({ id: "movies" })} + {intl.formatMessage({ id: "groups" })} } > - + = ({ active, tag }) => { const filterHook = useTagFilterHook(tag); - return ; + return ; }; diff --git a/ui/v2.5/src/core/config.ts b/ui/v2.5/src/core/config.ts index 3e7585df76d..e8f8290617f 100644 --- a/ui/v2.5/src/core/config.ts +++ b/ui/v2.5/src/core/config.ts @@ -143,7 +143,7 @@ export function generateDefaultFrontPageContent(intl: IntlShape) { return [ recentlyReleased(intl, FilterMode.Scenes, "scenes"), recentlyAdded(intl, FilterMode.Studios, "studios"), - recentlyReleased(intl, FilterMode.Movies, "movies"), + recentlyReleased(intl, FilterMode.Movies, "groups"), recentlyAdded(intl, FilterMode.Performers, "performers"), recentlyReleased(intl, FilterMode.Galleries, "galleries"), ]; @@ -156,8 +156,8 @@ export function generatePremadeFrontPageContent(intl: IntlShape) { recentlyReleased(intl, FilterMode.Galleries, "galleries"), recentlyAdded(intl, FilterMode.Galleries, "galleries"), recentlyAdded(intl, FilterMode.Images, "images"), - recentlyReleased(intl, FilterMode.Movies, "movies"), - recentlyAdded(intl, FilterMode.Movies, "movies"), + recentlyReleased(intl, FilterMode.Movies, "groups"), + recentlyAdded(intl, FilterMode.Movies, "groups"), recentlyAdded(intl, FilterMode.Studios, "studios"), recentlyAdded(intl, FilterMode.Performers, "performers"), ]; diff --git a/ui/v2.5/src/core/movies.ts b/ui/v2.5/src/core/movies.ts index 470de21eea2..1183785f16d 100644 --- a/ui/v2.5/src/core/movies.ts +++ b/ui/v2.5/src/core/movies.ts @@ -1,7 +1,7 @@ import * as GQL from "src/core/generated-graphql"; import TextUtils from "src/utils/text"; -export const scrapedMovieToCreateInput = (toCreate: GQL.ScrapedMovie) => { +export const scrapedGroupToCreateInput = (toCreate: GQL.ScrapedMovie) => { const input: GQL.MovieCreateInput = { name: toCreate.name ?? "", url: toCreate.url, diff --git a/ui/v2.5/src/index.scss b/ui/v2.5/src/index.scss index 2e9587f9632..df3cd9e2406 100755 --- a/ui/v2.5/src/index.scss +++ b/ui/v2.5/src/index.scss @@ -54,7 +54,7 @@ body { } } -#movie-page, +#group-page, #performer-page, #studio-page, #tag-page { @@ -83,7 +83,7 @@ dd { display: none; } - .movie-name, + .group-name, .performer-name, .studio-name, .tag-name { @@ -93,7 +93,7 @@ dd { .sticky.detail-header-group { padding: 1rem 2.5rem; - a.movie-name, + a.group-name, a.performer-name, a.studio-name, a.tag-name { @@ -313,7 +313,7 @@ dd { justify-content: center; padding: 0 1rem; - .movie-images { + .group-images { height: 100%; } @@ -322,7 +322,7 @@ dd { height: auto; padding: 0; - .movie-images { + .group-images { .img { max-width: 100%; } @@ -335,18 +335,18 @@ dd { transition: 0.5s; } - .movie-images img { + .group-images img { @media (max-width: 576px) { max-width: 100%; } } } -#movie-page .detail-header-image .movie-images img { +#group-page .detail-header-image .group-images img { max-width: 13rem; } -#movie-page .detail-header-image img, +#group-page .detail-header-image img, #performer-page .detail-header-image img, #tag-page .detail-header-image img { border-radius: 0.5rem; diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index 805eb9b50a9..5145ea829cd 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -798,9 +798,9 @@ "countables": { "files": "{count, plural, one {File} other {Files}}", "galleries": "{count, plural, one {Gallery} other {Galleries}}", + "groups": "{count, plural, one {Group} other {Groups}}", "images": "{count, plural, one {Image} other {Images}}", "markers": "{count, plural, one {Marker} other {Markers}}", - "movies": "{count, plural, one {Movie} other {Movies}}", "performers": "{count, plural, one {Performer} other {Performers}}", "scenes": "{count, plural, one {Scene} other {Scenes}}", "studios": "{count, plural, one {Studio} other {Studios}}", @@ -1060,6 +1060,10 @@ "TRANSGENDER_FEMALE": "Transgender Female", "TRANSGENDER_MALE": "Transgender Male" }, + "group": "Group", + "group_count": "Group Count", + "group_scene_number": "Scene Number", + "groups": "Groups", "hair_color": "Hair Colour", "handy_connection_status": { "connecting": "Connecting", @@ -1117,10 +1121,6 @@ }, "megabits_per_second": "{value} mbps", "metadata": "Metadata", - "movie": "Movie", - "movie_count": "Movie Count", - "movie_scene_number": "Scene Number", - "movies": "Movies", "name": "Name", "new": "New", "none": "None", 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 90874b70a57..6be7d604087 100644 --- a/ui/v2.5/src/models/list-filter/criteria/criterion.ts +++ b/ui/v2.5/src/models/list-filter/criteria/criterion.ts @@ -173,7 +173,7 @@ export type InputType = | "performer_tags" | "scenes" | "scene_tags" - | "movies" + | "groups" | "galleries" | undefined; diff --git a/ui/v2.5/src/models/list-filter/criteria/movies.ts b/ui/v2.5/src/models/list-filter/criteria/movies.ts index 547fc40b6f9..391fdb1d27c 100644 --- a/ui/v2.5/src/models/list-filter/criteria/movies.ts +++ b/ui/v2.5/src/models/list-filter/criteria/movies.ts @@ -1,9 +1,9 @@ import { ILabeledIdCriterion, ILabeledIdCriterionOption } from "./criterion"; -const inputType = "movies"; +const inputType = "groups"; export const MoviesCriterionOption = new ILabeledIdCriterionOption( - "movies", + "groups", "movies", false, inputType, diff --git a/ui/v2.5/src/models/list-filter/scenes.ts b/ui/v2.5/src/models/list-filter/scenes.ts index c25ee97668d..9a8e3f5e1db 100644 --- a/ui/v2.5/src/models/list-filter/scenes.ts +++ b/ui/v2.5/src/models/list-filter/scenes.ts @@ -47,7 +47,6 @@ const sortByOptions = [ "resume_time", "play_duration", "play_count", - "movie_scene_number", "interactive", "interactive_speed", "perceptual_similarity", @@ -59,6 +58,10 @@ const sortByOptions = [ messageID: "o_count", value: "o_counter", }, + { + messageID: "group_scene_number", + value: "movie_scene_number", + }, ]); const displayModeOptions = [ DisplayMode.Grid, diff --git a/ui/v2.5/src/models/list-filter/tags.ts b/ui/v2.5/src/models/list-filter/tags.ts index 51df9ed896c..dc84a9676bd 100644 --- a/ui/v2.5/src/models/list-filter/tags.ts +++ b/ui/v2.5/src/models/list-filter/tags.ts @@ -36,7 +36,7 @@ const sortByOptions = ["name", "random"] value: "scenes_count", }, { - messageID: "movie_count", + messageID: "group_count", value: "movies_count", }, { @@ -62,7 +62,7 @@ const criterionOptions = [ createMandatoryNumberCriterionOption("gallery_count"), createMandatoryNumberCriterionOption("performer_count"), createMandatoryNumberCriterionOption("studio_count"), - createMandatoryNumberCriterionOption("movie_count"), + createMandatoryNumberCriterionOption("movie_count", "group_count"), createMandatoryNumberCriterionOption("marker_count"), ParentTagsCriterionOption, new MandatoryNumberCriterionOption("parent_tag_count", "parent_count"), diff --git a/ui/v2.5/src/pluginApi.d.ts b/ui/v2.5/src/pluginApi.d.ts index ae292ceeeb3..9d3aad40f90 100644 --- a/ui/v2.5/src/pluginApi.d.ts +++ b/ui/v2.5/src/pluginApi.d.ts @@ -693,12 +693,12 @@ declare namespace PluginApi { function makePerformerScenesUrl(...args: any[]): any; function makePerformerImagesUrl(...args: any[]): any; function makePerformerGalleriesUrl(...args: any[]): any; - function makePerformerMoviesUrl(...args: any[]): any; + function makePerformerGroupsUrl(...args: any[]): any; function makePerformersCountryUrl(...args: any[]): any; function makeStudioScenesUrl(...args: any[]): any; function makeStudioImagesUrl(...args: any[]): any; function makeStudioGalleriesUrl(...args: any[]): any; - function makeStudioMoviesUrl(...args: any[]): any; + function makeStudioGroupsUrl(...args: any[]): any; function makeStudioPerformersUrl(...args: any[]): any; function makeTagUrl(...args: any[]): any; function makeParentTagsUrl(...args: any[]): any; @@ -710,7 +710,7 @@ declare namespace PluginApi { function makeTagImagesUrl(...args: any[]): any; function makeScenesPHashMatchUrl(...args: any[]): any; function makeSceneMarkerUrl(...args: any[]): any; - function makeMovieScenesUrl(...args: any[]): any; + function makeGroupScenesUrl(...args: any[]): any; function makeChildStudiosUrl(...args: any[]): any; function makeGalleryImagesUrl(...args: any[]): any; } diff --git a/ui/v2.5/src/utils/bulkUpdate.ts b/ui/v2.5/src/utils/bulkUpdate.ts index 22b5d3a68a7..9856c933690 100644 --- a/ui/v2.5/src/utils/bulkUpdate.ts +++ b/ui/v2.5/src/utils/bulkUpdate.ts @@ -81,11 +81,11 @@ export function getAggregateTagIds(state: { tags: IHasID[] }[]) { return getAggregateIds(sortedLists); } -interface IMovie { +interface IGroup { movie: IHasID; } -export function getAggregateMovieIds(state: { movies: IMovie[] }[]) { +export function getAggregateGroupIds(state: { movies: IGroup[] }[]) { const sortedLists = state.map((o) => o.movies.map((oo) => oo.movie.id).sort() ); diff --git a/ui/v2.5/src/utils/navigation.ts b/ui/v2.5/src/utils/navigation.ts index 864618fd414..c246d699a50 100644 --- a/ui/v2.5/src/utils/navigation.ts +++ b/ui/v2.5/src/utils/navigation.ts @@ -103,7 +103,7 @@ const makePerformerGalleriesUrl = ( return `/galleries?${filter.makeQueryParameters()}`; }; -const makePerformerMoviesUrl = ( +const makePerformerGroupsUrl = ( performer: Partial, extraPerformer?: ILabeledId, extraCriteria?: Criterion[] @@ -121,7 +121,7 @@ const makePerformerMoviesUrl = ( filter.criteria.push(criterion); addExtraCriteria(filter.criteria, extraCriteria); - return `/movies?${filter.makeQueryParameters()}`; + return `/groups?${filter.makeQueryParameters()}`; }; const makePerformersCountryUrl = ( @@ -174,7 +174,7 @@ const makeStudioGalleriesUrl = (studio: Partial) => { return `/galleries?${filter.makeQueryParameters()}`; }; -const makeStudioMoviesUrl = (studio: Partial) => { +const makeStudioGroupsUrl = (studio: Partial) => { if (!studio.id) return "#"; const filter = new ListFilterModel(GQL.FilterMode.Movies, undefined); const criterion = new StudiosCriterion(); @@ -184,7 +184,7 @@ const makeStudioMoviesUrl = (studio: Partial) => { depth: 0, }; filter.criteria.push(criterion); - return `/movies?${filter.makeQueryParameters()}`; + return `/groups?${filter.makeQueryParameters()}`; }; const makeStudioPerformersUrl = (studio: Partial) => { @@ -211,12 +211,12 @@ const makeChildStudiosUrl = (studio: Partial) => { return `/studios?${filter.makeQueryParameters()}`; }; -const makeMovieScenesUrl = (movie: Partial) => { - if (!movie.id) return "#"; +const makeGroupScenesUrl = (group: Partial) => { + if (!group.id) return "#"; const filter = new ListFilterModel(GQL.FilterMode.Scenes, undefined); const criterion = new MoviesCriterion(); criterion.value = [ - { id: movie.id, label: movie.name || `Movie ${movie.id}` }, + { id: group.id, label: group.name || `Group ${group.id}` }, ]; filter.criteria.push(criterion); return `/scenes?${filter.makeQueryParameters()}`; @@ -298,8 +298,8 @@ const makeTagImagesUrl = (tag: INamedObject) => { return `/images?${makeTagFilter(GQL.FilterMode.Images, tag)}`; }; -const makeTagMoviesUrl = (tag: INamedObject) => { - return `/movies?${makeTagFilter(GQL.FilterMode.Movies, tag)}`; +const makeTagGroupsUrl = (tag: INamedObject) => { + return `/groups?${makeTagFilter(GQL.FilterMode.Movies, tag)}`; }; type SceneMarkerDataFragment = Pick & { @@ -349,13 +349,13 @@ const makeDirectorScenesUrl = (director: string) => { return `/scenes?${filter.makeQueryParameters()}`; }; -const makeDirectorMoviesUrl = (director: string) => { +const makeDirectorGroupsUrl = (director: string) => { if (director.length == 0) return "#"; const filter = new ListFilterModel(GQL.FilterMode.Movies, undefined); filter.criteria.push( stringEqualsCriterion(createStringCriterionOption("director"), director) ); - return `/movies?${filter.makeQueryParameters()}`; + return `/groups?${filter.makeQueryParameters()}`; }; const makePhotographerGalleriesUrl = (photographer: string) => { @@ -401,12 +401,12 @@ const NavUtils = { makePerformerScenesUrl, makePerformerImagesUrl, makePerformerGalleriesUrl, - makePerformerMoviesUrl, + makePerformerGroupsUrl, makePerformersCountryUrl, makeStudioScenesUrl, makeStudioImagesUrl, makeStudioGalleriesUrl, - makeStudioMoviesUrl, + makeStudioGroupsUrl: makeStudioGroupsUrl, makeStudioPerformersUrl, makeTagUrl, makeParentTagsUrl, @@ -417,16 +417,16 @@ const NavUtils = { makeTagStudiosUrl, makeTagGalleriesUrl, makeTagImagesUrl, - makeTagMoviesUrl, + makeTagGroupsUrl, makeScenesPHashMatchUrl, makeSceneMarkerUrl, - makeMovieScenesUrl, + makeGroupScenesUrl, makeChildStudiosUrl, makeGalleryImagesUrl, makeDirectorScenesUrl, makePhotographerGalleriesUrl, makePhotographerImagesUrl, - makeDirectorMoviesUrl, + makeDirectorGroupsUrl, }; export default NavUtils; From 48035061ec65f27a191e68226487ccc77827cfb4 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Wed, 19 Jun 2024 19:52:33 +1000 Subject: [PATCH 032/103] Fix identify clearing parent studio when merging (#4993) * Refactor ScrapedStudio.ToPartial signature * Add unit test * Don't clear parent studio during ToPartial --- internal/identify/studio.go | 6 +- internal/manager/task_stash_box_tag.go | 12 +-- pkg/models/model_scraped_item.go | 8 +- pkg/models/model_scraped_item_test.go | 120 +++++++++++++++++++++++++ 4 files changed, 132 insertions(+), 14 deletions(-) diff --git a/internal/identify/studio.go b/internal/identify/studio.go index d05967bc4f2..51bcaf2eec9 100644 --- a/internal/identify/studio.go +++ b/internal/identify/studio.go @@ -46,17 +46,17 @@ func createMissingStudio(ctx context.Context, endpoint string, w models.StudioRe return nil, err } - studioPartial := s.Parent.ToPartial(s.Parent.StoredID, endpoint, nil, existingStashIDs) + studioPartial := s.Parent.ToPartial(*s.Parent.StoredID, endpoint, nil, existingStashIDs) parentImage, err := s.Parent.GetImage(ctx, nil) if err != nil { return nil, err } - if err := studio.ValidateModify(ctx, *studioPartial, w); err != nil { + if err := studio.ValidateModify(ctx, studioPartial, w); err != nil { return nil, err } - _, err = w.UpdatePartial(ctx, *studioPartial) + _, err = w.UpdatePartial(ctx, studioPartial) if err != nil { return nil, err } diff --git a/internal/manager/task_stash_box_tag.go b/internal/manager/task_stash_box_tag.go index 298b58e279f..8bb39960140 100644 --- a/internal/manager/task_stash_box_tag.go +++ b/internal/manager/task_stash_box_tag.go @@ -311,13 +311,13 @@ func (t *StashBoxBatchTagTask) processMatchedStudio(ctx context.Context, s *mode return err } - partial := s.ToPartial(s.StoredID, t.box.Endpoint, excluded, existingStashIDs) + partial := s.ToPartial(*s.StoredID, t.box.Endpoint, excluded, existingStashIDs) - if err := studio.ValidateModify(ctx, *partial, qb); err != nil { + if err := studio.ValidateModify(ctx, partial, qb); err != nil { return err } - if _, err := qb.UpdatePartial(ctx, *partial); err != nil { + if _, err := qb.UpdatePartial(ctx, partial); err != nil { return err } @@ -435,13 +435,13 @@ func (t *StashBoxBatchTagTask) processParentStudio(ctx context.Context, parent * return err } - partial := parent.ToPartial(parent.StoredID, t.box.Endpoint, excluded, existingStashIDs) + partial := parent.ToPartial(*parent.StoredID, t.box.Endpoint, excluded, existingStashIDs) - if err := studio.ValidateModify(ctx, *partial, qb); err != nil { + if err := studio.ValidateModify(ctx, partial, qb); err != nil { return err } - if _, err := qb.UpdatePartial(ctx, *partial); err != nil { + if _, err := qb.UpdatePartial(ctx, partial); err != nil { return err } diff --git a/pkg/models/model_scraped_item.go b/pkg/models/model_scraped_item.go index cb383c082e7..e5bbdc8dd77 100644 --- a/pkg/models/model_scraped_item.go +++ b/pkg/models/model_scraped_item.go @@ -62,9 +62,9 @@ func (s *ScrapedStudio) GetImage(ctx context.Context, excluded map[string]bool) return nil, nil } -func (s *ScrapedStudio) ToPartial(id *string, endpoint string, excluded map[string]bool, existingStashIDs []StashID) *StudioPartial { +func (s *ScrapedStudio) ToPartial(id string, endpoint string, excluded map[string]bool, existingStashIDs []StashID) StudioPartial { ret := NewStudioPartial() - ret.ID, _ = strconv.Atoi(*id) + ret.ID, _ = strconv.Atoi(id) if s.Name != "" && !excluded["name"] { ret.Name = NewOptionalString(s.Name) @@ -82,8 +82,6 @@ func (s *ScrapedStudio) ToPartial(id *string, endpoint string, excluded map[stri ret.ParentID = NewOptionalInt(parentID) } } - } else { - ret.ParentID = NewOptionalIntPtr(nil) } if s.RemoteSiteID != nil && endpoint != "" { @@ -97,7 +95,7 @@ func (s *ScrapedStudio) ToPartial(id *string, endpoint string, excluded map[stri }) } - return &ret + return ret } // A performer from a scraping operation... diff --git a/pkg/models/model_scraped_item_test.go b/pkg/models/model_scraped_item_test.go index a6e42f2fd80..4093192fab7 100644 --- a/pkg/models/model_scraped_item_test.go +++ b/pkg/models/model_scraped_item_test.go @@ -249,3 +249,123 @@ func Test_scrapedToPerformerInput(t *testing.T) { }) } } + +func TestScrapedStudio_ToPartial(t *testing.T) { + var ( + id = 1000 + idStr = strconv.Itoa(id) + storedID = "storedID" + parentStoredID = 2000 + parentStoredIDStr = strconv.Itoa(parentStoredID) + name = "name" + url = "url" + remoteSiteID = "remoteSiteID" + endpoint = "endpoint" + image = "image" + images = []string{image} + + existingEndpoint = "existingEndpoint" + existingStashID = StashID{"existingStashID", existingEndpoint} + existingStashIDs = []StashID{existingStashID} + ) + + fullStudio := ScrapedStudio{ + StoredID: &storedID, + Name: name, + URL: &url, + Parent: &ScrapedStudio{ + StoredID: &parentStoredIDStr, + }, + Image: &image, + Images: images, + RemoteSiteID: &remoteSiteID, + } + + type args struct { + id string + endpoint string + excluded map[string]bool + existingStashIDs []StashID + } + + stdArgs := args{ + id: idStr, + endpoint: endpoint, + excluded: map[string]bool{}, + existingStashIDs: existingStashIDs, + } + + excludeAll := map[string]bool{ + "name": true, + "url": true, + "parent": true, + } + + tests := []struct { + name string + o ScrapedStudio + args args + want StudioPartial + }{ + { + "full no exclusions", + fullStudio, + stdArgs, + StudioPartial{ + ID: id, + Name: NewOptionalString(name), + URL: NewOptionalString(url), + ParentID: NewOptionalInt(parentStoredID), + StashIDs: &UpdateStashIDs{ + StashIDs: append(existingStashIDs, StashID{ + Endpoint: endpoint, + StashID: remoteSiteID, + }), + Mode: RelationshipUpdateModeSet, + }, + }, + }, + { + "exclude all", + fullStudio, + args{ + id: idStr, + excluded: excludeAll, + }, + StudioPartial{ + ID: id, + }, + }, + { + "overwrite stash id", + fullStudio, + args{ + id: idStr, + excluded: excludeAll, + endpoint: existingEndpoint, + existingStashIDs: existingStashIDs, + }, + StudioPartial{ + ID: id, + StashIDs: &UpdateStashIDs{ + StashIDs: []StashID{{ + Endpoint: existingEndpoint, + StashID: remoteSiteID, + }}, + Mode: RelationshipUpdateModeSet, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + s := tt.o + got := s.ToPartial(tt.args.id, tt.args.endpoint, tt.args.excluded, tt.args.existingStashIDs) + + // unset updatedAt - we don't need to compare it + got.UpdatedAt = OptionalTime{} + + assert.Equal(t, tt.want, got) + }) + } +} From 205b24499bb9136eb263eec67de6c3529a145da3 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Fri, 21 Jun 2024 16:15:59 +1000 Subject: [PATCH 033/103] Fix key for tagger scenes (#5000) --- ui/v2.5/src/components/Tagger/scenes/SceneTagger.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/v2.5/src/components/Tagger/scenes/SceneTagger.tsx b/ui/v2.5/src/components/Tagger/scenes/SceneTagger.tsx index c8b1c43d9ff..ab6bd226e33 100755 --- a/ui/v2.5/src/components/Tagger/scenes/SceneTagger.tsx +++ b/ui/v2.5/src/components/Tagger/scenes/SceneTagger.tsx @@ -264,7 +264,7 @@ export const Tagger: React.FC = ({ scenes, queue }) => {
    {filteredScenes.map((s, i) => ( Date: Mon, 24 Jun 2024 01:03:29 -0500 Subject: [PATCH 034/103] Address resize loop (#5004) --- .../src/components/Shared/GridCard/GridCard.tsx | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/ui/v2.5/src/components/Shared/GridCard/GridCard.tsx b/ui/v2.5/src/components/Shared/GridCard/GridCard.tsx index 911064a9402..1d1a37528d9 100644 --- a/ui/v2.5/src/components/Shared/GridCard/GridCard.tsx +++ b/ui/v2.5/src/components/Shared/GridCard/GridCard.tsx @@ -42,9 +42,9 @@ interface IDimension { height: number; } -export const useContainerDimensions = < - T extends HTMLElement = HTMLDivElement ->(): [MutableRefObject, IDimension] => { +export const useContainerDimensions = ( + sensitivityThreshold = 20 +): [MutableRefObject, IDimension] => { const target = useRef(null); const [dimension, setDimension] = useState({ width: 0, @@ -53,7 +53,14 @@ export const useContainerDimensions = < useResizeObserver(target, (entry) => { const { inlineSize: width, blockSize: height } = entry.contentBoxSize[0]; - setDimension({ width, height }); + let difference = Math.abs(dimension.width - width); + // Only adjust when width changed by a significant margin. This addresses the cornercase that sees + // the dimensions toggle back and forward when the window is adjusted perfectly such that overflow + // is trigger then immediable disabled because of a resize event then continues this loop endlessly. + // the scrollbar size varies between platforms. Windows is apparently around 17 pixels. + if (difference > sensitivityThreshold) { + setDimension({ width, height }); + } }); return [target, dimension]; From b7f938531b5da06926425ec8f4bc7cc374d9f69f Mon Sep 17 00:00:00 2001 From: dogwithakeyboard <128322708+dogwithakeyboard@users.noreply.github.com> Date: Thu, 27 Jun 2024 01:12:39 +0100 Subject: [PATCH 035/103] Check for null disambiguation on validate (#5019) --- pkg/performer/validate.go | 12 ++++++++---- pkg/performer/validate_test.go | 10 +++------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/pkg/performer/validate.go b/pkg/performer/validate.go index 0106490cf62..68f7a8ef535 100644 --- a/pkg/performer/validate.go +++ b/pkg/performer/validate.go @@ -102,11 +102,15 @@ func validateName(ctx context.Context, name string, disambig string, existingID }, } + modifier := models.CriterionModifierIsNull + if disambig != "" { - performerFilter.Disambiguation = &models.StringCriterionInput{ - Value: disambig, - Modifier: models.CriterionModifierEquals, - } + modifier = models.CriterionModifierEquals + } + + performerFilter.Disambiguation = &models.StringCriterionInput{ + Value: disambig, + Modifier: modifier, } if existingID == nil { diff --git a/pkg/performer/validate_test.go b/pkg/performer/validate_test.go index 778459f1751..33f4b1cec58 100644 --- a/pkg/performer/validate_test.go +++ b/pkg/performer/validate_test.go @@ -15,6 +15,9 @@ func nameFilter(n string) *models.PerformerFilterType { Value: n, Modifier: models.CriterionModifierEquals, }, + Disambiguation: &models.StringCriterionInput{ + Modifier: models.CriterionModifierIsNull, + }, } } @@ -41,13 +44,6 @@ func TestValidateName(t *testing.T) { newName = "new name" newDisambig = "new disambiguation" ) - // existing1 := models.Performer{ - // Name: name1, - // } - // existing2 := models.Performer{ - // Name: name2, - // Disambiguation: disambig, - // } pp := 1 findFilter := &models.FindFilterType{ From e116775d60329a13d8eeda84e8de75f40879b57c Mon Sep 17 00:00:00 2001 From: dogwithakeyboard <128322708+dogwithakeyboard@users.noreply.github.com> Date: Thu, 27 Jun 2024 01:12:39 +0100 Subject: [PATCH 036/103] Check for null disambiguation on validate (#5019) --- pkg/performer/validate.go | 12 ++++++++---- pkg/performer/validate_test.go | 10 +++------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/pkg/performer/validate.go b/pkg/performer/validate.go index 0106490cf62..68f7a8ef535 100644 --- a/pkg/performer/validate.go +++ b/pkg/performer/validate.go @@ -102,11 +102,15 @@ func validateName(ctx context.Context, name string, disambig string, existingID }, } + modifier := models.CriterionModifierIsNull + if disambig != "" { - performerFilter.Disambiguation = &models.StringCriterionInput{ - Value: disambig, - Modifier: models.CriterionModifierEquals, - } + modifier = models.CriterionModifierEquals + } + + performerFilter.Disambiguation = &models.StringCriterionInput{ + Value: disambig, + Modifier: modifier, } if existingID == nil { diff --git a/pkg/performer/validate_test.go b/pkg/performer/validate_test.go index 778459f1751..33f4b1cec58 100644 --- a/pkg/performer/validate_test.go +++ b/pkg/performer/validate_test.go @@ -15,6 +15,9 @@ func nameFilter(n string) *models.PerformerFilterType { Value: n, Modifier: models.CriterionModifierEquals, }, + Disambiguation: &models.StringCriterionInput{ + Modifier: models.CriterionModifierIsNull, + }, } } @@ -41,13 +44,6 @@ func TestValidateName(t *testing.T) { newName = "new name" newDisambig = "new disambiguation" ) - // existing1 := models.Performer{ - // Name: name1, - // } - // existing2 := models.Performer{ - // Name: name2, - // Disambiguation: disambig, - // } pp := 1 findFilter := &models.FindFilterType{ From 2a373a25ca36aff1bac7f375fe61cb69aa4cd3c0 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Thu, 27 Jun 2024 09:26:37 +1000 Subject: [PATCH 037/103] Update changelog --- ui/v2.5/src/docs/en/Changelog/v0260.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/ui/v2.5/src/docs/en/Changelog/v0260.md b/ui/v2.5/src/docs/en/Changelog/v0260.md index a3d89d676f2..8ef0c35516b 100644 --- a/ui/v2.5/src/docs/en/Changelog/v0260.md +++ b/ui/v2.5/src/docs/en/Changelog/v0260.md @@ -22,6 +22,11 @@ * Changed umask when creating config file to exclude user write (CVE-2024-32233) ([#4866](https://github.com/stashapp/stash/pull/4866)) ### 🐛 Bug fixes +* **[0.26.2]** Fixed issue where performer could not be created without disambiguation if a performer with the same name and populated disambiguation exists. ([#5019](https://github.com/stashapp/stash/pull/5019)) +* **[0.26.2]** Fix resize loop in grid views. ([#5004](https://github.com/stashapp/stash/pull/5004)) +* **[0.26.2]** Fix query field values duplicating in tagger view when scene list is updated. ([#5000](https://github.com/stashapp/stash/pull/5000)) +* **[0.26.2]** Fix identify clearing parent studio when merging studio field. ([#4993](https://github.com/stashapp/stash/pull/4993)) +* **[0.26.2]** Fix manually selected studio not being applied during scrape. ([#4953](https://github.com/stashapp/stash/pull/4953)) * **[0.26.1]** Fixed identify task defaults not displaying correctly. ([#4931](https://github.com/stashapp/stash/pull/4931)) * **[0.26.1]** Fixed issue where full hardware transcoding did not work where a filter was not required. ([#4934](https://github.com/stashapp/stash/pull/4934)) * **[0.26.1]** Fixed new performer tags not displaying correctly in the performer scrape dialog. ([#4943](https://github.com/stashapp/stash/pull/4943)) From 4244bd0b18a55441192617c59659a7187f967fdd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 1 Jul 2024 11:09:58 +1000 Subject: [PATCH 038/103] Bump golang.org/x/image from 0.16.0 to 0.18.0 (#5021) Bumps [golang.org/x/image](https://github.com/golang/image) from 0.16.0 to 0.18.0. - [Commits](https://github.com/golang/image/compare/v0.16.0...v0.18.0) --- updated-dependencies: - dependency-name: golang.org/x/image dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 9 +++++---- go.sum | 26 +++++++++++++------------- 2 files changed, 18 insertions(+), 17 deletions(-) diff --git a/go.mod b/go.mod index 3056e6a9530..67a6f01838b 100644 --- a/go.mod +++ b/go.mod @@ -51,11 +51,11 @@ require ( github.com/xWTF/chardet v0.0.0-20230208095535-c780f2ac244e github.com/zencoder/go-dash/v3 v3.0.2 golang.org/x/crypto v0.23.0 - golang.org/x/image v0.16.0 + golang.org/x/image v0.18.0 golang.org/x/net v0.25.0 golang.org/x/sys v0.20.0 golang.org/x/term v0.20.0 - golang.org/x/text v0.15.0 + golang.org/x/text v0.16.0 gopkg.in/guregu/null.v4 v4.0.0 gopkg.in/yaml.v2 v2.4.0 ) @@ -110,8 +110,9 @@ require ( github.com/urfave/cli/v2 v2.8.1 // indirect github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect go.uber.org/atomic v1.11.0 // indirect - golang.org/x/mod v0.12.0 // indirect - golang.org/x/tools v0.13.0 // indirect + golang.org/x/mod v0.17.0 // indirect + golang.org/x/sync v0.7.0 // indirect + golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 6fe894ecfa3..b808cbd7421 100644 --- a/go.sum +++ b/go.sum @@ -196,9 +196,9 @@ github.com/envoyproxy/protoc-gen-validate v0.6.2/go.mod h1:2t7qjJNvHPx8IjnBOzl9E github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU= github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= +github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= -github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/fsnotify/fsnotify v1.5.1/go.mod h1:T3375wBYaZdLLcVNkcVbzGHY7f1l/uK5T5Ai1i3InKU= github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY= @@ -300,8 +300,8 @@ github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= -github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= -github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= @@ -729,8 +729,8 @@ golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMk golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= -golang.org/x/image v0.16.0 h1:9kloLAKhUufZhA12l5fwnx2NZW39/we1UhBesW433jw= -golang.org/x/image v0.16.0/go.mod h1:ugSZItdV4nOxyqp56HmXwH0Ry0nBCpjnZdpDaIHdoPs= +golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ= +golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= @@ -758,8 +758,8 @@ golang.org/x/mod v0.5.0/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro= golang.org/x/mod v0.5.1/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro= golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.12.0 h1:rmsUpXtvNzj340zd98LZ4KntptpfRHwpFOHG188oHXc= -golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -840,8 +840,8 @@ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJ 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/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E= -golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 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= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -952,8 +952,8 @@ golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk= -golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= +golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= @@ -1019,8 +1019,8 @@ golang.org/x/tools v0.1.8/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU= golang.org/x/tools v0.1.9/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU= golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.13.0 h1:Iey4qkscZuv0VvIt8E0neZjtPVQFSc870HQ448QgEmQ= -golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= From dc3ce2b414cfabbbeaddc2acaf763752cf0126a1 Mon Sep 17 00:00:00 2001 From: barraged1 <49034318+barraged1@users.noreply.github.com> Date: Sun, 30 Jun 2024 21:18:20 -0400 Subject: [PATCH 039/103] updating scrapedPerformerToCreateInput to pass Disambiguation (#5029) --- ui/v2.5/src/core/performers.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/ui/v2.5/src/core/performers.ts b/ui/v2.5/src/core/performers.ts index 83a62eac359..455ada9f97c 100644 --- a/ui/v2.5/src/core/performers.ts +++ b/ui/v2.5/src/core/performers.ts @@ -92,6 +92,7 @@ export const scrapedPerformerToCreateInput = ( name: toCreate.name ?? "", gender: stringToGender(toCreate.gender), birthdate: toCreate.birthdate, + disambiguation: toCreate.disambiguation, ethnicity: toCreate.ethnicity, country: toCreate.country, eye_color: toCreate.eye_color, From 436ae0a02742e58c9ef99c2eaa56a27eedc0e27f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 1 Jul 2024 11:18:54 +1000 Subject: [PATCH 040/103] Bump ws from 8.16.0 to 8.17.1 in /ui/v2.5 (#4980) Bumps [ws](https://github.com/websockets/ws) from 8.16.0 to 8.17.1. - [Release notes](https://github.com/websockets/ws/releases) - [Commits](https://github.com/websockets/ws/compare/8.16.0...8.17.1) --- updated-dependencies: - dependency-name: ws dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- ui/v2.5/yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ui/v2.5/yarn.lock b/ui/v2.5/yarn.lock index ca0e9cbf7e1..e76d7694c1d 100644 --- a/ui/v2.5/yarn.lock +++ b/ui/v2.5/yarn.lock @@ -8194,9 +8194,9 @@ write-json-file@^4.3.0: write-file-atomic "^3.0.0" ws@^8.12.0, ws@^8.13.0, ws@^8.15.0: - version "8.16.0" - resolved "https://registry.yarnpkg.com/ws/-/ws-8.16.0.tgz#d1cd774f36fbc07165066a60e40323eab6446fd4" - integrity sha512-HS0c//TP7Ina87TfiPUz1rQzMhHrl/SG2guqRcTOIUYD2q8uhUdNHZYJUaQ8aTGPzCh+c6oawMKW35nFl1dxyQ== + version "8.17.1" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.17.1.tgz#9293da530bb548febc95371d90f9c878727d919b" + integrity sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ== xtend@^4.0.0: version "4.0.2" From 4cca3b298d69903d957855ee637174d988c14420 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Mon, 1 Jul 2024 11:19:38 +1000 Subject: [PATCH 041/103] Add Opus as supported audio for mp4 (#5030) --- pkg/ffmpeg/browser.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/ffmpeg/browser.go b/pkg/ffmpeg/browser.go index 5e34a5f140d..d8bcc0b4ff1 100644 --- a/pkg/ffmpeg/browser.go +++ b/pkg/ffmpeg/browser.go @@ -20,7 +20,7 @@ var validForHevc = []Container{Mp4} var validAudioForMkv = []ProbeAudioCodec{Aac, Mp3, Vorbis, Opus} var validAudioForWebm = []ProbeAudioCodec{Vorbis, Opus} -var validAudioForMp4 = []ProbeAudioCodec{Aac, Mp3} +var validAudioForMp4 = []ProbeAudioCodec{Aac, Mp3, Opus} var ( // ErrUnsupportedVideoCodecForBrowser is returned when the video codec is not supported for browser streaming. From 70250c93f16349cec6bef25dd7b8e803dc0e1616 Mon Sep 17 00:00:00 2001 From: DogmaDragon <103123951+DogmaDragon@users.noreply.github.com> Date: Mon, 1 Jul 2024 09:15:21 +0300 Subject: [PATCH 042/103] Update translation instance (#5031) Replace (incomplete) flag names with SVG banner. --- README.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 27830b31bfa..32be39c1eb3 100644 --- a/README.md +++ b/README.md @@ -57,10 +57,11 @@ Stash can pull metadata (performers, tags, descriptions, studios, and more) dire [StashDB](http://stashdb.org) is the canonical instance of our open source metadata API, [stash-box](https://github.com/stashapp/stash-box). # Translation -[![Translate](https://hosted.weblate.org/widget/stashapp/stash/svg-badge.svg)](https://hosted.weblate.org/engage/stashapp/) -🇧🇷 🇨🇳 🇩🇰 🇳🇱 🇬🇧 🇪🇪 🇫🇮 🇫🇷 🇩🇪 🇮🇹 🇯🇵 🇰🇷 🇵🇱 🇷🇺 🇪🇸 🇸🇪 🇹🇼 🇹🇷 +[![Translate](https://translate.codeberg.org/widget/stash/stash/svg-badge.svg)](https://translate.codeberg.org/engage/stash/) -Stash is available in 25 languages (so far!) and it could be in your language too. We use Weblate to coordinate community translations. If you want to help us translate Stash into your language, you can make an account at [Stash's Weblate](https://hosted.weblate.org/projects/stashapp/stash/) to get started contributing new languages or improving existing ones. Thanks! +Stash is available in 32 languages (so far!) and it could be in your language too. We use Weblate to coordinate community translations. If you want to help us translate Stash into your language, you can make an account at [Codeberg's Weblate](https://translate.codeberg.org.org/projects/stash/stash/) to get started contributing new languages or improving existing ones. Thanks! + +[![Translation status](https://translate.codeberg.org/widget/stash/stash/multi-auto.svg)](https://translate.codeberg.org/engage/stash/) # Support (FAQ) From f477b996b5674500303a33321c8a798383d0958c Mon Sep 17 00:00:00 2001 From: DogmaDragon <103123951+DogmaDragon@users.noreply.github.com> Date: Mon, 1 Jul 2024 17:35:46 +0300 Subject: [PATCH 043/103] Update README.md [skip ci] Fix typo --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 32be39c1eb3..8c35c134c86 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,7 @@ Stash can pull metadata (performers, tags, descriptions, studios, and more) dire # Translation [![Translate](https://translate.codeberg.org/widget/stash/stash/svg-badge.svg)](https://translate.codeberg.org/engage/stash/) -Stash is available in 32 languages (so far!) and it could be in your language too. We use Weblate to coordinate community translations. If you want to help us translate Stash into your language, you can make an account at [Codeberg's Weblate](https://translate.codeberg.org.org/projects/stash/stash/) to get started contributing new languages or improving existing ones. Thanks! +Stash is available in 32 languages (so far!) and it could be in your language too. We use Weblate to coordinate community translations. If you want to help us translate Stash into your language, you can make an account at [Codeberg's Weblate](https://translate.codeberg.org/projects/stash/stash/) to get started contributing new languages or improving existing ones. Thanks! [![Translation status](https://translate.codeberg.org/widget/stash/stash/multi-auto.svg)](https://translate.codeberg.org/engage/stash/) From 27396968131d969109c713d29b5db5f8cbf6abec Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Wed, 3 Jul 2024 13:59:40 +1000 Subject: [PATCH 044/103] Add group graphql interfaces (#5017) * Deprecate movie and add group interfaces * UI changes --- gqlgen.yml | 5 + graphql/schema/schema.graphql | 37 +- graphql/schema/types/filters.graphql | 49 ++- graphql/schema/types/group.graphql | 80 +++++ graphql/schema/types/metadata.graphql | 3 +- graphql/schema/types/performer.graphql | 6 +- graphql/schema/types/scene.graphql | 22 +- ...ed-movie.graphql => scraped-group.graphql} | 32 ++ graphql/schema/types/scraper.graphql | 20 +- graphql/schema/types/stats.graphql | 3 +- graphql/schema/types/studio.graphql | 6 +- graphql/schema/types/tag.graphql | 3 +- internal/api/changeset_translator.go | 43 +++ internal/api/resolver.go | 14 +- internal/api/resolver_model_performer.go | 14 +- internal/api/resolver_model_scene.go | 31 ++ internal/api/resolver_model_studio.go | 14 +- internal/api/resolver_model_tag.go | 6 +- internal/api/resolver_mutation_group.go | 335 ++++++++++++++++++ internal/api/resolver_mutation_movie.go | 10 + internal/api/resolver_mutation_scene.go | 40 ++- internal/api/resolver_query_find_group.go | 59 +++ internal/api/resolver_query_scraper.go | 37 ++ internal/manager/task_export.go | 27 +- pkg/models/model_saved_filter.go | 4 +- pkg/models/model_scraped_item.go | 21 ++ pkg/models/scene.go | 12 + pkg/models/tag.go | 2 + pkg/plugin/hook/hooks.go | 6 + pkg/scraper/config.go | 5 +- pkg/scraper/group.go | 2 +- pkg/scraper/json.go | 2 +- pkg/scraper/scene.go | 1 + pkg/scraper/scraper.go | 6 +- pkg/scraper/script.go | 2 +- pkg/scraper/xpath.go | 2 +- pkg/sqlite/gallery_test.go | 2 +- pkg/sqlite/image_test.go | 2 +- pkg/sqlite/performer_test.go | 2 +- pkg/sqlite/saved_filter.go | 19 +- pkg/sqlite/scene.go | 3 +- pkg/sqlite/scene_filter.go | 7 +- pkg/sqlite/setup_test.go | 13 +- pkg/sqlite/tag.go | 2 +- pkg/sqlite/tag_filter.go | 7 +- ...{movie-slim.graphql => group-slim.graphql} | 4 +- .../data/{movie.graphql => group.graphql} | 2 +- ui/v2.5/graphql/data/performer.graphql | 2 +- ui/v2.5/graphql/data/scene-slim.graphql | 4 +- ui/v2.5/graphql/data/scene.graphql | 6 +- ui/v2.5/graphql/data/scrapers.graphql | 18 +- ui/v2.5/graphql/data/studio.graphql | 4 +- ui/v2.5/graphql/data/tag.graphql | 4 +- ui/v2.5/graphql/mutations/group.graphql | 25 ++ ui/v2.5/graphql/mutations/movie.graphql | 25 -- ui/v2.5/graphql/queries/misc.graphql | 2 +- ui/v2.5/graphql/queries/movie.graphql | 24 +- .../graphql/queries/scrapers/scrapers.graphql | 12 +- ui/v2.5/src/components/FrontPage/Control.tsx | 1 + .../components/FrontPage/FrontPageConfig.tsx | 1 + .../src/components/List/EditFilterDialog.tsx | 1 + .../components/Movies/EditMoviesDialog.tsx | 42 +-- ui/v2.5/src/components/Movies/MovieCard.tsx | 2 +- .../src/components/Movies/MovieCardGrid.tsx | 2 +- .../components/Movies/MovieDetails/Movie.tsx | 36 +- .../Movies/MovieDetails/MovieCreate.tsx | 16 +- .../Movies/MovieDetails/MovieDetailsPanel.tsx | 4 +- .../Movies/MovieDetails/MovieEditPanel.tsx | 32 +- .../Movies/MovieDetails/MovieScenesPanel.tsx | 36 +- .../Movies/MovieDetails/MovieScrapeDialog.tsx | 8 +- ui/v2.5/src/components/Movies/MovieList.tsx | 48 +-- .../Movies/MovieRecommendationRow.tsx | 10 +- ui/v2.5/src/components/Movies/MovieSelect.tsx | 56 +-- .../components/Performers/PerformerCard.tsx | 10 +- .../Performers/PerformerDetails/Performer.tsx | 4 +- .../SceneDuplicateChecker.tsx | 20 +- .../components/Scenes/EditScenesDialog.tsx | 4 +- ui/v2.5/src/components/Scenes/SceneCard.tsx | 20 +- .../components/Scenes/SceneDetails/Scene.tsx | 4 +- .../Scenes/SceneDetails/SceneEditPanel.tsx | 54 +-- .../Scenes/SceneDetails/SceneMoviePanel.tsx | 6 +- .../Scenes/SceneDetails/SceneMovieTable.tsx | 14 +- .../Scenes/SceneDetails/SceneScrapeDialog.tsx | 12 +- .../src/components/Scenes/SceneListTable.tsx | 8 +- .../components/Scenes/SceneMergeDialog.tsx | 28 +- .../Settings/SettingsScrapingPanel.tsx | 8 +- .../Shared/ScrapeDialog/ScrapedObjectsRow.tsx | 8 +- .../Shared/ScrapeDialog/createObjects.ts | 14 +- ui/v2.5/src/components/Stats.tsx | 2 +- ui/v2.5/src/components/Studios/StudioCard.tsx | 6 +- .../Studios/StudioDetails/Studio.tsx | 2 +- .../StudioDetails/StudioPerformersPanel.tsx | 2 +- ui/v2.5/src/components/Tags/TagCard.tsx | 4 +- .../src/components/Tags/TagDetails/Tag.tsx | 2 +- ui/v2.5/src/core/StashService.ts | 146 ++++---- ui/v2.5/src/core/config.ts | 6 +- ui/v2.5/src/core/createClient.ts | 6 +- ui/v2.5/src/core/movies.ts | 6 +- .../models/list-filter/criteria/is-missing.ts | 2 +- .../src/models/list-filter/criteria/movies.ts | 10 +- ui/v2.5/src/models/list-filter/factory.ts | 5 +- ui/v2.5/src/models/list-filter/movies.ts | 6 +- ui/v2.5/src/models/list-filter/scenes.ts | 4 +- ui/v2.5/src/models/list-filter/tags.ts | 2 +- ui/v2.5/src/models/list-filter/types.ts | 4 +- ui/v2.5/src/pluginApi.d.ts | 110 +++--- ui/v2.5/src/utils/bulkUpdate.ts | 6 +- ui/v2.5/src/utils/navigation.ts | 14 +- 108 files changed, 1437 insertions(+), 567 deletions(-) create mode 100644 graphql/schema/types/group.graphql rename graphql/schema/types/{scraped-movie.graphql => scraped-group.graphql} (53%) create mode 100644 internal/api/resolver_mutation_group.go create mode 100644 internal/api/resolver_query_find_group.go rename ui/v2.5/graphql/data/{movie-slim.graphql => group-slim.graphql} (63%) rename ui/v2.5/graphql/data/{movie.graphql => group.graphql} (88%) create mode 100644 ui/v2.5/graphql/mutations/group.graphql delete mode 100644 ui/v2.5/graphql/mutations/movie.graphql diff --git a/gqlgen.yml b/gqlgen.yml index c6a434e25b7..36febdaae01 100644 --- a/gqlgen.yml +++ b/gqlgen.yml @@ -51,6 +51,11 @@ models: fieldName: DurationFinite frame_rate: fieldName: FrameRateFinite + # group is movie under the hood + Group: + model: github.com/stashapp/stash/pkg/models.Movie + GroupFilterType: + model: github.com/stashapp/stash/pkg/models.MovieFilterType # autobind on config causes generation issues BlobsStorageType: model: github.com/stashapp/stash/internal/manager/config.BlobsStorageType diff --git a/graphql/schema/schema.graphql b/graphql/schema/schema.graphql index a1f163ecca0..da02af57528 100644 --- a/graphql/schema/schema.graphql +++ b/graphql/schema/schema.graphql @@ -77,13 +77,22 @@ type Query { ): FindStudiosResultType! "Find a movie by ID" - findMovie(id: ID!): Movie + findMovie(id: ID!): Movie @deprecated(reason: "Use findGroup instead") "A function which queries Movie objects" findMovies( movie_filter: MovieFilterType filter: FindFilterType ids: [ID!] - ): FindMoviesResultType! + ): FindMoviesResultType! @deprecated(reason: "Use findGroups instead") + + "Find a group by ID" + findGroup(id: ID!): Group + "A function which queries Group objects" + findGroups( + group_filter: GroupFilterType + filter: FindFilterType + ids: [ID!] + ): FindGroupsResultType! findGallery(id: ID!): Gallery findGalleries( @@ -156,7 +165,13 @@ type Query { scrapeSingleMovie( source: ScraperSourceInput! input: ScrapeSingleMovieInput! - ): [ScrapedMovie!]! + ): [ScrapedMovie!]! @deprecated(reason: "Use scrapeSingleGroup instead") + + "Scrape for a single group" + scrapeSingleGroup( + source: ScraperSourceInput! + input: ScrapeSingleGroupInput! + ): [ScrapedGroup!]! "Scrapes content based on a URL" scrapeURL(url: String!, ty: ScrapeContentType!): ScrapedContent @@ -169,6 +184,9 @@ type Query { scrapeGalleryURL(url: String!): ScrapedGallery "Scrapes a complete movie record based on a URL" scrapeMovieURL(url: String!): ScrapedMovie + @deprecated(reason: "Use scrapeGroupURL instead") + "Scrapes a complete group record based on a URL" + scrapeGroupURL(url: String!): ScrapedGroup # Plugins "List loaded plugins" @@ -214,7 +232,7 @@ type Query { allPerformers: [Performer!]! allTags: [Tag!]! @deprecated(reason: "Use findTags instead") allStudios: [Studio!]! @deprecated(reason: "Use findStudios instead") - allMovies: [Movie!]! @deprecated(reason: "Use findMovies instead") + allMovies: [Movie!]! @deprecated(reason: "Use findGroups instead") # Get everything with minimal metadata @@ -316,10 +334,21 @@ type Mutation { studiosDestroy(ids: [ID!]!): Boolean! movieCreate(input: MovieCreateInput!): Movie + @deprecated(reason: "Use groupCreate instead") movieUpdate(input: MovieUpdateInput!): Movie + @deprecated(reason: "Use groupUpdate instead") movieDestroy(input: MovieDestroyInput!): Boolean! + @deprecated(reason: "Use groupDestroy instead") moviesDestroy(ids: [ID!]!): Boolean! + @deprecated(reason: "Use groupsDestroy instead") bulkMovieUpdate(input: BulkMovieUpdateInput!): [Movie!] + @deprecated(reason: "Use bulkGroupUpdate instead") + + groupCreate(input: GroupCreateInput!): Group + groupUpdate(input: GroupUpdateInput!): Group + groupDestroy(input: GroupDestroyInput!): Boolean! + groupsDestroy(ids: [ID!]!): Boolean! + bulkGroupUpdate(input: BulkGroupUpdateInput!): [Group!] tagCreate(input: TagCreateInput!): Tag tagUpdate(input: TagUpdateInput!): Tag diff --git a/graphql/schema/types/filters.graphql b/graphql/schema/types/filters.graphql index 98b790d4fe3..d1b16976905 100644 --- a/graphql/schema/types/filters.graphql +++ b/graphql/schema/types/filters.graphql @@ -257,7 +257,9 @@ input SceneFilterType { "Filter to only include scenes with this studio" studios: HierarchicalMultiCriterionInput "Filter to only include scenes with this movie" - movies: MultiCriterionInput + movies: MultiCriterionInput @deprecated(reason: "use groups instead") + "Filter to only include scenes with this group" + groups: MultiCriterionInput "Filter to only include scenes with this gallery" galleries: MultiCriterionInput "Filter to only include scenes with these tags" @@ -309,6 +311,9 @@ input SceneFilterType { tags_filter: TagFilterType "Filter by related movies that meet this criteria" movies_filter: MovieFilterType + @deprecated(reason: "use groups_filter instead") + "Filter by related groups that meet this criteria" + groups_filter: GroupFilterType "Filter by related markers that meet this criteria" markers_filter: SceneMarkerFilterType } @@ -351,6 +356,44 @@ input MovieFilterType { studios_filter: StudioFilterType } +input GroupFilterType { + AND: GroupFilterType + OR: GroupFilterType + NOT: GroupFilterType + + name: StringCriterionInput + director: StringCriterionInput + synopsis: StringCriterionInput + + "Filter by duration (in seconds)" + duration: IntCriterionInput + # rating expressed as 1-100 + rating100: IntCriterionInput + "Filter to only include groups with this studio" + studios: HierarchicalMultiCriterionInput + "Filter to only include groups missing this property" + is_missing: String + "Filter by url" + url: StringCriterionInput + "Filter to only include groups where performer appears in a scene" + performers: MultiCriterionInput + "Filter to only include groups with these tags" + tags: HierarchicalMultiCriterionInput + "Filter by tag count" + tag_count: IntCriterionInput + "Filter by date" + date: DateCriterionInput + "Filter by creation time" + created_at: TimestampCriterionInput + "Filter by last update time" + updated_at: TimestampCriterionInput + + "Filter by related scenes that meet this criteria" + scenes_filter: SceneFilterType + "Filter by related studios that meet this criteria" + studios_filter: StudioFilterType +} + input StudioFilterType { AND: StudioFilterType OR: StudioFilterType @@ -508,6 +551,9 @@ input TagFilterType { "Filter by number of movies with this tag" movie_count: IntCriterionInput + "Filter by number of group with this tag" + group_count: IntCriterionInput + "Filter by number of markers with this tag" marker_count: IntCriterionInput @@ -702,6 +748,7 @@ enum FilterMode { GALLERIES SCENE_MARKERS MOVIES + GROUPS TAGS IMAGES } diff --git a/graphql/schema/types/group.graphql b/graphql/schema/types/group.graphql new file mode 100644 index 00000000000..15bb3556ca6 --- /dev/null +++ b/graphql/schema/types/group.graphql @@ -0,0 +1,80 @@ +type Group { + id: ID! + name: String! + aliases: String + "Duration in seconds" + duration: Int + date: String + # rating expressed as 1-100 + rating100: Int + studio: Studio + director: String + synopsis: String + urls: [String!]! + tags: [Tag!]! + created_at: Time! + updated_at: Time! + + front_image_path: String # Resolver + back_image_path: String # Resolver + scene_count: Int! # Resolver + scenes: [Scene!]! +} + +input GroupCreateInput { + name: String! + aliases: String + "Duration in seconds" + duration: Int + date: String + # rating expressed as 1-100 + rating100: Int + studio_id: ID + director: String + synopsis: String + urls: [String!] + tag_ids: [ID!] + "This should be a URL or a base64 encoded data URL" + front_image: String + "This should be a URL or a base64 encoded data URL" + back_image: String +} + +input GroupUpdateInput { + id: ID! + name: String + aliases: String + duration: Int + date: String + # rating expressed as 1-100 + rating100: Int + studio_id: ID + director: String + synopsis: String + urls: [String!] + tag_ids: [ID!] + "This should be a URL or a base64 encoded data URL" + front_image: String + "This should be a URL or a base64 encoded data URL" + back_image: String +} + +input BulkGroupUpdateInput { + clientMutationId: String + ids: [ID!] + # rating expressed as 1-100 + rating100: Int + studio_id: ID + director: String + urls: BulkUpdateStrings + tag_ids: BulkUpdateIds +} + +input GroupDestroyInput { + id: ID! +} + +type FindGroupsResultType { + count: Int! + groups: [Group!]! +} diff --git a/graphql/schema/types/metadata.graphql b/graphql/schema/types/metadata.graphql index 3221b0cc68d..38c910d369c 100644 --- a/graphql/schema/types/metadata.graphql +++ b/graphql/schema/types/metadata.graphql @@ -284,7 +284,8 @@ input ExportObjectsInput { studios: ExportObjectTypeInput performers: ExportObjectTypeInput tags: ExportObjectTypeInput - movies: ExportObjectTypeInput + groups: ExportObjectTypeInput + movies: ExportObjectTypeInput @deprecated(reason: "Use groups instead") galleries: ExportObjectTypeInput includeDependencies: Boolean } diff --git a/graphql/schema/types/performer.graphql b/graphql/schema/types/performer.graphql index d6d6b2696be..8ac6c6579ad 100644 --- a/graphql/schema/types/performer.graphql +++ b/graphql/schema/types/performer.graphql @@ -42,7 +42,8 @@ type Performer { scene_count: Int! # Resolver image_count: Int! # Resolver gallery_count: Int! # Resolver - movie_count: Int! # Resolver + group_count: Int! # Resolver + movie_count: Int! @deprecated(reason: "use group_count instead") # Resolver performer_count: Int! # Resolver o_counter: Int # Resolver scenes: [Scene!]! @@ -55,7 +56,8 @@ type Performer { weight: Int created_at: Time! updated_at: Time! - movies: [Movie!]! + groups: [Group!]! @deprecated(reason: "use groups instead") + movies: [Movie!]! @deprecated(reason: "use groups instead") } input PerformerCreateInput { diff --git a/graphql/schema/types/scene.graphql b/graphql/schema/types/scene.graphql index a5bb9f9057d..eca01d15ed8 100644 --- a/graphql/schema/types/scene.graphql +++ b/graphql/schema/types/scene.graphql @@ -26,6 +26,11 @@ type SceneMovie { scene_index: Int } +type SceneGroup { + group: Group! + scene_index: Int +} + type VideoCaption { language_code: String! caption_type: String! @@ -68,7 +73,8 @@ type Scene { scene_markers: [SceneMarker!]! galleries: [Gallery!]! studio: Studio - movies: [SceneMovie!]! + groups: [SceneGroup!]! + movies: [SceneMovie!]! @deprecated(reason: "Use groups") tags: [Tag!]! performers: [Performer!]! stash_ids: [StashID!]! @@ -82,6 +88,11 @@ input SceneMovieInput { scene_index: Int } +input SceneGroupInput { + group_id: ID! + scene_index: Int +} + input SceneCreateInput { title: String code: String @@ -96,7 +107,8 @@ input SceneCreateInput { studio_id: ID gallery_ids: [ID!] performer_ids: [ID!] - movies: [SceneMovieInput!] + groups: [SceneGroupInput!] + movies: [SceneMovieInput!] @deprecated(reason: "Use groups") tag_ids: [ID!] "This should be a URL or a base64 encoded data URL" cover_image: String @@ -128,7 +140,8 @@ input SceneUpdateInput { studio_id: ID gallery_ids: [ID!] performer_ids: [ID!] - movies: [SceneMovieInput!] + groups: [SceneGroupInput!] + movies: [SceneMovieInput!] @deprecated(reason: "Use groups") tag_ids: [ID!] "This should be a URL or a base64 encoded data URL" cover_image: String @@ -175,7 +188,8 @@ input BulkSceneUpdateInput { gallery_ids: BulkUpdateIds performer_ids: BulkUpdateIds tag_ids: BulkUpdateIds - movie_ids: BulkUpdateIds + group_ids: BulkUpdateIds + movie_ids: BulkUpdateIds @deprecated(reason: "Use group_ids") } input SceneDestroyInput { diff --git a/graphql/schema/types/scraped-movie.graphql b/graphql/schema/types/scraped-group.graphql similarity index 53% rename from graphql/schema/types/scraped-movie.graphql rename to graphql/schema/types/scraped-group.graphql index 5b07a222c93..e490f32bbb4 100644 --- a/graphql/schema/types/scraped-movie.graphql +++ b/graphql/schema/types/scraped-group.graphql @@ -31,3 +31,35 @@ input ScrapedMovieInput { synopsis: String # not including tags for the input } + +"A group from a scraping operation..." +type ScrapedGroup { + stored_id: ID + name: String + aliases: String + duration: String + date: String + rating: String + director: String + urls: [String!] + synopsis: String + studio: ScrapedStudio + tags: [ScrapedTag!] + + "This should be a base64 encoded data URL" + front_image: String + "This should be a base64 encoded data URL" + back_image: String +} + +input ScrapedGroupInput { + name: String + aliases: String + duration: String + date: String + rating: String + director: String + urls: [String!] + synopsis: String + # not including tags for the input +} diff --git a/graphql/schema/types/scraper.graphql b/graphql/schema/types/scraper.graphql index ccc888dc395..d49df1b2ba4 100644 --- a/graphql/schema/types/scraper.graphql +++ b/graphql/schema/types/scraper.graphql @@ -11,6 +11,7 @@ enum ScrapeType { enum ScrapeContentType { GALLERY MOVIE + GROUP PERFORMER SCENE } @@ -22,6 +23,7 @@ union ScrapedContent = | ScrapedScene | ScrapedGallery | ScrapedMovie + | ScrapedGroup | ScrapedPerformer type ScraperSpec { @@ -40,7 +42,9 @@ type Scraper { "Details for gallery scraper" gallery: ScraperSpec "Details for movie scraper" - movie: ScraperSpec + movie: ScraperSpec @deprecated(reason: "use group") + "Details for group scraper" + group: ScraperSpec } type ScrapedStudio { @@ -76,7 +80,8 @@ type ScrapedScene { studio: ScrapedStudio tags: [ScrapedTag!] performers: [ScrapedPerformer!] - movies: [ScrapedMovie!] + movies: [ScrapedMovie!] @deprecated(reason: "use groups") + groups: [ScrapedGroup!] remote_site_id: String duration: Int @@ -190,10 +195,19 @@ input ScrapeSingleMovieInput { query: String "Instructs to query by movie id" movie_id: ID - "Instructs to query by gallery fragment" + "Instructs to query by movie fragment" movie_input: ScrapedMovieInput } +input ScrapeSingleGroupInput { + "Instructs to query by string" + query: String + "Instructs to query by group id" + group_id: ID + "Instructs to query by group fragment" + group_input: ScrapedGroupInput +} + input StashBoxSceneQueryInput { "Index of the configured stash-box instance to use" stash_box_index: Int @deprecated(reason: "use stash_box_endpoint") diff --git a/graphql/schema/types/stats.graphql b/graphql/schema/types/stats.graphql index 3675c2a6bb2..6d78c919bfe 100644 --- a/graphql/schema/types/stats.graphql +++ b/graphql/schema/types/stats.graphql @@ -7,7 +7,8 @@ type StatsResultType { gallery_count: Int! performer_count: Int! studio_count: Int! - movie_count: Int! + group_count: Int! + movie_count: Int! @deprecated(reason: "use group_count instead") tag_count: Int! total_o_count: Int! total_play_duration: Float! diff --git a/graphql/schema/types/studio.graphql b/graphql/schema/types/studio.graphql index f90183ed09b..7823bf0c4ae 100644 --- a/graphql/schema/types/studio.graphql +++ b/graphql/schema/types/studio.graphql @@ -13,7 +13,8 @@ type Studio { image_count(depth: Int): Int! # Resolver gallery_count(depth: Int): Int! # Resolver performer_count(depth: Int): Int! # Resolver - movie_count(depth: Int): Int! # Resolver + group_count(depth: Int): Int! # Resolver + movie_count(depth: Int): Int! @deprecated(reason: "use group_count instead") # Resolver stash_ids: [StashID!]! # rating expressed as 1-100 rating100: Int @@ -21,7 +22,8 @@ type Studio { details: String created_at: Time! updated_at: Time! - movies: [Movie!]! + groups: [Group!]! + movies: [Movie!]! @deprecated(reason: "use groups instead") } input StudioCreateInput { diff --git a/graphql/schema/types/tag.graphql b/graphql/schema/types/tag.graphql index 6263b64a8d8..3c62c899cc3 100644 --- a/graphql/schema/types/tag.graphql +++ b/graphql/schema/types/tag.graphql @@ -14,7 +14,8 @@ type Tag { gallery_count(depth: Int): Int! # Resolver performer_count(depth: Int): Int! # Resolver studio_count(depth: Int): Int! # Resolver - movie_count(depth: Int): Int! # Resolver + group_count(depth: Int): Int! # Resolver + movie_count(depth: Int): Int! @deprecated(reason: "use group_count instead") # Resolver parents: [Tag!]! children: [Tag!]! diff --git a/internal/api/changeset_translator.go b/internal/api/changeset_translator.go index d148d47dacc..b5bd5835a58 100644 --- a/internal/api/changeset_translator.go +++ b/internal/api/changeset_translator.go @@ -355,6 +355,33 @@ func (t changesetTranslator) relatedMovies(value []models.SceneMovieInput) (mode return models.NewRelatedMovies(moviesScenes), nil } +func moviesScenesFromGroupInput(input []models.SceneGroupInput) ([]models.MoviesScenes, error) { + ret := make([]models.MoviesScenes, len(input)) + + for i, v := range input { + mID, err := strconv.Atoi(v.GroupID) + if err != nil { + return nil, fmt.Errorf("invalid group ID: %s", v.GroupID) + } + + ret[i] = models.MoviesScenes{ + MovieID: mID, + SceneIndex: v.SceneIndex, + } + } + + return ret, nil +} + +func (t changesetTranslator) relatedMoviesFromGroups(value []models.SceneGroupInput) (models.RelatedMovies, error) { + moviesScenes, err := moviesScenesFromGroupInput(value) + if err != nil { + return models.RelatedMovies{}, err + } + + return models.NewRelatedMovies(moviesScenes), nil +} + func (t changesetTranslator) updateMovieIDs(value []models.SceneMovieInput, field string) (*models.UpdateMovieIDs, error) { if !t.hasField(field) { return nil, nil @@ -371,6 +398,22 @@ func (t changesetTranslator) updateMovieIDs(value []models.SceneMovieInput, fiel }, nil } +func (t changesetTranslator) updateMovieIDsFromGroups(value []models.SceneGroupInput, field string) (*models.UpdateMovieIDs, error) { + if !t.hasField(field) { + return nil, nil + } + + moviesScenes, err := moviesScenesFromGroupInput(value) + if err != nil { + return nil, err + } + + return &models.UpdateMovieIDs{ + Movies: moviesScenes, + Mode: models.RelationshipUpdateModeSet, + }, nil +} + func (t changesetTranslator) updateMovieIDsBulk(value *BulkUpdateIds, field string) (*models.UpdateMovieIDs, error) { if !t.hasField(field) || value == nil { return nil, nil diff --git a/internal/api/resolver.go b/internal/api/resolver.go index 50adea9adc0..78ec0fc580d 100644 --- a/internal/api/resolver.go +++ b/internal/api/resolver.go @@ -72,9 +72,14 @@ func (r *Resolver) SceneMarker() SceneMarkerResolver { func (r *Resolver) Studio() StudioResolver { return &studioResolver{r} } + +func (r *Resolver) Group() GroupResolver { + return &groupResolver{&movieResolver{r}} +} func (r *Resolver) Movie() MovieResolver { return &movieResolver{r} } + func (r *Resolver) Subscription() SubscriptionResolver { return &subscriptionResolver{r} } @@ -111,7 +116,11 @@ type sceneResolver struct{ *Resolver } type sceneMarkerResolver struct{ *Resolver } type imageResolver struct{ *Resolver } type studioResolver struct{ *Resolver } + +// group is movie under the hood type movieResolver struct{ *Resolver } +type groupResolver struct{ *movieResolver } + type tagResolver struct{ *Resolver } type galleryFileResolver struct{ *Resolver } type videoFileResolver struct{ *Resolver } @@ -218,7 +227,7 @@ func (r *queryResolver) Stats(ctx context.Context) (*StatsResultType, error) { return err } - moviesCount, err := movieQB.Count(ctx) + groupsCount, err := movieQB.Count(ctx) if err != nil { return err } @@ -262,7 +271,8 @@ func (r *queryResolver) Stats(ctx context.Context) (*StatsResultType, error) { GalleryCount: galleryCount, PerformerCount: performersCount, StudioCount: studiosCount, - MovieCount: moviesCount, + GroupCount: groupsCount, + MovieCount: groupsCount, TagCount: tagsCount, TotalOCount: totalOCount, TotalPlayDuration: totalPlayDuration, diff --git a/internal/api/resolver_model_performer.go b/internal/api/resolver_model_performer.go index 58fac77ff24..224e733bdac 100644 --- a/internal/api/resolver_model_performer.go +++ b/internal/api/resolver_model_performer.go @@ -179,7 +179,7 @@ func (r *performerResolver) GalleryCount(ctx context.Context, obj *models.Perfor return ret, nil } -func (r *performerResolver) MovieCount(ctx context.Context, obj *models.Performer) (ret int, err error) { +func (r *performerResolver) GroupCount(ctx context.Context, obj *models.Performer) (ret int, err error) { if err := r.withReadTxn(ctx, func(ctx context.Context) error { ret, err = r.repository.Movie.CountByPerformerID(ctx, obj.ID) return err @@ -190,6 +190,11 @@ func (r *performerResolver) MovieCount(ctx context.Context, obj *models.Performe return ret, nil } +// deprecated +func (r *performerResolver) MovieCount(ctx context.Context, obj *models.Performer) (ret int, err error) { + return r.GroupCount(ctx, obj) +} + func (r *performerResolver) PerformerCount(ctx context.Context, obj *models.Performer) (ret int, err error) { if err := r.withReadTxn(ctx, func(ctx context.Context) error { ret, err = performer.CountByAppearsWith(ctx, r.repository.Performer, obj.ID) @@ -252,7 +257,7 @@ func (r *performerResolver) DeathDate(ctx context.Context, obj *models.Performer return nil, nil } -func (r *performerResolver) Movies(ctx context.Context, obj *models.Performer) (ret []*models.Movie, err error) { +func (r *performerResolver) Groups(ctx context.Context, obj *models.Performer) (ret []*models.Movie, err error) { if err := r.withReadTxn(ctx, func(ctx context.Context) error { ret, err = r.repository.Movie.FindByPerformerID(ctx, obj.ID) return err @@ -262,3 +267,8 @@ func (r *performerResolver) Movies(ctx context.Context, obj *models.Performer) ( return ret, nil } + +// deprecated +func (r *performerResolver) Movies(ctx context.Context, obj *models.Performer) (ret []*models.Movie, err error) { + return r.Groups(ctx, obj) +} diff --git a/internal/api/resolver_model_scene.go b/internal/api/resolver_model_scene.go index 2376ca22727..987c6e7b80e 100644 --- a/internal/api/resolver_model_scene.go +++ b/internal/api/resolver_model_scene.go @@ -214,6 +214,37 @@ func (r *sceneResolver) Movies(ctx context.Context, obj *models.Scene) (ret []*S return ret, nil } +func (r *sceneResolver) Groups(ctx context.Context, obj *models.Scene) (ret []*SceneGroup, err error) { + if !obj.Movies.Loaded() { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { + qb := r.repository.Scene + + return obj.LoadMovies(ctx, qb) + }); err != nil { + return nil, err + } + } + + loader := loaders.From(ctx).MovieByID + + for _, sm := range obj.Movies.List() { + movie, err := loader.Load(sm.MovieID) + if err != nil { + return nil, err + } + + sceneIdx := sm.SceneIndex + sceneGroup := &SceneGroup{ + Group: movie, + SceneIndex: sceneIdx, + } + + ret = append(ret, sceneGroup) + } + + return ret, nil +} + func (r *sceneResolver) Tags(ctx context.Context, obj *models.Scene) (ret []*models.Tag, err error) { if !obj.TagIDs.Loaded() { if err := r.withReadTxn(ctx, func(ctx context.Context) error { diff --git a/internal/api/resolver_model_studio.go b/internal/api/resolver_model_studio.go index 011ab343e0f..1f8142e9997 100644 --- a/internal/api/resolver_model_studio.go +++ b/internal/api/resolver_model_studio.go @@ -98,7 +98,7 @@ func (r *studioResolver) PerformerCount(ctx context.Context, obj *models.Studio, return ret, nil } -func (r *studioResolver) MovieCount(ctx context.Context, obj *models.Studio, depth *int) (ret int, err error) { +func (r *studioResolver) GroupCount(ctx context.Context, obj *models.Studio, depth *int) (ret int, err error) { if err := r.withReadTxn(ctx, func(ctx context.Context) error { ret, err = movie.CountByStudioID(ctx, r.repository.Movie, obj.ID, depth) return err @@ -109,6 +109,11 @@ func (r *studioResolver) MovieCount(ctx context.Context, obj *models.Studio, dep return ret, nil } +// deprecated +func (r *studioResolver) MovieCount(ctx context.Context, obj *models.Studio, depth *int) (ret int, err error) { + return r.GroupCount(ctx, obj, depth) +} + func (r *studioResolver) ParentStudio(ctx context.Context, obj *models.Studio) (ret *models.Studio, err error) { if obj.ParentID == nil { return nil, nil @@ -144,7 +149,7 @@ func (r *studioResolver) Rating100(ctx context.Context, obj *models.Studio) (*in return obj.Rating, nil } -func (r *studioResolver) Movies(ctx context.Context, obj *models.Studio) (ret []*models.Movie, err error) { +func (r *studioResolver) Groups(ctx context.Context, obj *models.Studio) (ret []*models.Movie, err error) { if err := r.withReadTxn(ctx, func(ctx context.Context) error { ret, err = r.repository.Movie.FindByStudioID(ctx, obj.ID) return err @@ -154,3 +159,8 @@ func (r *studioResolver) Movies(ctx context.Context, obj *models.Studio) (ret [] return ret, nil } + +// deprecated +func (r *studioResolver) Movies(ctx context.Context, obj *models.Studio) (ret []*models.Movie, err error) { + return r.Groups(ctx, obj) +} diff --git a/internal/api/resolver_model_tag.go b/internal/api/resolver_model_tag.go index a9930fb23db..3cf0bd1d9ab 100644 --- a/internal/api/resolver_model_tag.go +++ b/internal/api/resolver_model_tag.go @@ -120,7 +120,7 @@ func (r *tagResolver) StudioCount(ctx context.Context, obj *models.Tag, depth *i return ret, nil } -func (r *tagResolver) MovieCount(ctx context.Context, obj *models.Tag, depth *int) (ret int, err error) { +func (r *tagResolver) GroupCount(ctx context.Context, obj *models.Tag, depth *int) (ret int, err error) { if err := r.withReadTxn(ctx, func(ctx context.Context) error { ret, err = movie.CountByTagID(ctx, r.repository.Movie, obj.ID, depth) return err @@ -131,6 +131,10 @@ func (r *tagResolver) MovieCount(ctx context.Context, obj *models.Tag, depth *in return ret, nil } +func (r *tagResolver) MovieCount(ctx context.Context, obj *models.Tag, depth *int) (ret int, err error) { + return r.GroupCount(ctx, obj, depth) +} + func (r *tagResolver) ImagePath(ctx context.Context, obj *models.Tag) (*string, error) { var hasImage bool if err := r.withReadTxn(ctx, func(ctx context.Context) error { diff --git a/internal/api/resolver_mutation_group.go b/internal/api/resolver_mutation_group.go new file mode 100644 index 00000000000..1645d80b37c --- /dev/null +++ b/internal/api/resolver_mutation_group.go @@ -0,0 +1,335 @@ +package api + +import ( + "context" + "fmt" + "strconv" + + "github.com/stashapp/stash/internal/static" + "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/plugin/hook" + "github.com/stashapp/stash/pkg/sliceutil/stringslice" + "github.com/stashapp/stash/pkg/utils" +) + +func movieFromGroupCreateInput(ctx context.Context, input GroupCreateInput) (*models.Movie, error) { + translator := changesetTranslator{ + inputMap: getUpdateInputMap(ctx), + } + + // Populate a new movie from the input + newMovie := models.NewMovie() + + newMovie.Name = input.Name + newMovie.Aliases = translator.string(input.Aliases) + newMovie.Duration = input.Duration + newMovie.Rating = input.Rating100 + newMovie.Director = translator.string(input.Director) + newMovie.Synopsis = translator.string(input.Synopsis) + + var err error + + newMovie.Date, err = translator.datePtr(input.Date) + if err != nil { + return nil, fmt.Errorf("converting date: %w", err) + } + newMovie.StudioID, err = translator.intPtrFromString(input.StudioID) + if err != nil { + return nil, fmt.Errorf("converting studio id: %w", err) + } + + newMovie.TagIDs, err = translator.relatedIds(input.TagIds) + if err != nil { + return nil, fmt.Errorf("converting tag ids: %w", err) + } + + if input.Urls != nil { + newMovie.URLs = models.NewRelatedStrings(input.Urls) + } + + return &newMovie, nil +} + +func (r *mutationResolver) GroupCreate(ctx context.Context, input GroupCreateInput) (*models.Movie, error) { + newMovie, err := movieFromGroupCreateInput(ctx, input) + if err != nil { + return nil, err + } + + // Process the base 64 encoded image string + var frontimageData []byte + if input.FrontImage != nil { + frontimageData, err = utils.ProcessImageInput(ctx, *input.FrontImage) + if err != nil { + return nil, fmt.Errorf("processing front image: %w", err) + } + } + + // Process the base 64 encoded image string + var backimageData []byte + if input.BackImage != nil { + backimageData, err = utils.ProcessImageInput(ctx, *input.BackImage) + if err != nil { + return nil, fmt.Errorf("processing back image: %w", err) + } + } + + // HACK: if back image is being set, set the front image to the default. + // This is because we can't have a null front image with a non-null back image. + if len(frontimageData) == 0 && len(backimageData) != 0 { + frontimageData = static.ReadAll(static.DefaultMovieImage) + } + + // Start the transaction and save the movie + if err := r.withTxn(ctx, func(ctx context.Context) error { + qb := r.repository.Movie + + err = qb.Create(ctx, newMovie) + if err != nil { + return err + } + + // update image table + if len(frontimageData) > 0 { + if err := qb.UpdateFrontImage(ctx, newMovie.ID, frontimageData); err != nil { + return err + } + } + + if len(backimageData) > 0 { + if err := qb.UpdateBackImage(ctx, newMovie.ID, backimageData); err != nil { + return err + } + } + + return nil + }); err != nil { + return nil, err + } + + // for backwards compatibility - run both movie and group hooks + r.hookExecutor.ExecutePostHooks(ctx, newMovie.ID, hook.GroupCreatePost, input, nil) + r.hookExecutor.ExecutePostHooks(ctx, newMovie.ID, hook.MovieCreatePost, input, nil) + return r.getMovie(ctx, newMovie.ID) +} + +func moviePartialFromGroupUpdateInput(translator changesetTranslator, input GroupUpdateInput) (ret models.MoviePartial, err error) { + // Populate movie from the input + updatedMovie := models.NewMoviePartial() + + updatedMovie.Name = translator.optionalString(input.Name, "name") + updatedMovie.Aliases = translator.optionalString(input.Aliases, "aliases") + updatedMovie.Duration = translator.optionalInt(input.Duration, "duration") + updatedMovie.Rating = translator.optionalInt(input.Rating100, "rating100") + updatedMovie.Director = translator.optionalString(input.Director, "director") + updatedMovie.Synopsis = translator.optionalString(input.Synopsis, "synopsis") + + updatedMovie.Date, err = translator.optionalDate(input.Date, "date") + if err != nil { + err = fmt.Errorf("converting date: %w", err) + return + } + updatedMovie.StudioID, err = translator.optionalIntFromString(input.StudioID, "studio_id") + if err != nil { + err = fmt.Errorf("converting studio id: %w", err) + return + } + + updatedMovie.TagIDs, err = translator.updateIds(input.TagIds, "tag_ids") + if err != nil { + err = fmt.Errorf("converting tag ids: %w", err) + return + } + + updatedMovie.URLs = translator.updateStrings(input.Urls, "urls") + + return updatedMovie, nil +} + +func (r *mutationResolver) GroupUpdate(ctx context.Context, input GroupUpdateInput) (*models.Movie, error) { + movieID, err := strconv.Atoi(input.ID) + if err != nil { + return nil, fmt.Errorf("converting id: %w", err) + } + + translator := changesetTranslator{ + inputMap: getUpdateInputMap(ctx), + } + + updatedMovie, err := moviePartialFromGroupUpdateInput(translator, input) + if err != nil { + return nil, err + } + + var frontimageData []byte + frontImageIncluded := translator.hasField("front_image") + if input.FrontImage != nil { + frontimageData, err = utils.ProcessImageInput(ctx, *input.FrontImage) + if err != nil { + return nil, fmt.Errorf("processing front image: %w", err) + } + } + + var backimageData []byte + backImageIncluded := translator.hasField("back_image") + if input.BackImage != nil { + backimageData, err = utils.ProcessImageInput(ctx, *input.BackImage) + if err != nil { + return nil, fmt.Errorf("processing back image: %w", err) + } + } + + // Start the transaction and save the movie + var movie *models.Movie + if err := r.withTxn(ctx, func(ctx context.Context) error { + qb := r.repository.Movie + movie, err = qb.UpdatePartial(ctx, movieID, updatedMovie) + if err != nil { + return err + } + + // update image table + if frontImageIncluded { + if err := qb.UpdateFrontImage(ctx, movie.ID, frontimageData); err != nil { + return err + } + } + + if backImageIncluded { + if err := qb.UpdateBackImage(ctx, movie.ID, backimageData); err != nil { + return err + } + } + + return nil + }); err != nil { + return nil, err + } + + // for backwards compatibility - run both movie and group hooks + r.hookExecutor.ExecutePostHooks(ctx, movie.ID, hook.GroupUpdatePost, input, translator.getFields()) + r.hookExecutor.ExecutePostHooks(ctx, movie.ID, hook.MovieUpdatePost, input, translator.getFields()) + return r.getMovie(ctx, movie.ID) +} + +func moviePartialFromBulkGroupUpdateInput(translator changesetTranslator, input BulkGroupUpdateInput) (ret models.MoviePartial, err error) { + updatedMovie := models.NewMoviePartial() + + updatedMovie.Rating = translator.optionalInt(input.Rating100, "rating100") + updatedMovie.Director = translator.optionalString(input.Director, "director") + + updatedMovie.StudioID, err = translator.optionalIntFromString(input.StudioID, "studio_id") + if err != nil { + err = fmt.Errorf("converting studio id: %w", err) + return + } + + updatedMovie.TagIDs, err = translator.updateIdsBulk(input.TagIds, "tag_ids") + if err != nil { + err = fmt.Errorf("converting tag ids: %w", err) + return + } + + updatedMovie.URLs = translator.optionalURLsBulk(input.Urls, nil) + + return updatedMovie, nil +} + +func (r *mutationResolver) BulkGroupUpdate(ctx context.Context, input BulkGroupUpdateInput) ([]*models.Movie, error) { + movieIDs, err := stringslice.StringSliceToIntSlice(input.Ids) + if err != nil { + return nil, fmt.Errorf("converting ids: %w", err) + } + + translator := changesetTranslator{ + inputMap: getUpdateInputMap(ctx), + } + + // Populate movie from the input + updatedMovie, err := moviePartialFromBulkGroupUpdateInput(translator, input) + if err != nil { + return nil, err + } + + ret := []*models.Movie{} + + if err := r.withTxn(ctx, func(ctx context.Context) error { + qb := r.repository.Movie + + for _, movieID := range movieIDs { + movie, err := qb.UpdatePartial(ctx, movieID, updatedMovie) + if err != nil { + return err + } + + ret = append(ret, movie) + } + + return nil + }); err != nil { + return nil, err + } + + var newRet []*models.Movie + for _, movie := range ret { + // for backwards compatibility - run both movie and group hooks + r.hookExecutor.ExecutePostHooks(ctx, movie.ID, hook.GroupUpdatePost, input, translator.getFields()) + r.hookExecutor.ExecutePostHooks(ctx, movie.ID, hook.MovieUpdatePost, input, translator.getFields()) + + movie, err = r.getMovie(ctx, movie.ID) + if err != nil { + return nil, err + } + + newRet = append(newRet, movie) + } + + return newRet, nil +} + +func (r *mutationResolver) GroupDestroy(ctx context.Context, input GroupDestroyInput) (bool, error) { + id, err := strconv.Atoi(input.ID) + if err != nil { + return false, fmt.Errorf("converting id: %w", err) + } + + if err := r.withTxn(ctx, func(ctx context.Context) error { + return r.repository.Movie.Destroy(ctx, id) + }); err != nil { + return false, err + } + + // for backwards compatibility - run both movie and group hooks + r.hookExecutor.ExecutePostHooks(ctx, id, hook.GroupDestroyPost, input, nil) + r.hookExecutor.ExecutePostHooks(ctx, id, hook.MovieDestroyPost, input, nil) + + return true, nil +} + +func (r *mutationResolver) GroupsDestroy(ctx context.Context, groupIDs []string) (bool, error) { + ids, err := stringslice.StringSliceToIntSlice(groupIDs) + if err != nil { + return false, fmt.Errorf("converting ids: %w", err) + } + + if err := r.withTxn(ctx, func(ctx context.Context) error { + qb := r.repository.Movie + for _, id := range ids { + if err := qb.Destroy(ctx, id); err != nil { + return err + } + } + + return nil + }); err != nil { + return false, err + } + + for _, id := range ids { + // for backwards compatibility - run both movie and group hooks + r.hookExecutor.ExecutePostHooks(ctx, id, hook.GroupDestroyPost, groupIDs, nil) + r.hookExecutor.ExecutePostHooks(ctx, id, hook.MovieDestroyPost, groupIDs, nil) + } + + return true, nil +} diff --git a/internal/api/resolver_mutation_movie.go b/internal/api/resolver_mutation_movie.go index c3fce71a601..3e73f32ddd3 100644 --- a/internal/api/resolver_mutation_movie.go +++ b/internal/api/resolver_mutation_movie.go @@ -112,6 +112,8 @@ func (r *mutationResolver) MovieCreate(ctx context.Context, input MovieCreateInp return nil, err } + // for backwards compatibility - run both movie and group hooks + r.hookExecutor.ExecutePostHooks(ctx, newMovie.ID, hook.GroupCreatePost, input, nil) r.hookExecutor.ExecutePostHooks(ctx, newMovie.ID, hook.MovieCreatePost, input, nil) return r.getMovie(ctx, newMovie.ID) } @@ -197,6 +199,8 @@ func (r *mutationResolver) MovieUpdate(ctx context.Context, input MovieUpdateInp return nil, err } + // for backwards compatibility - run both movie and group hooks + r.hookExecutor.ExecutePostHooks(ctx, movie.ID, hook.GroupUpdatePost, input, translator.getFields()) r.hookExecutor.ExecutePostHooks(ctx, movie.ID, hook.MovieUpdatePost, input, translator.getFields()) return r.getMovie(ctx, movie.ID) } @@ -250,6 +254,8 @@ func (r *mutationResolver) BulkMovieUpdate(ctx context.Context, input BulkMovieU var newRet []*models.Movie for _, movie := range ret { + // for backwards compatibility - run both movie and group hooks + r.hookExecutor.ExecutePostHooks(ctx, movie.ID, hook.GroupUpdatePost, input, translator.getFields()) r.hookExecutor.ExecutePostHooks(ctx, movie.ID, hook.MovieUpdatePost, input, translator.getFields()) movie, err = r.getMovie(ctx, movie.ID) @@ -275,6 +281,8 @@ func (r *mutationResolver) MovieDestroy(ctx context.Context, input MovieDestroyI return false, err } + // for backwards compatibility - run both movie and group hooks + r.hookExecutor.ExecutePostHooks(ctx, id, hook.GroupDestroyPost, input, nil) r.hookExecutor.ExecutePostHooks(ctx, id, hook.MovieDestroyPost, input, nil) return true, nil @@ -300,6 +308,8 @@ func (r *mutationResolver) MoviesDestroy(ctx context.Context, movieIDs []string) } for _, id := range ids { + // for backwards compatibility - run both movie and group hooks + r.hookExecutor.ExecutePostHooks(ctx, id, hook.GroupDestroyPost, movieIDs, nil) r.hookExecutor.ExecutePostHooks(ctx, id, hook.MovieDestroyPost, movieIDs, nil) } diff --git a/internal/api/resolver_mutation_scene.go b/internal/api/resolver_mutation_scene.go index 15bf4514716..d3616cc4c0b 100644 --- a/internal/api/resolver_mutation_scene.go +++ b/internal/api/resolver_mutation_scene.go @@ -80,9 +80,17 @@ func (r *mutationResolver) SceneCreate(ctx context.Context, input models.SceneCr return nil, fmt.Errorf("converting gallery ids: %w", err) } - newScene.Movies, err = translator.relatedMovies(input.Movies) - if err != nil { - return nil, fmt.Errorf("converting movies: %w", err) + // prefer groups over movies + if len(input.Groups) > 0 { + newScene.Movies, err = translator.relatedMoviesFromGroups(input.Groups) + if err != nil { + return nil, fmt.Errorf("converting groups: %w", err) + } + } else if len(input.Movies) > 0 { + newScene.Movies, err = translator.relatedMovies(input.Movies) + if err != nil { + return nil, fmt.Errorf("converting movies: %w", err) + } } var coverImageData []byte @@ -216,9 +224,16 @@ func scenePartialFromInput(input models.SceneUpdateInput, translator changesetTr return nil, fmt.Errorf("converting gallery ids: %w", err) } - updatedScene.MovieIDs, err = translator.updateMovieIDs(input.Movies, "movies") - if err != nil { - return nil, fmt.Errorf("converting movies: %w", err) + if translator.hasField("groups") { + updatedScene.MovieIDs, err = translator.updateMovieIDsFromGroups(input.Groups, "groups") + if err != nil { + return nil, fmt.Errorf("converting movies: %w", err) + } + } else if translator.hasField("movies") { + updatedScene.MovieIDs, err = translator.updateMovieIDs(input.Movies, "movies") + if err != nil { + return nil, fmt.Errorf("converting movies: %w", err) + } } return &updatedScene, nil @@ -358,9 +373,16 @@ func (r *mutationResolver) BulkSceneUpdate(ctx context.Context, input BulkSceneU return nil, fmt.Errorf("converting gallery ids: %w", err) } - updatedScene.MovieIDs, err = translator.updateMovieIDsBulk(input.MovieIds, "movie_ids") - if err != nil { - return nil, fmt.Errorf("converting movie ids: %w", err) + if translator.hasField("groups") { + updatedScene.MovieIDs, err = translator.updateMovieIDsBulk(input.GroupIds, "group_ids") + if err != nil { + return nil, fmt.Errorf("converting group ids: %w", err) + } + } else if translator.hasField("movies") { + updatedScene.MovieIDs, err = translator.updateMovieIDsBulk(input.MovieIds, "movie_ids") + if err != nil { + return nil, fmt.Errorf("converting movie ids: %w", err) + } } ret := []*models.Scene{} diff --git a/internal/api/resolver_query_find_group.go b/internal/api/resolver_query_find_group.go new file mode 100644 index 00000000000..f5fdde50a66 --- /dev/null +++ b/internal/api/resolver_query_find_group.go @@ -0,0 +1,59 @@ +package api + +import ( + "context" + "strconv" + + "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/sliceutil/stringslice" +) + +func (r *queryResolver) FindGroup(ctx context.Context, id string) (ret *models.Movie, err error) { + idInt, err := strconv.Atoi(id) + if err != nil { + return nil, err + } + + if err := r.withReadTxn(ctx, func(ctx context.Context) error { + ret, err = r.repository.Movie.Find(ctx, idInt) + return err + }); err != nil { + return nil, err + } + + return ret, nil +} + +func (r *queryResolver) FindGroups(ctx context.Context, movieFilter *models.MovieFilterType, filter *models.FindFilterType, ids []string) (ret *FindGroupsResultType, err error) { + idInts, err := stringslice.StringSliceToIntSlice(ids) + if err != nil { + return nil, err + } + + if err := r.withReadTxn(ctx, func(ctx context.Context) error { + var movies []*models.Movie + var err error + var total int + + if len(idInts) > 0 { + movies, err = r.repository.Movie.FindMany(ctx, idInts) + total = len(movies) + } else { + movies, total, err = r.repository.Movie.Query(ctx, movieFilter, filter) + } + + if err != nil { + return err + } + + ret = &FindGroupsResultType{ + Count: total, + Groups: movies, + } + return nil + }); err != nil { + return nil, err + } + + return ret, nil +} diff --git a/internal/api/resolver_query_scraper.go b/internal/api/resolver_query_scraper.go index 503f73b7e80..9d69843bc99 100644 --- a/internal/api/resolver_query_scraper.go +++ b/internal/api/resolver_query_scraper.go @@ -213,6 +213,39 @@ func (r *queryResolver) ScrapeMovieURL(ctx context.Context, url string) (*models return ret, nil } +func (r *queryResolver) ScrapeGroupURL(ctx context.Context, url string) (*models.ScrapedGroup, error) { + content, err := r.scraperCache().ScrapeURL(ctx, url, scraper.ScrapeContentTypeMovie) + if err != nil { + return nil, err + } + + ret, err := marshalScrapedMovie(content) + if err != nil { + return nil, err + } + + filterMovieTags([]*models.ScrapedMovie{ret}) + + // convert to scraped group + group := &models.ScrapedGroup{ + StoredID: ret.StoredID, + Name: ret.Name, + Aliases: ret.Aliases, + Duration: ret.Duration, + Date: ret.Date, + Rating: ret.Rating, + Director: ret.Director, + URLs: ret.URLs, + Synopsis: ret.Synopsis, + Studio: ret.Studio, + Tags: ret.Tags, + FrontImage: ret.FrontImage, + BackImage: ret.BackImage, + } + + return group, nil +} + func (r *queryResolver) ScrapeSingleScene(ctx context.Context, source scraper.Source, input ScrapeSingleSceneInput) ([]*scraper.ScrapedScene, error) { var ret []*scraper.ScrapedScene @@ -461,3 +494,7 @@ func (r *queryResolver) ScrapeSingleGallery(ctx context.Context, source scraper. func (r *queryResolver) ScrapeSingleMovie(ctx context.Context, source scraper.Source, input ScrapeSingleMovieInput) ([]*models.ScrapedMovie, error) { return nil, ErrNotSupported } + +func (r *queryResolver) ScrapeSingleGroup(ctx context.Context, source scraper.Source, input ScrapeSingleGroupInput) ([]*models.ScrapedGroup, error) { + return nil, ErrNotSupported +} diff --git a/internal/manager/task_export.go b/internal/manager/task_export.go index 0a294e70e4a..cbf304fb6cd 100644 --- a/internal/manager/task_export.go +++ b/internal/manager/task_export.go @@ -42,7 +42,7 @@ type ExportTask struct { scenes *exportSpec images *exportSpec performers *exportSpec - movies *exportSpec + groups *exportSpec tags *exportSpec studios *exportSpec galleries *exportSpec @@ -63,7 +63,8 @@ type ExportObjectsInput struct { Studios *ExportObjectTypeInput `json:"studios"` Performers *ExportObjectTypeInput `json:"performers"` Tags *ExportObjectTypeInput `json:"tags"` - Movies *ExportObjectTypeInput `json:"movies"` + Groups *ExportObjectTypeInput `json:"groups"` + Movies *ExportObjectTypeInput `json:"movies"` // deprecated Galleries *ExportObjectTypeInput `json:"galleries"` IncludeDependencies *bool `json:"includeDependencies"` } @@ -97,13 +98,19 @@ func CreateExportTask(a models.HashAlgorithm, input ExportObjectsInput) *ExportT includeDeps = *input.IncludeDependencies } + // handle deprecated Movies field + groupSpec := input.Groups + if groupSpec == nil && input.Movies != nil { + groupSpec = input.Movies + } + return &ExportTask{ repository: GetInstance().Repository, fileNamingAlgorithm: a, scenes: newExportSpec(input.Scenes), images: newExportSpec(input.Images), performers: newExportSpec(input.Performers), - movies: newExportSpec(input.Movies), + groups: newExportSpec(groupSpec), tags: newExportSpec(input.Tags), studios: newExportSpec(input.Studios), galleries: newExportSpec(input.Galleries), @@ -282,11 +289,11 @@ func (t *ExportTask) populateMovieScenes(ctx context.Context) { var movies []*models.Movie var err error - all := t.full || (t.movies != nil && t.movies.all) + all := t.full || (t.groups != nil && t.groups.all) if all { movies, err = reader.All(ctx) - } else if t.movies != nil && len(t.movies.IDs) > 0 { - movies, err = reader.FindMany(ctx, t.movies.IDs) + } else if t.groups != nil && len(t.groups.IDs) > 0 { + movies, err = reader.FindMany(ctx, t.groups.IDs) } if err != nil { @@ -574,7 +581,7 @@ func (t *ExportTask) exportScene(ctx context.Context, wg *sync.WaitGroup, jobCha logger.Errorf("[scenes] <%s> error getting scene movies: %v", sceneHash, err) continue } - t.movies.IDs = sliceutil.AppendUniques(t.movies.IDs, movieIDs) + t.groups.IDs = sliceutil.AppendUniques(t.groups.IDs, movieIDs) t.performers.IDs = sliceutil.AppendUniques(t.performers.IDs, performer.GetIDs(performers)) } @@ -1080,11 +1087,11 @@ func (t *ExportTask) ExportMovies(ctx context.Context, workers int) { reader := t.repository.Movie var movies []*models.Movie var err error - all := t.full || (t.movies != nil && t.movies.all) + all := t.full || (t.groups != nil && t.groups.all) if all { movies, err = reader.All(ctx) - } else if t.movies != nil && len(t.movies.IDs) > 0 { - movies, err = reader.FindMany(ctx, t.movies.IDs) + } else if t.groups != nil && len(t.groups.IDs) > 0 { + movies, err = reader.FindMany(ctx, t.groups.IDs) } if err != nil { diff --git a/pkg/models/model_saved_filter.go b/pkg/models/model_saved_filter.go index d680e7c95ef..8c9e7b18d8e 100644 --- a/pkg/models/model_saved_filter.go +++ b/pkg/models/model_saved_filter.go @@ -15,6 +15,7 @@ const ( FilterModeGalleries FilterMode = "GALLERIES" FilterModeSceneMarkers FilterMode = "SCENE_MARKERS" FilterModeMovies FilterMode = "MOVIES" + FilterModeGroups FilterMode = "GROUPS" FilterModeTags FilterMode = "TAGS" FilterModeImages FilterMode = "IMAGES" ) @@ -25,6 +26,7 @@ var AllFilterMode = []FilterMode{ FilterModeStudios, FilterModeGalleries, FilterModeSceneMarkers, + FilterModeGroups, FilterModeMovies, FilterModeTags, FilterModeImages, @@ -32,7 +34,7 @@ var AllFilterMode = []FilterMode{ func (e FilterMode) IsValid() bool { switch e { - case FilterModeScenes, FilterModePerformers, FilterModeStudios, FilterModeGalleries, FilterModeSceneMarkers, FilterModeMovies, FilterModeTags, FilterModeImages: + case FilterModeScenes, FilterModePerformers, FilterModeStudios, FilterModeGalleries, FilterModeSceneMarkers, FilterModeMovies, FilterModeGroups, FilterModeTags, FilterModeImages: return true } return false diff --git a/pkg/models/model_scraped_item.go b/pkg/models/model_scraped_item.go index 84c69d7e49b..e95fc6df413 100644 --- a/pkg/models/model_scraped_item.go +++ b/pkg/models/model_scraped_item.go @@ -414,3 +414,24 @@ type ScrapedMovie struct { } func (ScrapedMovie) IsScrapedContent() {} + +// ScrapedGroup is a group from a scraping operation +type ScrapedGroup struct { + StoredID *string `json:"stored_id"` + Name *string `json:"name"` + Aliases *string `json:"aliases"` + Duration *string `json:"duration"` + Date *string `json:"date"` + Rating *string `json:"rating"` + Director *string `json:"director"` + URLs []string `json:"urls"` + Synopsis *string `json:"synopsis"` + Studio *ScrapedStudio `json:"studio"` + Tags []*ScrapedTag `json:"tags"` + // This should be a base64 encoded data URL + FrontImage *string `json:"front_image"` + // This should be a base64 encoded data URL + BackImage *string `json:"back_image"` +} + +func (ScrapedGroup) IsScrapedContent() {} diff --git a/pkg/models/scene.go b/pkg/models/scene.go index 8a2ffde8d5d..5c5df87dbaf 100644 --- a/pkg/models/scene.go +++ b/pkg/models/scene.go @@ -55,6 +55,8 @@ type SceneFilterType struct { IsMissing *string `json:"is_missing"` // Filter to only include scenes with this studio Studios *HierarchicalMultiCriterionInput `json:"studios"` + // Filter to only include scenes with this group + Groups *MultiCriterionInput `json:"groups"` // Filter to only include scenes with this movie Movies *MultiCriterionInput `json:"movies"` // Filter to only include scenes with this gallery @@ -103,6 +105,8 @@ type SceneFilterType struct { StudiosFilter *StudioFilterType `json:"studios_filter"` // Filter by related tags that meet this criteria TagsFilter *TagFilterType `json:"tags_filter"` + // Filter by related groups that meet this criteria + GroupsFilter *MovieFilterType `json:"groups_filter"` // Filter by related movies that meet this criteria MoviesFilter *MovieFilterType `json:"movies_filter"` // Filter by related markers that meet this criteria @@ -131,11 +135,17 @@ type SceneQueryResult struct { resolveErr error } +// SceneMovieInput is used for groups and movies type SceneMovieInput struct { MovieID string `json:"movie_id"` SceneIndex *int `json:"scene_index"` } +type SceneGroupInput struct { + GroupID string `json:"group_id"` + SceneIndex *int `json:"scene_index"` +} + type SceneCreateInput struct { Title *string `json:"title"` Code *string `json:"code"` @@ -150,6 +160,7 @@ type SceneCreateInput struct { GalleryIds []string `json:"gallery_ids"` PerformerIds []string `json:"performer_ids"` Movies []SceneMovieInput `json:"movies"` + Groups []SceneGroupInput `json:"groups"` TagIds []string `json:"tag_ids"` // This should be a URL or a base64 encoded data URL CoverImage *string `json:"cover_image"` @@ -177,6 +188,7 @@ type SceneUpdateInput struct { GalleryIds []string `json:"gallery_ids"` PerformerIds []string `json:"performer_ids"` Movies []SceneMovieInput `json:"movies"` + Groups []SceneGroupInput `json:"groups"` TagIds []string `json:"tag_ids"` // This should be a URL or a base64 encoded data URL CoverImage *string `json:"cover_image"` diff --git a/pkg/models/tag.go b/pkg/models/tag.go index cc32a6ce25c..ddab8baf5d6 100644 --- a/pkg/models/tag.go +++ b/pkg/models/tag.go @@ -22,6 +22,8 @@ type TagFilterType struct { PerformerCount *IntCriterionInput `json:"performer_count"` // Filter by number of studios with this tag StudioCount *IntCriterionInput `json:"studio_count"` + // Filter by number of groups with this tag + GroupCount *IntCriterionInput `json:"group_count"` // Filter by number of movies with this tag MovieCount *IntCriterionInput `json:"movie_count"` // Filter by number of markers with this tag diff --git a/pkg/plugin/hook/hooks.go b/pkg/plugin/hook/hooks.go index 1b7d93be4d6..a8235b1834d 100644 --- a/pkg/plugin/hook/hooks.go +++ b/pkg/plugin/hook/hooks.go @@ -26,10 +26,16 @@ const ( GalleryChapterUpdatePost TriggerEnum = "GalleryChapter.Update.Post" GalleryChapterDestroyPost TriggerEnum = "GalleryChapter.Destroy.Post" + // deprecated - use Group hooks instead + // for now, both movie and group hooks will be executed MovieCreatePost TriggerEnum = "Movie.Create.Post" MovieUpdatePost TriggerEnum = "Movie.Update.Post" MovieDestroyPost TriggerEnum = "Movie.Destroy.Post" + GroupCreatePost TriggerEnum = "Group.Create.Post" + GroupUpdatePost TriggerEnum = "Group.Update.Post" + GroupDestroyPost TriggerEnum = "Group.Destroy.Post" + PerformerCreatePost TriggerEnum = "Performer.Create.Post" PerformerUpdatePost TriggerEnum = "Performer.Update.Post" PerformerDestroyPost TriggerEnum = "Performer.Destroy.Post" diff --git a/pkg/scraper/config.go b/pkg/scraper/config.go index 3a0aadf51e8..19545a08d35 100644 --- a/pkg/scraper/config.go +++ b/pkg/scraper/config.go @@ -299,6 +299,7 @@ func (c config) spec() Scraper { if len(movie.SupportedScrapes) > 0 { ret.Movie = &movie + ret.Group = &movie } return ret @@ -312,7 +313,7 @@ func (c config) supports(ty ScrapeContentType) bool { return (c.SceneByName != nil && c.SceneByQueryFragment != nil) || c.SceneByFragment != nil || len(c.SceneByURL) > 0 case ScrapeContentTypeGallery: return c.GalleryByFragment != nil || len(c.GalleryByURL) > 0 - case ScrapeContentTypeMovie: + case ScrapeContentTypeMovie, ScrapeContentTypeGroup: return len(c.MovieByURL) > 0 } @@ -339,7 +340,7 @@ func (c config) matchesURL(url string, ty ScrapeContentType) bool { return true } } - case ScrapeContentTypeMovie: + case ScrapeContentTypeMovie, ScrapeContentTypeGroup: for _, scraper := range c.MovieByURL { if scraper.matchesURL(url) { return true diff --git a/pkg/scraper/group.go b/pkg/scraper/group.go index bbf0a680adb..94cc05b96ff 100644 --- a/pkg/scraper/group.go +++ b/pkg/scraper/group.go @@ -81,7 +81,7 @@ func loadUrlCandidates(c config, ty ScrapeContentType) []*scrapeByURLConfig { return c.PerformerByURL case ScrapeContentTypeScene: return c.SceneByURL - case ScrapeContentTypeMovie: + case ScrapeContentTypeMovie, ScrapeContentTypeGroup: return c.MovieByURL case ScrapeContentTypeGallery: return c.GalleryByURL diff --git a/pkg/scraper/json.go b/pkg/scraper/json.go index ae96ecb06c8..0da20a8275e 100644 --- a/pkg/scraper/json.go +++ b/pkg/scraper/json.go @@ -102,7 +102,7 @@ func (s *jsonScraper) scrapeByURL(ctx context.Context, url string, ty ScrapeCont return nil, err } return ret, nil - case ScrapeContentTypeMovie: + case ScrapeContentTypeMovie, ScrapeContentTypeGroup: ret, err := scraper.scrapeMovie(ctx, q) if err != nil || ret == nil { return nil, err diff --git a/pkg/scraper/scene.go b/pkg/scraper/scene.go index e5de74a23f1..1ffc20996bb 100644 --- a/pkg/scraper/scene.go +++ b/pkg/scraper/scene.go @@ -18,6 +18,7 @@ type ScrapedScene struct { Studio *models.ScrapedStudio `json:"studio"` Tags []*models.ScrapedTag `json:"tags"` Performers []*models.ScrapedPerformer `json:"performers"` + Groups []*models.ScrapedGroup `json:"groups"` Movies []*models.ScrapedMovie `json:"movies"` RemoteSiteID *string `json:"remote_site_id"` Duration *int `json:"duration"` diff --git a/pkg/scraper/scraper.go b/pkg/scraper/scraper.go index 4eb67dcf415..1e814bd8ad1 100644 --- a/pkg/scraper/scraper.go +++ b/pkg/scraper/scraper.go @@ -31,6 +31,7 @@ type ScrapeContentType string const ( ScrapeContentTypeGallery ScrapeContentType = "GALLERY" ScrapeContentTypeMovie ScrapeContentType = "MOVIE" + ScrapeContentTypeGroup ScrapeContentType = "GROUP" ScrapeContentTypePerformer ScrapeContentType = "PERFORMER" ScrapeContentTypeScene ScrapeContentType = "SCENE" ) @@ -38,13 +39,14 @@ const ( var AllScrapeContentType = []ScrapeContentType{ ScrapeContentTypeGallery, ScrapeContentTypeMovie, + ScrapeContentTypeGroup, ScrapeContentTypePerformer, ScrapeContentTypeScene, } func (e ScrapeContentType) IsValid() bool { switch e { - case ScrapeContentTypeGallery, ScrapeContentTypeMovie, ScrapeContentTypePerformer, ScrapeContentTypeScene: + case ScrapeContentTypeGallery, ScrapeContentTypeMovie, ScrapeContentTypeGroup, ScrapeContentTypePerformer, ScrapeContentTypeScene: return true } return false @@ -81,6 +83,8 @@ type Scraper struct { // Details for gallery scraper Gallery *ScraperSpec `json:"gallery"` // Details for movie scraper + Group *ScraperSpec `json:"group"` + // Details for movie scraper Movie *ScraperSpec `json:"movie"` } diff --git a/pkg/scraper/script.go b/pkg/scraper/script.go index 51ee8526259..bff78ac791b 100644 --- a/pkg/scraper/script.go +++ b/pkg/scraper/script.go @@ -384,7 +384,7 @@ func (s *scriptScraper) scrape(ctx context.Context, input string, ty ScrapeConte var scene *ScrapedScene err := s.runScraperScript(ctx, input, &scene) return scene, err - case ScrapeContentTypeMovie: + case ScrapeContentTypeMovie, ScrapeContentTypeGroup: var movie *models.ScrapedMovie err := s.runScraperScript(ctx, input, &movie) return movie, err diff --git a/pkg/scraper/xpath.go b/pkg/scraper/xpath.go index d13c8e4c077..9eab9c67f8a 100644 --- a/pkg/scraper/xpath.go +++ b/pkg/scraper/xpath.go @@ -83,7 +83,7 @@ func (s *xpathScraper) scrapeByURL(ctx context.Context, url string, ty ScrapeCon return nil, err } return ret, nil - case ScrapeContentTypeMovie: + case ScrapeContentTypeMovie, ScrapeContentTypeGroup: ret, err := scraper.scrapeMovie(ctx, q) if err != nil || ret == nil { return nil, err diff --git a/pkg/sqlite/gallery_test.go b/pkg/sqlite/gallery_test.go index 08908220b75..fcc10aece10 100644 --- a/pkg/sqlite/gallery_test.go +++ b/pkg/sqlite/gallery_test.go @@ -1881,7 +1881,7 @@ func TestGalleryQueryIsMissingPerformers(t *testing.T) { assert.True(t, len(galleries) > 0) - // ensure non of the ids equal the one with movies + // ensure non of the ids equal the one with galleries for _, gallery := range galleries { assert.NotEqual(t, galleryIDs[galleryIdxWithPerformer], gallery.ID) } diff --git a/pkg/sqlite/image_test.go b/pkg/sqlite/image_test.go index e1246ebbe12..aa4ed3b99ad 100644 --- a/pkg/sqlite/image_test.go +++ b/pkg/sqlite/image_test.go @@ -2053,7 +2053,7 @@ func TestImageQueryIsMissingPerformers(t *testing.T) { assert.True(t, len(images) > 0) - // ensure non of the ids equal the one with movies + // ensure non of the ids equal the one with performers for _, image := range images { assert.NotEqual(t, imageIDs[imageIdxWithPerformer], image.ID) } diff --git a/pkg/sqlite/performer_test.go b/pkg/sqlite/performer_test.go index c0124d09d48..e0294f3e442 100644 --- a/pkg/sqlite/performer_test.go +++ b/pkg/sqlite/performer_test.go @@ -1330,7 +1330,7 @@ func verifyPerformerQuery(t *testing.T, filter models.PerformerFilterType, verif for _, performer := range performers { if err := performer.LoadURLs(ctx, db.Performer); err != nil { - t.Errorf("Error loading movie relationships: %v", err) + t.Errorf("Error loading url relationships: %v", err) } } diff --git a/pkg/sqlite/saved_filter.go b/pkg/sqlite/saved_filter.go index 49b1f45ed58..8f58b05e76c 100644 --- a/pkg/sqlite/saved_filter.go +++ b/pkg/sqlite/saved_filter.go @@ -228,10 +228,21 @@ func (qb *SavedFilterStore) getMany(ctx context.Context, q *goqu.SelectDataset) func (qb *SavedFilterStore) FindByMode(ctx context.Context, mode models.FilterMode) ([]*models.SavedFilter, error) { // SELECT * FROM %s WHERE mode = ? AND name != ? ORDER BY name ASC table := qb.table() - sq := qb.selectDataset().Prepared(true).Where( - table.Col("mode").Eq(mode), - table.Col("name").Neq(savedFilterDefaultName), - ).Order(table.Col("name").Asc()) + + // TODO - querying on groups needs to include movies + // remove this when we migrate to remove the movies filter mode in the database + var whereClause exp.Expression + + if mode == models.FilterModeGroups || mode == models.FilterModeMovies { + whereClause = goqu.Or( + table.Col("mode").Eq(models.FilterModeGroups), + table.Col("mode").Eq(models.FilterModeMovies), + ) + } else { + whereClause = table.Col("mode").Eq(mode) + } + + sq := qb.selectDataset().Prepared(true).Where(whereClause).Order(table.Col("name").Asc()) ret, err := qb.getMany(ctx, sq) if err != nil { diff --git a/pkg/sqlite/scene.go b/pkg/sqlite/scene.go index a6b73ac2eb4..fa0cb5e6390 100644 --- a/pkg/sqlite/scene.go +++ b/pkg/sqlite/scene.go @@ -1074,6 +1074,7 @@ var sceneSortOptions = sortOptions{ "duration", "file_mod_time", "framerate", + "group_scene_number", "id", "interactive", "interactive_speed", @@ -1140,7 +1141,7 @@ func (qb *SceneStore) setSceneSort(query *queryBuilder, findFilter *models.FindF direction := findFilter.GetDirection() switch sort { - case "movie_scene_number": + case "movie_scene_number", "group_scene_number": query.join(moviesScenesTable, "", "scenes.id = movies_scenes.scene_id") query.sortAndPagination += getSort("scene_index", direction, moviesScenesTable) case "tag_count": diff --git a/pkg/sqlite/scene_filter.go b/pkg/sqlite/scene_filter.go index b9c219695d0..7c6e3f634fa 100644 --- a/pkg/sqlite/scene_filter.go +++ b/pkg/sqlite/scene_filter.go @@ -147,7 +147,10 @@ func (qb *sceneFilterHandler) criterionHandler() criterionHandler { qb.performersCriterionHandler(sceneFilter.Performers), qb.performerCountCriterionHandler(sceneFilter.PerformerCount), studioCriterionHandler(sceneTable, sceneFilter.Studios), - qb.moviesCriterionHandler(sceneFilter.Movies), + + qb.groupsCriterionHandler(sceneFilter.Groups), + qb.groupsCriterionHandler(sceneFilter.Movies), + qb.galleriesCriterionHandler(sceneFilter.Galleries), qb.performerTagsCriterionHandler(sceneFilter.PerformerTags), qb.performerFavoriteCriterionHandler(sceneFilter.PerformerFavorite), @@ -480,7 +483,7 @@ func (qb *sceneFilterHandler) performerAgeCriterionHandler(performerAge *models. } } -func (qb *sceneFilterHandler) moviesCriterionHandler(movies *models.MultiCriterionInput) criterionHandlerFunc { +func (qb *sceneFilterHandler) groupsCriterionHandler(movies *models.MultiCriterionInput) criterionHandlerFunc { addJoinsFunc := func(f *filterBuilder) { sceneRepository.movies.join(f, "", "scenes.id") f.addLeftJoin("movies", "", "movies_scenes.movie_id = movies.id") diff --git a/pkg/sqlite/setup_test.go b/pkg/sqlite/setup_test.go index 4a6e3edb48d..b8ce23cd6c0 100644 --- a/pkg/sqlite/setup_test.go +++ b/pkg/sqlite/setup_test.go @@ -278,9 +278,7 @@ const ( ) const ( - savedFilterIdxDefaultScene = iota - savedFilterIdxDefaultImage - savedFilterIdxScene + savedFilterIdxScene = iota savedFilterIdxImage // new indexes above @@ -1777,9 +1775,9 @@ func createChapter(ctx context.Context, mqb models.GalleryChapterReaderWriter, c func getSavedFilterMode(index int) models.FilterMode { switch index { - case savedFilterIdxScene, savedFilterIdxDefaultScene: + case savedFilterIdxScene: return models.FilterModeScenes - case savedFilterIdxImage, savedFilterIdxDefaultImage: + case savedFilterIdxImage: return models.FilterModeImages default: return models.FilterModeScenes @@ -1787,11 +1785,6 @@ func getSavedFilterMode(index int) models.FilterMode { } func getSavedFilterName(index int) string { - if index <= savedFilterIdxDefaultImage { - // empty string for default filters - return "" - } - if index <= savedFilterIdxImage { // use the same name for the first two - should be possible return firstSavedFilterName diff --git a/pkg/sqlite/tag.go b/pkg/sqlite/tag.go index c6494f38b91..69dc086ea3f 100644 --- a/pkg/sqlite/tag.go +++ b/pkg/sqlite/tag.go @@ -683,7 +683,7 @@ func (qb *TagStore) getTagSort(query *queryBuilder, findFilter *models.FindFilte sortQuery += getCountSort(tagTable, performersTagsTable, tagIDColumn, direction) case "studios_count": sortQuery += getCountSort(tagTable, studiosTagsTable, tagIDColumn, direction) - case "movies_count": + case "movies_count", "groups_count": sortQuery += getCountSort(tagTable, moviesTagsTable, tagIDColumn, direction) default: sortQuery += getSort(sort, direction, "tags") diff --git a/pkg/sqlite/tag_filter.go b/pkg/sqlite/tag_filter.go index 5bae18c0010..c2fd1723fec 100644 --- a/pkg/sqlite/tag_filter.go +++ b/pkg/sqlite/tag_filter.go @@ -67,7 +67,10 @@ func (qb *tagFilterHandler) criterionHandler() criterionHandler { qb.galleryCountCriterionHandler(tagFilter.GalleryCount), qb.performerCountCriterionHandler(tagFilter.PerformerCount), qb.studioCountCriterionHandler(tagFilter.StudioCount), - qb.movieCountCriterionHandler(tagFilter.MovieCount), + + qb.groupCountCriterionHandler(tagFilter.GroupCount), + qb.groupCountCriterionHandler(tagFilter.MovieCount), + qb.markerCountCriterionHandler(tagFilter.MarkerCount), qb.parentsCriterionHandler(tagFilter.Parents), qb.childrenCriterionHandler(tagFilter.Children), @@ -187,7 +190,7 @@ func (qb *tagFilterHandler) studioCountCriterionHandler(studioCount *models.IntC } } -func (qb *tagFilterHandler) movieCountCriterionHandler(movieCount *models.IntCriterionInput) criterionHandlerFunc { +func (qb *tagFilterHandler) groupCountCriterionHandler(movieCount *models.IntCriterionInput) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if movieCount != nil { f.addLeftJoin("movies_tags", "", "movies_tags.tag_id = tags.id") diff --git a/ui/v2.5/graphql/data/movie-slim.graphql b/ui/v2.5/graphql/data/group-slim.graphql similarity index 63% rename from ui/v2.5/graphql/data/movie-slim.graphql rename to ui/v2.5/graphql/data/group-slim.graphql index 2db5b80bd45..ddb18d4e276 100644 --- a/ui/v2.5/graphql/data/movie-slim.graphql +++ b/ui/v2.5/graphql/data/group-slim.graphql @@ -1,11 +1,11 @@ -fragment SlimMovieData on Movie { +fragment SlimGroupData on Group { id name front_image_path rating100 } -fragment SelectMovieData on Movie { +fragment SelectGroupData on Group { id name aliases diff --git a/ui/v2.5/graphql/data/movie.graphql b/ui/v2.5/graphql/data/group.graphql similarity index 88% rename from ui/v2.5/graphql/data/movie.graphql rename to ui/v2.5/graphql/data/group.graphql index b94450f281e..60f55e30948 100644 --- a/ui/v2.5/graphql/data/movie.graphql +++ b/ui/v2.5/graphql/data/group.graphql @@ -1,4 +1,4 @@ -fragment MovieData on Movie { +fragment GroupData on Group { id name aliases diff --git a/ui/v2.5/graphql/data/performer.graphql b/ui/v2.5/graphql/data/performer.graphql index 91393f39e82..144382a4522 100644 --- a/ui/v2.5/graphql/data/performer.graphql +++ b/ui/v2.5/graphql/data/performer.graphql @@ -23,7 +23,7 @@ fragment PerformerData on Performer { scene_count image_count gallery_count - movie_count + group_count performer_count o_counter diff --git a/ui/v2.5/graphql/data/scene-slim.graphql b/ui/v2.5/graphql/data/scene-slim.graphql index c24eb9752b7..7e2a4ffad2d 100644 --- a/ui/v2.5/graphql/data/scene-slim.graphql +++ b/ui/v2.5/graphql/data/scene-slim.graphql @@ -58,8 +58,8 @@ fragment SlimSceneData on Scene { image_path } - movies { - movie { + groups { + group { id name front_image_path diff --git a/ui/v2.5/graphql/data/scene.graphql b/ui/v2.5/graphql/data/scene.graphql index 2b9ef76a3ea..ef58922295a 100644 --- a/ui/v2.5/graphql/data/scene.graphql +++ b/ui/v2.5/graphql/data/scene.graphql @@ -53,9 +53,9 @@ fragment SceneData on Scene { ...SlimStudioData } - movies { - movie { - ...MovieData + groups { + group { + ...GroupData } scene_index } diff --git a/ui/v2.5/graphql/data/scrapers.graphql b/ui/v2.5/graphql/data/scrapers.graphql index a68bb5c70bd..7e12610a040 100644 --- a/ui/v2.5/graphql/data/scrapers.graphql +++ b/ui/v2.5/graphql/data/scrapers.graphql @@ -73,13 +73,13 @@ fragment ScrapedScenePerformerData on ScrapedPerformer { weight } -fragment ScrapedMovieStudioData on ScrapedStudio { +fragment ScrapedGroupStudioData on ScrapedStudio { stored_id name url } -fragment ScrapedMovieData on ScrapedMovie { +fragment ScrapedGroupData on ScrapedGroup { name aliases duration @@ -92,14 +92,14 @@ fragment ScrapedMovieData on ScrapedMovie { back_image studio { - ...ScrapedMovieStudioData + ...ScrapedGroupStudioData } tags { ...ScrapedSceneTagData } } -fragment ScrapedSceneMovieData on ScrapedMovie { +fragment ScrapedSceneGroupData on ScrapedGroup { stored_id name aliases @@ -113,7 +113,7 @@ fragment ScrapedSceneMovieData on ScrapedMovie { back_image studio { - ...ScrapedMovieStudioData + ...ScrapedGroupStudioData } tags { ...ScrapedSceneTagData @@ -173,8 +173,8 @@ fragment ScrapedSceneData on ScrapedScene { ...ScrapedScenePerformerData } - movies { - ...ScrapedSceneMovieData + groups { + ...ScrapedSceneGroupData } fingerprints { @@ -245,8 +245,8 @@ fragment ScrapedStashBoxSceneData on ScrapedScene { ...ScrapedScenePerformerData } - movies { - ...ScrapedSceneMovieData + groups { + ...ScrapedSceneGroupData } } diff --git a/ui/v2.5/graphql/data/studio.graphql b/ui/v2.5/graphql/data/studio.graphql index afd254d2294..feb35136fed 100644 --- a/ui/v2.5/graphql/data/studio.graphql +++ b/ui/v2.5/graphql/data/studio.graphql @@ -23,8 +23,8 @@ fragment StudioData on Studio { gallery_count_all: gallery_count(depth: -1) performer_count performer_count_all: performer_count(depth: -1) - movie_count - movie_count_all: movie_count(depth: -1) + group_count + group_count_all: group_count(depth: -1) stash_ids { stash_id endpoint diff --git a/ui/v2.5/graphql/data/tag.graphql b/ui/v2.5/graphql/data/tag.graphql index 695bb5de6de..b0501de697c 100644 --- a/ui/v2.5/graphql/data/tag.graphql +++ b/ui/v2.5/graphql/data/tag.graphql @@ -18,8 +18,8 @@ fragment TagData on Tag { performer_count_all: performer_count(depth: -1) studio_count studio_count_all: studio_count(depth: -1) - movie_count - movie_count_all: movie_count(depth: -1) + group_count + group_count_all: group_count(depth: -1) parents { ...SlimTagData diff --git a/ui/v2.5/graphql/mutations/group.graphql b/ui/v2.5/graphql/mutations/group.graphql new file mode 100644 index 00000000000..fb739e84009 --- /dev/null +++ b/ui/v2.5/graphql/mutations/group.graphql @@ -0,0 +1,25 @@ +mutation GroupCreate($input: GroupCreateInput!) { + groupCreate(input: $input) { + ...GroupData + } +} + +mutation GroupUpdate($input: GroupUpdateInput!) { + groupUpdate(input: $input) { + ...GroupData + } +} + +mutation BulkGroupUpdate($input: BulkGroupUpdateInput!) { + bulkGroupUpdate(input: $input) { + ...GroupData + } +} + +mutation GroupDestroy($id: ID!) { + groupDestroy(input: { id: $id }) +} + +mutation GroupsDestroy($ids: [ID!]!) { + groupsDestroy(ids: $ids) +} diff --git a/ui/v2.5/graphql/mutations/movie.graphql b/ui/v2.5/graphql/mutations/movie.graphql deleted file mode 100644 index 1eebae15c77..00000000000 --- a/ui/v2.5/graphql/mutations/movie.graphql +++ /dev/null @@ -1,25 +0,0 @@ -mutation MovieCreate($input: MovieCreateInput!) { - movieCreate(input: $input) { - ...MovieData - } -} - -mutation MovieUpdate($input: MovieUpdateInput!) { - movieUpdate(input: $input) { - ...MovieData - } -} - -mutation BulkMovieUpdate($input: BulkMovieUpdateInput!) { - bulkMovieUpdate(input: $input) { - ...MovieData - } -} - -mutation MovieDestroy($id: ID!) { - movieDestroy(input: { id: $id }) -} - -mutation MoviesDestroy($ids: [ID!]!) { - moviesDestroy(ids: $ids) -} diff --git a/ui/v2.5/graphql/queries/misc.graphql b/ui/v2.5/graphql/queries/misc.graphql index 9367f0cc254..91aa5f15d13 100644 --- a/ui/v2.5/graphql/queries/misc.graphql +++ b/ui/v2.5/graphql/queries/misc.graphql @@ -16,7 +16,7 @@ query Stats { gallery_count performer_count studio_count - movie_count + group_count tag_count total_o_count total_play_duration diff --git a/ui/v2.5/graphql/queries/movie.graphql b/ui/v2.5/graphql/queries/movie.graphql index 088629b87b6..ad47e908dbd 100644 --- a/ui/v2.5/graphql/queries/movie.graphql +++ b/ui/v2.5/graphql/queries/movie.graphql @@ -1,27 +1,27 @@ -query FindMovies($filter: FindFilterType, $movie_filter: MovieFilterType) { - findMovies(filter: $filter, movie_filter: $movie_filter) { +query FindGroups($filter: FindFilterType, $group_filter: GroupFilterType) { + findGroups(filter: $filter, group_filter: $group_filter) { count - movies { - ...MovieData + groups { + ...GroupData } } } -query FindMovie($id: ID!) { - findMovie(id: $id) { - ...MovieData +query FindGroup($id: ID!) { + findGroup(id: $id) { + ...GroupData } } -query FindMoviesForSelect( +query FindGroupsForSelect( $filter: FindFilterType - $movie_filter: MovieFilterType + $group_filter: GroupFilterType $ids: [ID!] ) { - findMovies(filter: $filter, movie_filter: $movie_filter, ids: $ids) { + findGroups(filter: $filter, group_filter: $group_filter, ids: $ids) { count - movies { - ...SelectMovieData + groups { + ...SelectGroupData } } } diff --git a/ui/v2.5/graphql/queries/scrapers/scrapers.graphql b/ui/v2.5/graphql/queries/scrapers/scrapers.graphql index 366938fd4d5..37e5a3a4ab8 100644 --- a/ui/v2.5/graphql/queries/scrapers/scrapers.graphql +++ b/ui/v2.5/graphql/queries/scrapers/scrapers.graphql @@ -31,11 +31,11 @@ query ListGalleryScrapers { } } -query ListMovieScrapers { - listScrapers(types: [MOVIE]) { +query ListGroupScrapers { + listScrapers(types: [GROUP]) { id name - movie { + group { urls supported_scrapes } @@ -114,9 +114,9 @@ query ScrapeGalleryURL($url: String!) { } } -query ScrapeMovieURL($url: String!) { - scrapeMovieURL(url: $url) { - ...ScrapedMovieData +query ScrapeGroupURL($url: String!) { + scrapeGroupURL(url: $url) { + ...ScrapedGroupData } } diff --git a/ui/v2.5/src/components/FrontPage/Control.tsx b/ui/v2.5/src/components/FrontPage/Control.tsx index f06fbb75621..495fbc852c0 100644 --- a/ui/v2.5/src/components/FrontPage/Control.tsx +++ b/ui/v2.5/src/components/FrontPage/Control.tsx @@ -44,6 +44,7 @@ const RecommendationRow: React.FC = ({ mode, filter, header }) => { /> ); case GQL.FilterMode.Movies: + case GQL.FilterMode.Groups: return ( void; } @@ -39,32 +39,32 @@ export const EditGroupsDialog: React.FC = ( const [tagIds, setTagIds] = useState(); const [existingTagIds, setExistingTagIds] = useState(); - const [updateMovies] = useBulkMovieUpdate(getMovieInput()); + const [updateGroups] = useBulkGroupUpdate(getGroupInput()); const [isUpdating, setIsUpdating] = useState(false); - function getMovieInput(): GQL.BulkMovieUpdateInput { + function getGroupInput(): GQL.BulkGroupUpdateInput { const aggregateRating = getAggregateRating(props.selected); const aggregateStudioId = getAggregateStudioId(props.selected); const aggregateTagIds = getAggregateTagIds(props.selected); - const movieInput: GQL.BulkMovieUpdateInput = { - ids: props.selected.map((movie) => movie.id), + const groupInput: GQL.BulkGroupUpdateInput = { + ids: props.selected.map((group) => group.id), director, }; // if rating is undefined - movieInput.rating100 = getAggregateInputValue(rating100, aggregateRating); - movieInput.studio_id = getAggregateInputValue(studioId, aggregateStudioId); - movieInput.tag_ids = getAggregateInputIDs(tagMode, tagIds, aggregateTagIds); + groupInput.rating100 = getAggregateInputValue(rating100, aggregateRating); + groupInput.studio_id = getAggregateInputValue(studioId, aggregateStudioId); + groupInput.tag_ids = getAggregateInputIDs(tagMode, tagIds, aggregateTagIds); - return movieInput; + return groupInput; } async function onSave() { setIsUpdating(true); try { - await updateMovies(); + await updateGroups(); Toast.success( intl.formatMessage( { id: "toast.updated_entity" }, @@ -88,26 +88,26 @@ export const EditGroupsDialog: React.FC = ( let updateDirector: string | undefined; let first = true; - state.forEach((movie: GQL.MovieDataFragment) => { - const movieTagIDs = (movie.tags ?? []).map((p) => p.id).sort(); + state.forEach((group: GQL.GroupDataFragment) => { + const groupTagIDs = (group.tags ?? []).map((p) => p.id).sort(); if (first) { first = false; - updateRating = movie.rating100 ?? undefined; - updateStudioId = movie.studio?.id ?? undefined; - updateTagIds = movieTagIDs; - updateDirector = movie.director ?? undefined; + updateRating = group.rating100 ?? undefined; + updateStudioId = group.studio?.id ?? undefined; + updateTagIds = groupTagIDs; + updateDirector = group.director ?? undefined; } else { - if (movie.rating100 !== updateRating) { + if (group.rating100 !== updateRating) { updateRating = undefined; } - if (movie.studio?.id !== updateStudioId) { + if (group.studio?.id !== updateStudioId) { updateStudioId = undefined; } - if (movie.director !== updateDirector) { + if (group.director !== updateDirector) { updateDirector = undefined; } - if (!isEqual(movieTagIDs, updateTagIds)) { + if (!isEqual(groupTagIDs, updateTagIds)) { updateTagIds = []; } } diff --git a/ui/v2.5/src/components/Movies/MovieCard.tsx b/ui/v2.5/src/components/Movies/MovieCard.tsx index 739761251c3..ff84262533f 100644 --- a/ui/v2.5/src/components/Movies/MovieCard.tsx +++ b/ui/v2.5/src/components/Movies/MovieCard.tsx @@ -12,7 +12,7 @@ import { faPlayCircle, faTag } from "@fortawesome/free-solid-svg-icons"; import ScreenUtils from "src/utils/screen"; interface IProps { - group: GQL.MovieDataFragment; + group: GQL.GroupDataFragment; containerWidth?: number; sceneIndex?: number; selecting?: boolean; diff --git a/ui/v2.5/src/components/Movies/MovieCardGrid.tsx b/ui/v2.5/src/components/Movies/MovieCardGrid.tsx index 52cbc0f53d6..a08ec58fecd 100644 --- a/ui/v2.5/src/components/Movies/MovieCardGrid.tsx +++ b/ui/v2.5/src/components/Movies/MovieCardGrid.tsx @@ -4,7 +4,7 @@ import { GroupCard } from "./MovieCard"; import { useContainerDimensions } from "../Shared/GridCard/GridCard"; interface IGroupCardGrid { - groups: GQL.MovieDataFragment[]; + groups: GQL.GroupDataFragment[]; selectedIds: Set; onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void; } diff --git a/ui/v2.5/src/components/Movies/MovieDetails/Movie.tsx b/ui/v2.5/src/components/Movies/MovieDetails/Movie.tsx index 686a92b39f2..731818da4cc 100644 --- a/ui/v2.5/src/components/Movies/MovieDetails/Movie.tsx +++ b/ui/v2.5/src/components/Movies/MovieDetails/Movie.tsx @@ -6,9 +6,9 @@ import cx from "classnames"; import Mousetrap from "mousetrap"; import * as GQL from "src/core/generated-graphql"; import { - useFindMovie, - useMovieUpdate, - useMovieDestroy, + useFindGroup, + useGroupUpdate, + useGroupDestroy, } from "src/core/StashService"; import { useHistory, RouteComponentProps } from "react-router-dom"; import { DetailsEditNavbar } from "src/components/Shared/DetailsEditNavbar"; @@ -19,7 +19,7 @@ import { ModalComponent } from "src/components/Shared/Modal"; import { useToast } from "src/hooks/Toast"; import { GroupScenesPanel } from "./MovieScenesPanel"; import { - CompressedMovieDetailsPanel, + CompressedGroupDetailsPanel, GroupDetailsPanel, } from "./MovieDetailsPanel"; import { GroupEditPanel } from "./MovieEditPanel"; @@ -38,7 +38,7 @@ import { useScrollToTopOnMount } from "src/hooks/scrollToTop"; import { ExternalLinksButton } from "src/components/Shared/ExternalLinksButton"; interface IProps { - group: GQL.MovieDataFragment; + group: GQL.GroupDataFragment; } interface IGroupParams { @@ -64,7 +64,7 @@ const GroupPage: React.FC = ({ group }) => { const [isEditing, setIsEditing] = useState(false); const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState(false); - // Editing movie state + // Editing group state const [frontImage, setFrontImage] = useState(); const [backImage, setBackImage] = useState(); const [encodingImage, setEncodingImage] = useState(false); @@ -106,8 +106,8 @@ const GroupPage: React.FC = ({ group }) => { images: lightboxImages, }); - const [updateMovie, { loading: updating }] = useMovieUpdate(); - const [deleteMovie, { loading: deleting }] = useMovieDestroy({ + const [updateGroup, { loading: updating }] = useGroupUpdate(); + const [deleteGroup, { loading: deleting }] = useGroupDestroy({ id: group.id, }); @@ -131,8 +131,8 @@ const GroupPage: React.FC = ({ group }) => { setRating ); - async function onSave(input: GQL.MovieCreateInput) { - await updateMovie({ + async function onSave(input: GQL.GroupCreateInput) { + await updateGroup({ variables: { input: { id: group.id, @@ -151,12 +151,12 @@ const GroupPage: React.FC = ({ group }) => { async function onDelete() { try { - await deleteMovie(); + await deleteGroup(); } catch (e) { Toast.error(e); } - // redirect to movies page + // redirect to groups page history.push(`/groups`); } @@ -287,7 +287,7 @@ const GroupPage: React.FC = ({ group }) => { function setRating(v: number | null) { if (group.id) { - updateMovie({ + updateGroup({ variables: { input: { id: group.id, @@ -343,7 +343,7 @@ const GroupPage: React.FC = ({ group }) => { function maybeRenderCompressedDetails() { if (!isEditing && loadStickyHeader) { - return ; + return ; } } @@ -441,16 +441,16 @@ const GroupLoader: React.FC> = ({ match, }) => { const { id } = match.params; - const { data, loading, error } = useFindMovie(id); + const { data, loading, error } = useFindGroup(id); useScrollToTopOnMount(); if (loading) return ; if (error) return ; - if (!data?.findMovie) - return ; + if (!data?.findGroup) + return ; - return ; + return ; }; export default GroupLoader; diff --git a/ui/v2.5/src/components/Movies/MovieDetails/MovieCreate.tsx b/ui/v2.5/src/components/Movies/MovieDetails/MovieCreate.tsx index 2f65463c90a..5d15afbd088 100644 --- a/ui/v2.5/src/components/Movies/MovieDetails/MovieCreate.tsx +++ b/ui/v2.5/src/components/Movies/MovieDetails/MovieCreate.tsx @@ -1,6 +1,6 @@ import React, { useMemo, useState } from "react"; import * as GQL from "src/core/generated-graphql"; -import { useMovieCreate } from "src/core/StashService"; +import { useGroupCreate } from "src/core/StashService"; import { useHistory, useLocation } from "react-router-dom"; import { useIntl } from "react-intl"; import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; @@ -18,23 +18,23 @@ const GroupCreate: React.FC = () => { name: query.get("q") ?? undefined, }; - // Editing movie state + // Editing group state const [frontImage, setFrontImage] = useState(); const [backImage, setBackImage] = useState(); const [encodingImage, setEncodingImage] = useState(false); - const [createMovie] = useMovieCreate(); + const [createGroup] = useGroupCreate(); - async function onSave(input: GQL.MovieCreateInput) { - const result = await createMovie({ + async function onSave(input: GQL.GroupCreateInput) { + const result = await createGroup({ variables: { input }, }); - if (result.data?.movieCreate?.id) { - history.push(`/groups/${result.data.movieCreate.id}`); + if (result.data?.groupCreate?.id) { + history.push(`/groups/${result.data.groupCreate.id}`); Toast.success( intl.formatMessage( { id: "toast.created_entity" }, - { entity: intl.formatMessage({ id: "gallery" }).toLocaleLowerCase() } + { entity: intl.formatMessage({ id: "group" }).toLocaleLowerCase() } ) ); } diff --git a/ui/v2.5/src/components/Movies/MovieDetails/MovieDetailsPanel.tsx b/ui/v2.5/src/components/Movies/MovieDetails/MovieDetailsPanel.tsx index 8f911c08c76..eb3696550fe 100644 --- a/ui/v2.5/src/components/Movies/MovieDetails/MovieDetailsPanel.tsx +++ b/ui/v2.5/src/components/Movies/MovieDetails/MovieDetailsPanel.tsx @@ -8,7 +8,7 @@ import { DirectorLink } from "src/components/Shared/Link"; import { TagLink } from "src/components/Shared/TagLink"; interface IGroupDetailsPanel { - group: GQL.MovieDataFragment; + group: GQL.GroupDataFragment; collapsed?: boolean; fullWidth?: boolean; } @@ -97,7 +97,7 @@ export const GroupDetailsPanel: React.FC = ({ ); }; -export const CompressedMovieDetailsPanel: React.FC = ({ +export const CompressedGroupDetailsPanel: React.FC = ({ group, }) => { function scrollToTop() { diff --git a/ui/v2.5/src/components/Movies/MovieDetails/MovieEditPanel.tsx b/ui/v2.5/src/components/Movies/MovieDetails/MovieEditPanel.tsx index 0a281df513d..5b8584f6a69 100644 --- a/ui/v2.5/src/components/Movies/MovieDetails/MovieEditPanel.tsx +++ b/ui/v2.5/src/components/Movies/MovieDetails/MovieEditPanel.tsx @@ -4,8 +4,8 @@ import * as GQL from "src/core/generated-graphql"; import * as yup from "yup"; import Mousetrap from "mousetrap"; import { - queryScrapeMovieURL, - useListMovieScrapers, + queryScrapeGroupURL, + useListGroupScrapers, } from "src/core/StashService"; import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; import { DetailsEditNavbar } from "src/components/Shared/DetailsEditNavbar"; @@ -28,8 +28,8 @@ import { Studio, StudioSelect } from "src/components/Studios/StudioSelect"; import { useTagsEdit } from "src/hooks/tagsEdit"; interface IGroupEditPanel { - group: Partial; - onSubmit: (movie: GQL.MovieCreateInput) => Promise; + group: Partial; + onSubmit: (group: GQL.GroupCreateInput) => Promise; onCancel: () => void; onDelete: () => void; setFrontImage: (image?: string | null) => void; @@ -56,8 +56,8 @@ export const GroupEditPanel: React.FC = ({ const [imageClipboard, setImageClipboard] = useState(); - const Scrapers = useListMovieScrapers(); - const [scrapedGroup, setScrapedGroup] = useState(); + const Scrapers = useListGroupScrapers(); + const [scrapedGroup, setScrapedGroup] = useState(); const [studio, setStudio] = useState(null); @@ -129,7 +129,7 @@ export const GroupEditPanel: React.FC = ({ }); function updateGroupEditStateFromScraper( - state: Partial + state: Partial ) { if (state.name) { formik.setFieldValue("name", state.name); @@ -190,21 +190,21 @@ export const GroupEditPanel: React.FC = ({ setIsLoading(false); } - async function onScrapeMovieURL(url: string) { + async function onScrapeGroupURL(url: string) { if (!url) return; setIsLoading(true); try { - const result = await queryScrapeMovieURL(url); - if (!result.data || !result.data.scrapeMovieURL) { + const result = await queryScrapeGroupURL(url); + if (!result.data || !result.data.scrapeGroupURL) { return; } // if this is a new group, just dump the data if (isNew) { - updateGroupEditStateFromScraper(result.data.scrapeMovieURL); + updateGroupEditStateFromScraper(result.data.scrapeGroupURL); } else { - setScrapedGroup(result.data.scrapeMovieURL); + setScrapedGroup(result.data.scrapeGroupURL); } } catch (e) { Toast.error(e); @@ -217,7 +217,7 @@ export const GroupEditPanel: React.FC = ({ return ( !!scrapedUrl && (Scrapers?.data?.listScrapers ?? []).some((s) => - (s?.movie?.urls ?? []).some((u) => scrapedUrl.includes(u)) + (s?.group?.urls ?? []).some((u) => scrapedUrl.includes(u)) ) ); } @@ -249,7 +249,7 @@ export const GroupEditPanel: React.FC = ({ ); } - function onScrapeDialogClosed(p?: GQL.ScrapedMovieDataFragment) { + function onScrapeDialogClosed(p?: GQL.ScrapedGroupDataFragment) { if (p) { updateGroupEditStateFromScraper(p); } @@ -381,7 +381,7 @@ export const GroupEditPanel: React.FC = ({ { - // Check if it's a redirect after movie creation + // Check if it's a redirect after group creation if (action === "PUSH" && location.pathname.startsWith("/groups/")) return true; @@ -396,7 +396,7 @@ export const GroupEditPanel: React.FC = ({ {renderDateField("date")} {renderStudioField()} {renderInputField("director")} - {renderURLListField("urls", onScrapeMovieURL, urlScrapable)} + {renderURLListField("urls", onScrapeGroupURL, urlScrapable)} {renderInputField("synopsis", "textarea")} {renderTagsField()} diff --git a/ui/v2.5/src/components/Movies/MovieDetails/MovieScenesPanel.tsx b/ui/v2.5/src/components/Movies/MovieDetails/MovieScenesPanel.tsx index 6ab34bfcad1..deb1f31a7f6 100644 --- a/ui/v2.5/src/components/Movies/MovieDetails/MovieScenesPanel.tsx +++ b/ui/v2.5/src/components/Movies/MovieDetails/MovieScenesPanel.tsx @@ -1,13 +1,13 @@ import React from "react"; import * as GQL from "src/core/generated-graphql"; -import { MoviesCriterion } from "src/models/list-filter/criteria/movies"; +import { GroupsCriterion as GroupsCriterion } from "src/models/list-filter/criteria/movies"; import { ListFilterModel } from "src/models/list-filter/filter"; import { SceneList } from "src/components/Scenes/SceneList"; import { View } from "src/components/List/views"; interface IGroupScenesPanel { active: boolean; - group: GQL.MovieDataFragment; + group: GQL.GroupDataFragment; } export const GroupScenesPanel: React.FC = ({ @@ -15,32 +15,32 @@ export const GroupScenesPanel: React.FC = ({ group, }) => { function filterHook(filter: ListFilterModel) { - const movieValue = { id: group.id, label: group.name }; - // if movie is already present, then we modify it, otherwise add - let movieCriterion = filter.criteria.find((c) => { - return c.criterionOption.type === "movies"; - }) as MoviesCriterion | undefined; + const groupValue = { id: group.id, label: group.name }; + // if group is already present, then we modify it, otherwise add + let groupCriterion = filter.criteria.find((c) => { + return c.criterionOption.type === "groups"; + }) as GroupsCriterion | undefined; if ( - movieCriterion && - (movieCriterion.modifier === GQL.CriterionModifier.IncludesAll || - movieCriterion.modifier === GQL.CriterionModifier.Includes) + groupCriterion && + (groupCriterion.modifier === GQL.CriterionModifier.IncludesAll || + groupCriterion.modifier === GQL.CriterionModifier.Includes) ) { - // add the movie if not present + // add the group if not present if ( - !movieCriterion.value.find((p) => { + !groupCriterion.value.find((p) => { return p.id === group.id; }) ) { - movieCriterion.value.push(movieValue); + groupCriterion.value.push(groupValue); } - movieCriterion.modifier = GQL.CriterionModifier.IncludesAll; + groupCriterion.modifier = GQL.CriterionModifier.IncludesAll; } else { // overwrite - movieCriterion = new MoviesCriterion(); - movieCriterion.value = [movieValue]; - filter.criteria.push(movieCriterion); + groupCriterion = new GroupsCriterion(); + groupCriterion.value = [groupValue]; + filter.criteria.push(groupCriterion); } return filter; @@ -50,7 +50,7 @@ export const GroupScenesPanel: React.FC = ({ return ( diff --git a/ui/v2.5/src/components/Movies/MovieDetails/MovieScrapeDialog.tsx b/ui/v2.5/src/components/Movies/MovieDetails/MovieScrapeDialog.tsx index 64456411243..bdb5d6ad519 100644 --- a/ui/v2.5/src/components/Movies/MovieDetails/MovieScrapeDialog.tsx +++ b/ui/v2.5/src/components/Movies/MovieDetails/MovieScrapeDialog.tsx @@ -21,12 +21,12 @@ import { Tag } from "src/components/Tags/TagSelect"; import { useScrapedTags } from "src/components/Shared/ScrapeDialog/scrapedTags"; interface IGroupScrapeDialogProps { - group: Partial; + group: Partial; groupStudio: Studio | null; groupTags: Tag[]; - scraped: GQL.ScrapedMovie; + scraped: GQL.ScrapedGroup; - onClose: (scrapedMovie?: GQL.ScrapedMovie) => void; + onClose: (scrapedGroup?: GQL.ScrapedGroup) => void; } export const GroupScrapeDialog: React.FC = ({ @@ -126,7 +126,7 @@ export const GroupScrapeDialog: React.FC = ({ return <>; } - function makeNewScrapedItem(): GQL.ScrapedMovie { + function makeNewScrapedItem(): GQL.ScrapedGroup { const newStudioValue = studio.getNewValue(); const durationString = duration.getNewValue(); diff --git a/ui/v2.5/src/components/Movies/MovieList.tsx b/ui/v2.5/src/components/Movies/MovieList.tsx index d4d4bf1c9aa..28c4baadd59 100644 --- a/ui/v2.5/src/components/Movies/MovieList.tsx +++ b/ui/v2.5/src/components/Movies/MovieList.tsx @@ -7,9 +7,9 @@ import { ListFilterModel } from "src/models/list-filter/filter"; import { DisplayMode } from "src/models/list-filter/types"; import * as GQL from "src/core/generated-graphql"; import { - queryFindMovies, - useFindMovies, - useMoviesDestroy, + queryFindGroups, + useFindGroups, + useGroupsDestroy, } from "src/core/StashService"; import { makeItemList, showWhenSelected } from "../List/ItemList"; import { ExportDialog } from "../Shared/ExportDialog"; @@ -19,13 +19,13 @@ import { EditGroupsDialog } from "./EditMoviesDialog"; import { View } from "../List/views"; const GroupItemList = makeItemList({ - filterMode: GQL.FilterMode.Movies, - useResult: useFindMovies, - getItems(result: GQL.FindMoviesQueryResult) { - return result?.data?.findMovies?.movies ?? []; + filterMode: GQL.FilterMode.Groups, + useResult: useFindGroups, + getItems(result: GQL.FindGroupsQueryResult) { + return result?.data?.findGroups?.groups ?? []; }, - getCount(result: GQL.FindMoviesQueryResult) { - return result?.data?.findMovies?.count ?? 0; + getCount(result: GQL.FindGroupsQueryResult) { + return result?.data?.findGroups?.count ?? 0; }, }); @@ -62,7 +62,7 @@ export const GroupList: React.FC = ({ ]; function addKeybinds( - result: GQL.FindMoviesQueryResult, + result: GQL.FindGroupsQueryResult, filter: ListFilterModel ) { Mousetrap.bind("p r", () => { @@ -75,21 +75,21 @@ export const GroupList: React.FC = ({ } async function viewRandom( - result: GQL.FindMoviesQueryResult, + result: GQL.FindGroupsQueryResult, filter: ListFilterModel ) { // query for a random image - if (result.data?.findMovies) { - const { count } = result.data.findMovies; + if (result.data?.findGroups) { + const { count } = result.data.findGroups; const index = Math.floor(Math.random() * count); const filterCopy = cloneDeep(filter); filterCopy.itemsPerPage = 1; filterCopy.currentPage = index + 1; - const singleResult = await queryFindMovies(filterCopy); - if (singleResult.data.findMovies.movies.length === 1) { - const { id } = singleResult.data.findMovies.movies[0]; - // navigate to the movie page + const singleResult = await queryFindGroups(filterCopy); + if (singleResult.data.findGroups.groups.length === 1) { + const { id } = singleResult.data.findGroups.groups[0]; + // navigate to the group page history.push(`/groups/${id}`); } } @@ -106,7 +106,7 @@ export const GroupList: React.FC = ({ } function renderContent( - result: GQL.FindMoviesQueryResult, + result: GQL.FindGroupsQueryResult, filter: ListFilterModel, selectedIds: Set, onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void @@ -116,7 +116,7 @@ export const GroupList: React.FC = ({ return ( = ({ } function renderGroups() { - if (!result.data?.findMovies) return; + if (!result.data?.findGroups) return; if (filter.displayMode === DisplayMode.Grid) { return ( @@ -152,14 +152,14 @@ export const GroupList: React.FC = ({ } function renderEditDialog( - selectedGroups: GQL.MovieDataFragment[], + selectedGroups: GQL.GroupDataFragment[], onClose: (applied: boolean) => void ) { return ; } function renderDeleteDialog( - selectedGroups: GQL.SlimMovieDataFragment[], + selectedGroups: GQL.SlimGroupDataFragment[], onClose: (confirmed: boolean) => void ) { return ( @@ -168,7 +168,7 @@ export const GroupList: React.FC = ({ onClose={onClose} singularEntity={intl.formatMessage({ id: "group" })} pluralEntity={intl.formatMessage({ id: "groups" })} - destroyMutation={useMoviesDestroy} + destroyMutation={useGroupsDestroy} /> ); } diff --git a/ui/v2.5/src/components/Movies/MovieRecommendationRow.tsx b/ui/v2.5/src/components/Movies/MovieRecommendationRow.tsx index 9247d4ed9a1..5ecf3616158 100644 --- a/ui/v2.5/src/components/Movies/MovieRecommendationRow.tsx +++ b/ui/v2.5/src/components/Movies/MovieRecommendationRow.tsx @@ -1,6 +1,6 @@ import React from "react"; import { Link } from "react-router-dom"; -import { useFindMovies } from "src/core/StashService"; +import { useFindGroups } from "src/core/StashService"; import Slider from "@ant-design/react-slick"; import { GroupCard } from "./MovieCard"; import { ListFilterModel } from "src/models/list-filter/filter"; @@ -15,8 +15,8 @@ interface IProps { } export const GroupRecommendationRow: React.FC = (props: IProps) => { - const result = useFindMovies(props.filter); - const cardCount = result.data?.findMovies.count; + const result = useFindGroups(props.filter); + const cardCount = result.data?.findGroups.count; if (!result.loading && !cardCount) { return null; @@ -42,8 +42,8 @@ export const GroupRecommendationRow: React.FC = (props: IProps) => { ? [...Array(props.filter.itemsPerPage)].map((i) => (
    )) - : result.data?.findMovies.movies.map((m) => ( - + : result.data?.findGroups.groups.map((g) => ( + ))} diff --git a/ui/v2.5/src/components/Movies/MovieSelect.tsx b/ui/v2.5/src/components/Movies/MovieSelect.tsx index 1aa791235de..4f611e5e3b4 100644 --- a/ui/v2.5/src/components/Movies/MovieSelect.tsx +++ b/ui/v2.5/src/components/Movies/MovieSelect.tsx @@ -9,9 +9,9 @@ import cx from "classnames"; import * as GQL from "src/core/generated-graphql"; import { - queryFindMoviesForSelect, - queryFindMoviesByIDForSelect, - useMovieCreate, + queryFindGroupsForSelect, + queryFindGroupsByIDForSelect, + useGroupCreate, } from "src/core/StashService"; import { ConfigurationContext } from "src/hooks/Config"; import { useIntl } from "react-intl"; @@ -31,29 +31,29 @@ import { PatchComponent, PatchFunction } from "src/patch"; import { TruncatedText } from "../Shared/TruncatedText"; export type Group = Pick< - GQL.Movie, + GQL.Group, "id" | "name" | "date" | "front_image_path" | "aliases" > & { studio?: Pick | null; }; type Option = SelectOption; -type FindMoviesResult = Awaited< - ReturnType ->["data"]["findMovies"]["movies"]; +type FindGroupsResult = Awaited< + ReturnType +>["data"]["findGroups"]["groups"]; -function sortMoviesByRelevance(input: string, movies: FindMoviesResult) { +function sortGroupsByRelevance(input: string, groups: FindGroupsResult) { return sortByRelevance( input, - movies, + groups, (m) => m.name, (m) => (m.aliases ? [m.aliases] : []) ); } -const movieSelectSort = PatchFunction( - "MovieSelect.sort", - sortMoviesByRelevance +const groupSelectSort = PatchFunction( + "GroupSelect.sort", + sortGroupsByRelevance ); const _GroupSelect: React.FC< @@ -63,7 +63,7 @@ const _GroupSelect: React.FC< excludeIds?: string[]; } > = (props) => { - const [createMovie] = useMovieCreate(); + const [createGroup] = useGroupCreate(); const { configuration } = React.useContext(ConfigurationContext); const intl = useIntl(); @@ -74,23 +74,23 @@ const _GroupSelect: React.FC< const exclude = useMemo(() => props.excludeIds ?? [], [props.excludeIds]); - async function loadMovies(input: string): Promise { - const filter = new ListFilterModel(GQL.FilterMode.Movies); + async function loadGroups(input: string): Promise { + const filter = new ListFilterModel(GQL.FilterMode.Groups); filter.searchTerm = input; filter.currentPage = 1; filter.itemsPerPage = maxOptionsShown; filter.sortBy = "name"; filter.sortDirection = GQL.SortDirectionEnum.Asc; - const query = await queryFindMoviesForSelect(filter); - let ret = query.data.findMovies.movies.filter((movie) => { + const query = await queryFindGroupsForSelect(filter); + let ret = query.data.findGroups.groups.filter((group) => { // HACK - we should probably exclude these in the backend query, but // this will do in the short-term - return !exclude.includes(movie.id.toString()); + return !exclude.includes(group.id.toString()); }); - return movieSelectSort(input, ret).map((movie) => ({ - value: movie.id, - object: movie, + return groupSelectSort(input, ret).map((group) => ({ + value: group.id, + object: group, })); } @@ -184,12 +184,12 @@ const _GroupSelect: React.FC< }; const onCreate = async (name: string) => { - const result = await createMovie({ + const result = await createGroup({ variables: { input: { name } }, }); return { - value: result.data!.movieCreate!.id, - item: result.data!.movieCreate!, + value: result.data!.groupCreate!.id, + item: result.data!.groupCreate!, message: "Created group", }; }; @@ -230,7 +230,7 @@ const _GroupSelect: React.FC< }, props.className )} - loadOptions={loadMovies} + loadOptions={loadGroups} getNamedObject={getNamedObject} isValidNewOption={isValidNewOption} components={{ @@ -273,10 +273,10 @@ const _GroupIDSelect: React.FC> = ( } async function loadObjectsByID(idsToLoad: string[]): Promise { - const query = await queryFindMoviesByIDForSelect(idsToLoad); - const { movies: loadedMovies } = query.data.findMovies; + const query = await queryFindGroupsByIDForSelect(idsToLoad); + const { groups: loadedGroups } = query.data.findGroups; - return loadedMovies; + return loadedGroups; } useEffect(() => { diff --git a/ui/v2.5/src/components/Performers/PerformerCard.tsx b/ui/v2.5/src/components/Performers/PerformerCard.tsx index 3d5765adad6..eae5d121c90 100644 --- a/ui/v2.5/src/components/Performers/PerformerCard.tsx +++ b/ui/v2.5/src/components/Performers/PerformerCard.tsx @@ -28,7 +28,7 @@ export interface IPerformerCardExtraCriteria { scenes?: Criterion[]; images?: Criterion[]; galleries?: Criterion[]; - movies?: Criterion[]; + groups?: Criterion[]; performer?: ILabeledId; } @@ -179,17 +179,17 @@ export const PerformerCard: React.FC = ({ } function maybeRenderGroupsPopoverButton() { - if (!performer.movie_count) return; + if (!performer.group_count) return; return ( ); @@ -202,7 +202,7 @@ export const PerformerCard: React.FC = ({ performer.gallery_count || performer.tags.length > 0 || performer.o_counter || - performer.movie_count + performer.group_count ) { return ( <> diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx index 2c19fa7753c..6d67ec88898 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx @@ -145,7 +145,7 @@ const PerformerPage: React.FC = ({ performer, tabKey }) => { ret = "galleries"; } else if (performer.image_count != 0) { ret = "images"; - } else if (performer.movie_count != 0) { + } else if (performer.group_count != 0) { ret = "groups"; } } @@ -325,7 +325,7 @@ const PerformerPage: React.FC = ({ performer, tabKey }) => { {intl.formatMessage({ id: "groups" })} diff --git a/ui/v2.5/src/components/SceneDuplicateChecker/SceneDuplicateChecker.tsx b/ui/v2.5/src/components/SceneDuplicateChecker/SceneDuplicateChecker.tsx index 9ee04ac68c9..5f4f5e44da2 100644 --- a/ui/v2.5/src/components/SceneDuplicateChecker/SceneDuplicateChecker.tsx +++ b/ui/v2.5/src/components/SceneDuplicateChecker/SceneDuplicateChecker.tsx @@ -387,23 +387,23 @@ export const SceneDuplicateChecker: React.FC = () => { } function maybeRenderGroupPopoverButton(scene: GQL.SlimSceneDataFragment) { - if (scene.movies.length <= 0) return; + if (scene.groups.length <= 0) return; - const popoverContent = scene.movies.map((sceneMovie) => ( -
    + const popoverContent = scene.groups.map((sceneGroup) => ( +
    {sceneMovie.movie.name
    @@ -417,7 +417,7 @@ export const SceneDuplicateChecker: React.FC = () => { > ); @@ -511,7 +511,7 @@ export const SceneDuplicateChecker: React.FC = () => { if ( scene.tags.length > 0 || scene.performers.length > 0 || - scene.movies.length > 0 || + scene.groups.length > 0 || scene.scene_markers.length > 0 || scene?.o_counter || scene.galleries.length > 0 || diff --git a/ui/v2.5/src/components/Scenes/EditScenesDialog.tsx b/ui/v2.5/src/components/Scenes/EditScenesDialog.tsx index f4fc8a1e233..06f3549a3cd 100644 --- a/ui/v2.5/src/components/Scenes/EditScenesDialog.tsx +++ b/ui/v2.5/src/components/Scenes/EditScenesDialog.tsx @@ -79,7 +79,7 @@ export const EditScenesDialog: React.FC = ( aggregatePerformerIds ); sceneInput.tag_ids = getAggregateInputIDs(tagMode, tagIds, aggregateTagIds); - sceneInput.movie_ids = getAggregateInputIDs( + sceneInput.group_ids = getAggregateInputIDs( groupMode, groupIds, aggregateGroupIds @@ -126,7 +126,7 @@ export const EditScenesDialog: React.FC = ( .map((p) => p.id) .sort(); const sceneTagIDs = (scene.tags ?? []).map((p) => p.id).sort(); - const sceneGroupIDs = (scene.movies ?? []).map((m) => m.movie.id).sort(); + const sceneGroupIDs = (scene.groups ?? []).map((m) => m.group.id).sort(); if (first) { updateRating = sceneRating ?? undefined; diff --git a/ui/v2.5/src/components/Scenes/SceneCard.tsx b/ui/v2.5/src/components/Scenes/SceneCard.tsx index 694d1bdcc56..e2f773626c0 100644 --- a/ui/v2.5/src/components/Scenes/SceneCard.tsx +++ b/ui/v2.5/src/components/Scenes/SceneCard.tsx @@ -144,23 +144,23 @@ const SceneCardPopovers = PatchComponent( } function maybeRenderGroupPopoverButton() { - if (props.scene.movies.length <= 0) return; + if (props.scene.groups.length <= 0) return; - const popoverContent = props.scene.movies.map((sceneGroup) => ( -
    + const popoverContent = props.scene.groups.map((sceneGroup) => ( +
    {sceneGroup.movie.name
    @@ -174,7 +174,7 @@ const SceneCardPopovers = PatchComponent( > ); @@ -279,7 +279,7 @@ const SceneCardPopovers = PatchComponent( !props.compact && (props.scene.tags.length > 0 || props.scene.performers.length > 0 || - props.scene.movies.length > 0 || + props.scene.groups.length > 0 || props.scene.scene_markers.length > 0 || props.scene?.o_counter || props.scene.galleries.length > 0 || diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx index ee599bee4ba..84754f18456 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx @@ -441,12 +441,12 @@ const ScenePage: React.FC = ({ - {scene.movies.length > 0 ? ( + {scene.groups.length > 0 ? ( diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx index 7d23d82379a..7b853b98cab 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx @@ -104,8 +104,8 @@ export const SceneEditPanel: React.FC = ({ }, [scene.performers]); useEffect(() => { - setGroups(scene.movies?.map((m) => m.movie) ?? []); - }, [scene.movies]); + setGroups(scene.groups?.map((m) => m.group) ?? []); + }, [scene.groups]); useEffect(() => { setStudio(scene.studio ?? null); @@ -125,10 +125,10 @@ export const SceneEditPanel: React.FC = ({ gallery_ids: yup.array(yup.string().required()).defined(), studio_id: yup.string().required().nullable(), performer_ids: yup.array(yup.string().required()).defined(), - movies: yup + groups: yup .array( yup.object({ - movie_id: yup.string().required(), + group_id: yup.string().required(), scene_index: yup.number().integer().nullable().defined(), }) ) @@ -149,8 +149,8 @@ export const SceneEditPanel: React.FC = ({ gallery_ids: (scene.galleries ?? []).map((g) => g.id), studio_id: scene.studio?.id ?? null, performer_ids: (scene.performers ?? []).map((p) => p.id), - movies: (scene.movies ?? []).map((m) => { - return { movie_id: m.movie.id, scene_index: m.scene_index ?? null }; + groups: (scene.groups ?? []).map((m) => { + return { group_id: m.group.id, scene_index: m.scene_index ?? null }; }), tag_ids: (scene.tags ?? []).map((t) => t.id), stash_ids: getStashIDs(scene.stash_ids), @@ -187,16 +187,16 @@ export const SceneEditPanel: React.FC = ({ return sceneImage; }, [formik.values.cover_image, scene.paths?.screenshot]); - const movieEntries = useMemo(() => { - return formik.values.movies + const groupEntries = useMemo(() => { + return formik.values.groups .map((m) => { return { - movie: groups.find((mm) => mm.id === m.movie_id), + group: groups.find((mm) => mm.id === m.group_id), scene_index: m.scene_index, }; }) - .filter((m) => m.movie !== undefined) as IGroupEntry[]; - }, [formik.values.movies, groups]); + .filter((m) => m.group !== undefined) as IGroupEntry[]; + }, [formik.values.groups, groups]); function onSetGalleries(items: Gallery[]) { setGalleries(items); @@ -256,21 +256,21 @@ export const SceneEditPanel: React.FC = ({ function onSetGroups(items: Group[]) { setGroups(items); - const existingMovies = formik.values.movies; + const existingGroups = formik.values.groups; - const newMovies = items.map((m) => { - const existing = existingMovies.find((mm) => mm.movie_id === m.id); + const newGroups = items.map((m) => { + const existing = existingGroups.find((mm) => mm.group_id === m.id); if (existing) { return existing; } return { - movie_id: m.id, + group_id: m.id, scene_index: null, }; }); - formik.setFieldValue("movies", newMovies); + formik.setFieldValue("groups", newGroups); } async function onSave(input: InputValues) { @@ -568,8 +568,8 @@ export const SceneEditPanel: React.FC = ({ } } - if (updatedScene.movies && updatedScene.movies.length > 0) { - const idMovis = updatedScene.movies.filter((p) => { + if (updatedScene.groups && updatedScene.groups.length > 0) { + const idMovis = updatedScene.groups.filter((p) => { return p.stored_id !== undefined && p.stored_id !== null; }); @@ -725,24 +725,24 @@ export const SceneEditPanel: React.FC = ({ return renderField("performer_ids", title, control, fullWidthProps); } - function onSetMovieEntries(input: IGroupEntry[]) { - setGroups(input.map((m) => m.movie)); + function onSetGroupEntries(input: IGroupEntry[]) { + setGroups(input.map((m) => m.group)); - const newMovies = input.map((m) => ({ - movie_id: m.movie.id, + const newGroups = input.map((m) => ({ + group_id: m.group.id, scene_index: m.scene_index, })); - formik.setFieldValue("movies", newMovies); + formik.setFieldValue("groups", newGroups); } - function renderMoviesField() { + function renderGroupsField() { const title = intl.formatMessage({ id: "groups" }); const control = ( - + ); - return renderField("movies", title, control, fullWidthProps); + return renderField("groups", title, control, fullWidthProps); } function renderTagsField() { @@ -820,7 +820,7 @@ export const SceneEditPanel: React.FC = ({ {renderGalleriesField()} {renderStudioField()} {renderPerformersField()} - {renderMoviesField()} + {renderGroupsField()} {renderTagsField()} {renderStashIDsField( diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneMoviePanel.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneMoviePanel.tsx index 50febd07489..78fc7875a52 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneMoviePanel.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneMoviePanel.tsx @@ -9,10 +9,10 @@ interface ISceneGroupPanelProps { export const SceneGroupPanel: React.FC = ( props: ISceneGroupPanelProps ) => { - const cards = props.scene.movies.map((sceneGroup) => ( + const cards = props.scene.groups.map((sceneGroup) => ( )); diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneMovieTable.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneMovieTable.tsx index a851d5330cf..5b85c83522b 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneMovieTable.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneMovieTable.tsx @@ -5,10 +5,10 @@ import { Form, Row, Col } from "react-bootstrap"; import { Group, GroupSelect } from "src/components/Movies/MovieSelect"; import cx from "classnames"; -export type MovieSceneIndexMap = Map; +export type GroupSceneIndexMap = Map; export interface IGroupEntry { - movie: Group; + group: Group; scene_index?: GQL.InputMaybe | undefined; } @@ -22,7 +22,7 @@ export const SceneGroupTable: React.FC = (props) => { const intl = useIntl(); - const groupIDs = useMemo(() => value.map((m) => m.movie.id), [value]); + const groupIDs = useMemo(() => value.map((m) => m.group.id), [value]); const updateFieldChanged = (index: number, sceneIndex: number | null) => { const newValues = value.map((existing, i) => { @@ -52,7 +52,7 @@ export const SceneGroupTable: React.FC = (props) => { if (i === index) { return { ...existing, - movie: group, + group: group, }; } return existing; @@ -71,7 +71,7 @@ export const SceneGroupTable: React.FC = (props) => { const newValues = [ ...value, { - movie: group, + group: group, scene_index: null, }, ]; @@ -83,11 +83,11 @@ export const SceneGroupTable: React.FC = (props) => { return ( <> {value.map((m, i) => ( - + onGroupSet(i, items)} - values={[m.movie!]} + values={[m.group!]} excludeIds={groupIDs} /> diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneScrapeDialog.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneScrapeDialog.tsx index 5cdbd6d9930..59f13b1ec0a 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneScrapeDialog.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneScrapeDialog.tsx @@ -115,20 +115,20 @@ export const SceneScrapeDialog: React.FC = ({ ); const [groups, setGroups] = useState< - ObjectListScrapeResult + ObjectListScrapeResult >( - new ObjectListScrapeResult( + new ObjectListScrapeResult( sortStoredIdObjects( sceneGroups.map((p) => ({ stored_id: p.id, name: p.name, })) ), - sortStoredIdObjects(scraped.movies ?? undefined) + sortStoredIdObjects(scraped.groups ?? undefined) ) ); - const [newGroups, setNewGroups] = useState( - scraped.movies?.filter((t) => !t.stored_id) ?? [] + const [newGroups, setNewGroups] = useState( + scraped.groups?.filter((t) => !t.stored_id) ?? [] ); const { tags, newTags, scrapedTagsRow } = useScrapedTags( @@ -202,7 +202,7 @@ export const SceneScrapeDialog: React.FC = ({ director: director.getNewValue(), studio: newStudioValue, performers: performers.getNewValue(), - movies: groups.getNewValue(), + groups: groups.getNewValue(), tags: tags.getNewValue(), details: details.getNewValue(), image: image.getNewValue(), diff --git a/ui/v2.5/src/components/Scenes/SceneListTable.tsx b/ui/v2.5/src/components/Scenes/SceneListTable.tsx index d6a5fda648e..3e68d27c22f 100644 --- a/ui/v2.5/src/components/Scenes/SceneListTable.tsx +++ b/ui/v2.5/src/components/Scenes/SceneListTable.tsx @@ -126,10 +126,10 @@ export const SceneListTable: React.FC = ( const GroupCell = (scene: GQL.SlimSceneDataFragment) => (
      - {scene.movies.map((sceneGroup) => ( -
    • - - {sceneGroup.movie.name} + {scene.groups.map((sceneGroup) => ( +
    • + + {sceneGroup.group.name}
    • ))} diff --git a/ui/v2.5/src/components/Scenes/SceneMergeDialog.tsx b/ui/v2.5/src/components/Scenes/SceneMergeDialog.tsx index 31499736388..c019abceaca 100644 --- a/ui/v2.5/src/components/Scenes/SceneMergeDialog.tsx +++ b/ui/v2.5/src/components/Scenes/SceneMergeDialog.tsx @@ -100,10 +100,10 @@ const SceneMergeDetails: React.FC = ({ }; } - function groupToStoredID(o: { movie: { id: string; name: string } }) { + function groupToStoredID(o: { group: { id: string; name: string } }) { return { - stored_id: o.movie.id, - name: o.movie.name, + stored_id: o.group.id, + name: o.group.name, }; } @@ -142,10 +142,10 @@ const SceneMergeDetails: React.FC = ({ ); const [groups, setGroups] = useState< - ObjectListScrapeResult + ObjectListScrapeResult >( - new ObjectListScrapeResult( - sortStoredIdObjects(dest.movies.map(groupToStoredID)) + new ObjectListScrapeResult( + sortStoredIdObjects(dest.groups.map(groupToStoredID)) ) ); @@ -253,9 +253,9 @@ const SceneMergeDetails: React.FC = ({ ); setGroups( - new ObjectListScrapeResult( - sortStoredIdObjects(dest.movies.map(groupToStoredID)), - uniqIDStoredIDs(all.map((s) => s.movies.map(groupToStoredID)).flat()) + new ObjectListScrapeResult( + sortStoredIdObjects(dest.groups.map(groupToStoredID)), + uniqIDStoredIDs(all.map((s) => s.groups.map(groupToStoredID)).flat()) ) ); @@ -585,14 +585,14 @@ const SceneMergeDetails: React.FC = ({ gallery_ids: galleries.getNewValue(), studio_id: studio.getNewValue()?.stored_id, performer_ids: performers.getNewValue()?.map((p) => p.stored_id!), - movies: groups.getNewValue()?.map((m) => { - // find the equivalent movie in the original scenes + groups: groups.getNewValue()?.map((m) => { + // find the equivalent group in the original scenes const found = all - .map((s) => s.movies) + .map((s) => s.groups) .flat() - .find((mm) => mm.movie.id === m.stored_id); + .find((mm) => mm.group.id === m.stored_id); return { - movie_id: m.stored_id!, + group_id: m.stored_id!, scene_index: found!.scene_index, }; }), diff --git a/ui/v2.5/src/components/Settings/SettingsScrapingPanel.tsx b/ui/v2.5/src/components/Settings/SettingsScrapingPanel.tsx index caf8147f352..4b38100b53a 100644 --- a/ui/v2.5/src/components/Settings/SettingsScrapingPanel.tsx +++ b/ui/v2.5/src/components/Settings/SettingsScrapingPanel.tsx @@ -3,7 +3,7 @@ import { FormattedMessage, useIntl } from "react-intl"; import { Button } from "react-bootstrap"; import { mutateReloadScrapers, - useListMovieScrapers, + useListGroupScrapers, useListPerformerScrapers, useListSceneScrapers, useListGalleryScrapers, @@ -80,7 +80,7 @@ export const SettingsScrapingPanel: React.FC = () => { const { data: galleryScrapers, loading: loadingGalleries } = useListGalleryScrapers(); const { data: groupScrapers, loading: loadingGroups } = - useListMovieScrapers(); + useListGroupScrapers(); const { general, scraping, loading, error, saveGeneral, saveScraping } = useSettings(); @@ -251,9 +251,9 @@ export const SettingsScrapingPanel: React.FC = () => { {scraper.name} - {renderGroupScrapeTypes(scraper.movie?.supported_scrapes ?? [])} + {renderGroupScrapeTypes(scraper.group?.supported_scrapes ?? [])} - {renderURLs(scraper.movie?.urls ?? [])} + {renderURLs(scraper.group?.urls ?? [])} )); diff --git a/ui/v2.5/src/components/Shared/ScrapeDialog/ScrapedObjectsRow.tsx b/ui/v2.5/src/components/Shared/ScrapeDialog/ScrapedObjectsRow.tsx index 73589ce926d..1ec41386b59 100644 --- a/ui/v2.5/src/components/Shared/ScrapeDialog/ScrapedObjectsRow.tsx +++ b/ui/v2.5/src/components/Shared/ScrapeDialog/ScrapedObjectsRow.tsx @@ -197,7 +197,7 @@ export const ScrapedPerformersRow: React.FC< }; export const ScrapedGroupsRow: React.FC< - IScrapedObjectRowImpl + IScrapedObjectRowImpl > = ({ title, result, onChange, newObjects, onCreateNew }) => { const groupsCopy = useMemo(() => { return ( @@ -209,9 +209,9 @@ export const ScrapedGroupsRow: React.FC< }, [newObjects]); function renderScrapedGroups( - scrapeResult: ScrapeResult, + scrapeResult: ScrapeResult, isNew?: boolean, - onChangeFn?: (value: GQL.ScrapedMovie[]) => void + onChangeFn?: (value: GQL.ScrapedGroup[]) => void ) { const resultValue = isNew ? scrapeResult.newValue @@ -244,7 +244,7 @@ export const ScrapedGroupsRow: React.FC< } return ( - + title={title} result={result} renderObjects={renderScrapedGroups} diff --git a/ui/v2.5/src/components/Shared/ScrapeDialog/createObjects.ts b/ui/v2.5/src/components/Shared/ScrapeDialog/createObjects.ts index 397681483e0..61311e5465e 100644 --- a/ui/v2.5/src/components/Shared/ScrapeDialog/createObjects.ts +++ b/ui/v2.5/src/components/Shared/ScrapeDialog/createObjects.ts @@ -1,7 +1,7 @@ import { useToast } from "src/hooks/Toast"; import * as GQL from "src/core/generated-graphql"; import { - useMovieCreate, + useGroupCreate, usePerformerCreate, useStudioCreate, useTagCreate, @@ -124,12 +124,12 @@ export function useCreateScrapedPerformer( } export function useCreateScrapedGroup( - props: IUseCreateNewObjectProps + props: IUseCreateNewObjectProps ) { const { scrapeResult, setScrapeResult, newObjects, setNewObjects } = props; - const [createGroup] = useMovieCreate(); + const [createGroup] = useGroupCreate(); - async function createNewGroup(toCreate: GQL.ScrapedMovie) { + async function createNewGroup(toCreate: GQL.ScrapedGroup) { const input = scrapedGroupToCreateInput(toCreate); const result = await createGroup({ @@ -137,10 +137,10 @@ export function useCreateScrapedGroup( }); const newValue = [...(scrapeResult.newValue ?? [])]; - if (result.data?.movieCreate) + if (result.data?.groupCreate) newValue.push({ - stored_id: result.data.movieCreate.id, - name: result.data.movieCreate.name, + stored_id: result.data.groupCreate.id, + name: result.data.groupCreate.name, }); // add the new object to the new object value diff --git a/ui/v2.5/src/components/Stats.tsx b/ui/v2.5/src/components/Stats.tsx index 608afc0e2c3..87d022be7b8 100644 --- a/ui/v2.5/src/components/Stats.tsx +++ b/ui/v2.5/src/components/Stats.tsx @@ -50,7 +50,7 @@ export const Stats: React.FC = () => {

    - +

    diff --git a/ui/v2.5/src/components/Studios/StudioCard.tsx b/ui/v2.5/src/components/Studios/StudioCard.tsx index 62604555e2a..e2a7bada267 100644 --- a/ui/v2.5/src/components/Studios/StudioCard.tsx +++ b/ui/v2.5/src/components/Studios/StudioCard.tsx @@ -143,13 +143,13 @@ export const StudioCard: React.FC = ({ } function maybeRenderGroupsPopoverButton() { - if (!studio.movie_count) return; + if (!studio.group_count) return; return ( ); @@ -190,7 +190,7 @@ export const StudioCard: React.FC = ({ studio.scene_count || studio.image_count || studio.gallery_count || - studio.movie_count || + studio.group_count || studio.performer_count || studio.tags.length > 0 ) { diff --git a/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx b/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx index 870db812c55..20291e2a7de 100644 --- a/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx +++ b/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx @@ -109,7 +109,7 @@ const StudioPage: React.FC = ({ studio, tabKey }) => { const performerCount = (showAllCounts ? studio.performer_count_all : studio.performer_count) ?? 0; const groupCount = - (showAllCounts ? studio.movie_count_all : studio.movie_count) ?? 0; + (showAllCounts ? studio.group_count_all : studio.group_count) ?? 0; const populatedDefaultTab = useMemo(() => { let ret: TabKey = "scenes"; diff --git a/ui/v2.5/src/components/Studios/StudioDetails/StudioPerformersPanel.tsx b/ui/v2.5/src/components/Studios/StudioDetails/StudioPerformersPanel.tsx index 0eb146a1b7d..33e98072f0a 100644 --- a/ui/v2.5/src/components/Studios/StudioDetails/StudioPerformersPanel.tsx +++ b/ui/v2.5/src/components/Studios/StudioDetails/StudioPerformersPanel.tsx @@ -25,7 +25,7 @@ export const StudioPerformersPanel: React.FC = ({ scenes: [studioCriterion], images: [studioCriterion], galleries: [studioCriterion], - movies: [studioCriterion], + groups: [studioCriterion], }; const filterHook = useStudioFilterHook(studio); diff --git a/ui/v2.5/src/components/Tags/TagCard.tsx b/ui/v2.5/src/components/Tags/TagCard.tsx index 770e0bb9134..f9514453777 100644 --- a/ui/v2.5/src/components/Tags/TagCard.tsx +++ b/ui/v2.5/src/components/Tags/TagCard.tsx @@ -237,13 +237,13 @@ export const TagCard: React.FC = ({ } function maybeRenderGroupsPopoverButton() { - if (!tag.movie_count) return; + if (!tag.group_count) return; return ( ); diff --git a/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx b/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx index 51a334c11b4..7b794e9bf52 100644 --- a/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx +++ b/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx @@ -106,7 +106,7 @@ const TagPage: React.FC = ({ tag, tabKey }) => { const galleryCount = (showAllCounts ? tag.gallery_count_all : tag.gallery_count) ?? 0; const groupCount = - (showAllCounts ? tag.movie_count_all : tag.movie_count) ?? 0; + (showAllCounts ? tag.group_count_all : tag.group_count) ?? 0; const sceneMarkerCount = (showAllCounts ? tag.scene_marker_count_all : tag.scene_marker_count) ?? 0; const performerCount = diff --git a/ui/v2.5/src/core/StashService.ts b/ui/v2.5/src/core/StashService.ts index d7518b6c77c..949a061c122 100644 --- a/ui/v2.5/src/core/StashService.ts +++ b/ui/v2.5/src/core/StashService.ts @@ -210,43 +210,43 @@ export const queryFindImages = (filter: ListFilterModel) => }, }); -export const useFindMovie = (id: string) => { +export const useFindGroup = (id: string) => { const skip = id === "new" || id === ""; - return GQL.useFindMovieQuery({ variables: { id }, skip }); + return GQL.useFindGroupQuery({ variables: { id }, skip }); }; -export const useFindMovies = (filter?: ListFilterModel) => - GQL.useFindMoviesQuery({ +export const useFindGroups = (filter?: ListFilterModel) => + GQL.useFindGroupsQuery({ skip: filter === undefined, variables: { filter: filter?.makeFindFilter(), - movie_filter: filter?.makeFilter(), + group_filter: filter?.makeFilter(), }, }); -export const queryFindMovies = (filter: ListFilterModel) => - client.query({ - query: GQL.FindMoviesDocument, +export const queryFindGroups = (filter: ListFilterModel) => + client.query({ + query: GQL.FindGroupsDocument, variables: { filter: filter.makeFindFilter(), - movie_filter: filter.makeFilter(), + group_filter: filter.makeFilter(), }, }); -export const queryFindMoviesByIDForSelect = (movieIDs: string[]) => - client.query({ - query: GQL.FindMoviesForSelectDocument, +export const queryFindGroupsByIDForSelect = (groupIDs: string[]) => + client.query({ + query: GQL.FindGroupsForSelectDocument, variables: { - ids: movieIDs, + ids: groupIDs, }, }); -export const queryFindMoviesForSelect = (filter: ListFilterModel) => - client.query({ - query: GQL.FindMoviesForSelectDocument, +export const queryFindGroupsForSelect = (filter: ListFilterModel) => + client.query({ + query: GQL.FindGroupsForSelectDocument, variables: { filter: filter.makeFindFilter(), - movie_filter: filter.makeFilter(), + group_filter: filter.makeFilter(), }, }); @@ -485,13 +485,13 @@ function updateO( } const sceneMutationImpactedTypeFields = { - Movie: ["scenes", "scene_count"], + Group: ["scenes", "scene_count"], Gallery: ["scenes"], Performer: [ "scenes", "scene_count", - "movies", - "movie_count", + "groups", + "group_count", "performer_count", ], Studio: ["scene_count", "performer_count"], @@ -500,7 +500,7 @@ const sceneMutationImpactedTypeFields = { const sceneMutationImpactedQueries = [ GQL.FindScenesDocument, // various filters - GQL.FindMoviesDocument, // is missing scenes + GQL.FindGroupsDocument, // is missing scenes GQL.FindGalleriesDocument, // is missing scenes GQL.FindPerformersDocument, // filter by scene count GQL.FindStudiosDocument, // filter by scene count @@ -1273,98 +1273,98 @@ export const mutateImageSetPrimaryFile = (id: string, fileID: string) => }, }); -const movieMutationImpactedTypeFields = { - Performer: ["movie_count"], - Studio: ["movie_count"], +const groupMutationImpactedTypeFields = { + Performer: ["group_count"], + Studio: ["group_count"], }; -const movieMutationImpactedQueries = [ - GQL.FindMoviesDocument, // various filters +const groupMutationImpactedQueries = [ + GQL.FindGroupsDocument, // various filters ]; -export const useMovieCreate = () => - GQL.useMovieCreateMutation({ +export const useGroupCreate = () => + GQL.useGroupCreateMutation({ update(cache, result) { - const movie = result.data?.movieCreate; - if (!movie) return; + const group = result.data?.groupCreate; + if (!group) return; // update stats - updateStats(cache, "movie_count", 1); + updateStats(cache, "group_count", 1); - evictTypeFields(cache, movieMutationImpactedTypeFields); - evictQueries(cache, movieMutationImpactedQueries); + evictTypeFields(cache, groupMutationImpactedTypeFields); + evictQueries(cache, groupMutationImpactedQueries); }, }); -export const useMovieUpdate = () => - GQL.useMovieUpdateMutation({ +export const useGroupUpdate = () => + GQL.useGroupUpdateMutation({ update(cache, result) { - if (!result.data?.movieUpdate) return; + if (!result.data?.groupUpdate) return; - evictTypeFields(cache, movieMutationImpactedTypeFields); - evictQueries(cache, movieMutationImpactedQueries); + evictTypeFields(cache, groupMutationImpactedTypeFields); + evictQueries(cache, groupMutationImpactedQueries); }, }); -export const useBulkMovieUpdate = (input: GQL.BulkMovieUpdateInput) => - GQL.useBulkMovieUpdateMutation({ +export const useBulkGroupUpdate = (input: GQL.BulkGroupUpdateInput) => + GQL.useBulkGroupUpdateMutation({ variables: { input }, update(cache, result) { - if (!result.data?.bulkMovieUpdate) return; + if (!result.data?.bulkGroupUpdate) return; - evictTypeFields(cache, movieMutationImpactedTypeFields); - evictQueries(cache, movieMutationImpactedQueries); + evictTypeFields(cache, groupMutationImpactedTypeFields); + evictQueries(cache, groupMutationImpactedQueries); }, }); -export const useMovieDestroy = (input: GQL.MovieDestroyInput) => - GQL.useMovieDestroyMutation({ +export const useGroupDestroy = (input: GQL.GroupDestroyInput) => + GQL.useGroupDestroyMutation({ variables: input, update(cache, result) { - if (!result.data?.movieDestroy) return; + if (!result.data?.groupDestroy) return; - const obj = { __typename: "Movie", id: input.id }; - deleteObject(cache, obj, GQL.FindMovieDocument); + const obj = { __typename: "Group", id: input.id }; + deleteObject(cache, obj, GQL.FindGroupDocument); // update stats - updateStats(cache, "movie_count", -1); + updateStats(cache, "group_count", -1); evictTypeFields(cache, { - Scene: ["movies"], - Performer: ["movie_count"], - Studio: ["movie_count"], + Scene: ["groups"], + Performer: ["group_count"], + Studio: ["group_count"], }); evictQueries(cache, [ - ...movieMutationImpactedQueries, - GQL.FindScenesDocument, // filter by movie + ...groupMutationImpactedQueries, + GQL.FindScenesDocument, // filter by group ]); }, }); -export const useMoviesDestroy = (input: GQL.MoviesDestroyMutationVariables) => - GQL.useMoviesDestroyMutation({ +export const useGroupsDestroy = (input: GQL.GroupsDestroyMutationVariables) => + GQL.useGroupsDestroyMutation({ variables: input, update(cache, result) { - if (!result.data?.moviesDestroy) return; + if (!result.data?.groupsDestroy) return; const { ids } = input; for (const id of ids) { - const obj = { __typename: "Movie", id }; - deleteObject(cache, obj, GQL.FindMovieDocument); + const obj = { __typename: "Group", id }; + deleteObject(cache, obj, GQL.FindGroupDocument); } // update stats - updateStats(cache, "movie_count", -ids.length); + updateStats(cache, "group_count", -ids.length); evictTypeFields(cache, { - Scene: ["movies"], - Performer: ["movie_count"], - Studio: ["movie_count"], + Scene: ["groups"], + Performer: ["group_count"], + Studio: ["group_count"], }); evictQueries(cache, [ - ...movieMutationImpactedQueries, - GQL.FindScenesDocument, // filter by movie + ...groupMutationImpactedQueries, + GQL.FindScenesDocument, // filter by group ]); }, }); @@ -1678,7 +1678,7 @@ export const usePerformerDestroy = () => evictQueries(cache, [ ...performerMutationImpactedQueries, GQL.FindPerformersDocument, // appears with - GQL.FindMoviesDocument, // filter by performers + GQL.FindGroupsDocument, // filter by performers GQL.FindSceneMarkersDocument, // filter by performers ]); }, @@ -1718,7 +1718,7 @@ export const usePerformersDestroy = ( evictQueries(cache, [ ...performerMutationImpactedQueries, GQL.FindPerformersDocument, // appears with - GQL.FindMoviesDocument, // filter by performers + GQL.FindGroupsDocument, // filter by performers GQL.FindSceneMarkersDocument, // filter by performers ]); }, @@ -1731,7 +1731,7 @@ const studioMutationImpactedTypeFields = { export const studioMutationImpactedQueries = [ GQL.FindScenesDocument, // filter by studio GQL.FindImagesDocument, // filter by studio - GQL.FindMoviesDocument, // filter by studio + GQL.FindGroupsDocument, // filter by studio GQL.FindGalleriesDocument, // filter by studio GQL.FindPerformersDocument, // filter by studio GQL.FindStudiosDocument, // various filters @@ -2161,11 +2161,11 @@ export const mutateStashBoxBatchStudioTag = ( variables: { input }, }); -export const useListMovieScrapers = () => GQL.useListMovieScrapersQuery(); +export const useListGroupScrapers = () => GQL.useListGroupScrapersQuery(); -export const queryScrapeMovieURL = (url: string) => - client.query({ - query: GQL.ScrapeMovieUrlDocument, +export const queryScrapeGroupURL = (url: string) => + client.query({ + query: GQL.ScrapeGroupUrlDocument, variables: { url }, fetchPolicy: "network-only", }); @@ -2261,7 +2261,7 @@ export const useLoggingSubscribe = () => GQL.useLoggingSubscribeSubscription(); // all scraper-related queries export const scraperMutationImpactedQueries = [ - GQL.ListMovieScrapersDocument, + GQL.ListGroupScrapersDocument, GQL.ListPerformerScrapersDocument, GQL.ListSceneScrapersDocument, GQL.InstalledScraperPackagesDocument, diff --git a/ui/v2.5/src/core/config.ts b/ui/v2.5/src/core/config.ts index e8f8290617f..213d8c11340 100644 --- a/ui/v2.5/src/core/config.ts +++ b/ui/v2.5/src/core/config.ts @@ -143,7 +143,7 @@ export function generateDefaultFrontPageContent(intl: IntlShape) { return [ recentlyReleased(intl, FilterMode.Scenes, "scenes"), recentlyAdded(intl, FilterMode.Studios, "studios"), - recentlyReleased(intl, FilterMode.Movies, "groups"), + recentlyReleased(intl, FilterMode.Groups, "groups"), recentlyAdded(intl, FilterMode.Performers, "performers"), recentlyReleased(intl, FilterMode.Galleries, "galleries"), ]; @@ -156,8 +156,8 @@ export function generatePremadeFrontPageContent(intl: IntlShape) { recentlyReleased(intl, FilterMode.Galleries, "galleries"), recentlyAdded(intl, FilterMode.Galleries, "galleries"), recentlyAdded(intl, FilterMode.Images, "images"), - recentlyReleased(intl, FilterMode.Movies, "groups"), - recentlyAdded(intl, FilterMode.Movies, "groups"), + recentlyReleased(intl, FilterMode.Groups, "groups"), + recentlyAdded(intl, FilterMode.Groups, "groups"), recentlyAdded(intl, FilterMode.Studios, "studios"), recentlyAdded(intl, FilterMode.Performers, "performers"), ]; diff --git a/ui/v2.5/src/core/createClient.ts b/ui/v2.5/src/core/createClient.ts index e5f502cca9e..4fbcd918345 100644 --- a/ui/v2.5/src/core/createClient.ts +++ b/ui/v2.5/src/core/createClient.ts @@ -46,8 +46,8 @@ const typePolicies: TypePolicies = { findStudio: { read: readReference("Studio"), }, - findMovie: { - read: readReference("Movie"), + findGroup: { + read: readReference("Group"), }, findGallery: { read: readReference("Gallery"), @@ -80,7 +80,7 @@ const typePolicies: TypePolicies = { }, }, }, - Movie: { + Group: { fields: { studio: { read: readDanglingNull, diff --git a/ui/v2.5/src/core/movies.ts b/ui/v2.5/src/core/movies.ts index 1183785f16d..8a741b750c2 100644 --- a/ui/v2.5/src/core/movies.ts +++ b/ui/v2.5/src/core/movies.ts @@ -1,10 +1,10 @@ import * as GQL from "src/core/generated-graphql"; import TextUtils from "src/utils/text"; -export const scrapedGroupToCreateInput = (toCreate: GQL.ScrapedMovie) => { - const input: GQL.MovieCreateInput = { +export const scrapedGroupToCreateInput = (toCreate: GQL.ScrapedGroup) => { + const input: GQL.GroupCreateInput = { name: toCreate.name ?? "", - url: toCreate.url, + urls: toCreate.urls, aliases: toCreate.aliases, front_image: toCreate.front_image, back_image: toCreate.back_image, diff --git a/ui/v2.5/src/models/list-filter/criteria/is-missing.ts b/ui/v2.5/src/models/list-filter/criteria/is-missing.ts index 15272d7563f..99b53bc7d08 100644 --- a/ui/v2.5/src/models/list-filter/criteria/is-missing.ts +++ b/ui/v2.5/src/models/list-filter/criteria/is-missing.ts @@ -87,7 +87,7 @@ export const StudioIsMissingCriterionOption = new IsMissingCriterionOption( ["image", "stash_id", "details"] ); -export const MovieIsMissingCriterionOption = new IsMissingCriterionOption( +export const GroupIsMissingCriterionOption = new IsMissingCriterionOption( "isMissing", "is_missing", ["front_image", "back_image", "scenes"] diff --git a/ui/v2.5/src/models/list-filter/criteria/movies.ts b/ui/v2.5/src/models/list-filter/criteria/movies.ts index 391fdb1d27c..1daffeab9e4 100644 --- a/ui/v2.5/src/models/list-filter/criteria/movies.ts +++ b/ui/v2.5/src/models/list-filter/criteria/movies.ts @@ -2,16 +2,16 @@ import { ILabeledIdCriterion, ILabeledIdCriterionOption } from "./criterion"; const inputType = "groups"; -export const MoviesCriterionOption = new ILabeledIdCriterionOption( +export const GroupsCriterionOption = new ILabeledIdCriterionOption( + "groups", "groups", - "movies", false, inputType, - () => new MoviesCriterion() + () => new GroupsCriterion() ); -export class MoviesCriterion extends ILabeledIdCriterion { +export class GroupsCriterion extends ILabeledIdCriterion { constructor() { - super(MoviesCriterionOption); + super(GroupsCriterionOption); } } diff --git a/ui/v2.5/src/models/list-filter/factory.ts b/ui/v2.5/src/models/list-filter/factory.ts index 36b9d66dbc7..1677358e366 100644 --- a/ui/v2.5/src/models/list-filter/factory.ts +++ b/ui/v2.5/src/models/list-filter/factory.ts @@ -2,7 +2,7 @@ import { FilterMode } from "src/core/generated-graphql"; import { ListFilterOptions } from "./filter-options"; import { GalleryListFilterOptions } from "./galleries"; import { ImageListFilterOptions } from "./images"; -import { MovieListFilterOptions } from "./movies"; +import { GroupListFilterOptions } from "./movies"; import { PerformerListFilterOptions } from "./performers"; import { SceneMarkerListFilterOptions } from "./scene-markers"; import { SceneListFilterOptions } from "./scenes"; @@ -22,7 +22,8 @@ export function getFilterOptions(mode: FilterMode): ListFilterOptions { case FilterMode.SceneMarkers: return SceneMarkerListFilterOptions; case FilterMode.Movies: - return MovieListFilterOptions; + case FilterMode.Groups: + return GroupListFilterOptions; case FilterMode.Tags: return TagListFilterOptions; case FilterMode.Images: diff --git a/ui/v2.5/src/models/list-filter/movies.ts b/ui/v2.5/src/models/list-filter/movies.ts index 7e89d59393c..43439a62f33 100644 --- a/ui/v2.5/src/models/list-filter/movies.ts +++ b/ui/v2.5/src/models/list-filter/movies.ts @@ -5,7 +5,7 @@ import { createDurationCriterionOption, createMandatoryNumberCriterionOption, } from "./criteria/criterion"; -import { MovieIsMissingCriterionOption } from "./criteria/is-missing"; +import { GroupIsMissingCriterionOption } from "./criteria/is-missing"; import { StudiosCriterionOption } from "./criteria/studios"; import { PerformersCriterionOption } from "./criteria/performers"; import { ListFilterOptions } from "./filter-options"; @@ -35,7 +35,7 @@ const displayModeOptions = [DisplayMode.Grid]; const criterionOptions = [ // StudioTagsCriterionOption, StudiosCriterionOption, - MovieIsMissingCriterionOption, + GroupIsMissingCriterionOption, createStringCriterionOption("url"), createStringCriterionOption("name"), createStringCriterionOption("director"), @@ -50,7 +50,7 @@ const criterionOptions = [ createMandatoryTimestampCriterionOption("updated_at"), ]; -export const MovieListFilterOptions = new ListFilterOptions( +export const GroupListFilterOptions = new ListFilterOptions( defaultSortBy, sortByOptions, displayModeOptions, diff --git a/ui/v2.5/src/models/list-filter/scenes.ts b/ui/v2.5/src/models/list-filter/scenes.ts index 9a8e3f5e1db..92b9eadfa20 100644 --- a/ui/v2.5/src/models/list-filter/scenes.ts +++ b/ui/v2.5/src/models/list-filter/scenes.ts @@ -8,7 +8,7 @@ import { } from "./criteria/criterion"; import { HasMarkersCriterionOption } from "./criteria/has-markers"; import { SceneIsMissingCriterionOption } from "./criteria/is-missing"; -import { MoviesCriterionOption } from "./criteria/movies"; +import { GroupsCriterionOption } from "./criteria/movies"; import { GalleriesCriterionOption } from "./criteria/galleries"; import { OrganizedCriterionOption } from "./criteria/organized"; import { PerformersCriterionOption } from "./criteria/performers"; @@ -105,7 +105,7 @@ const criterionOptions = [ PerformerFavoriteCriterionOption, // StudioTagsCriterionOption, StudiosCriterionOption, - MoviesCriterionOption, + GroupsCriterionOption, GalleriesCriterionOption, createStringCriterionOption("url"), StashIDCriterionOption, diff --git a/ui/v2.5/src/models/list-filter/tags.ts b/ui/v2.5/src/models/list-filter/tags.ts index dc84a9676bd..9aa4c80e6a8 100644 --- a/ui/v2.5/src/models/list-filter/tags.ts +++ b/ui/v2.5/src/models/list-filter/tags.ts @@ -62,7 +62,7 @@ const criterionOptions = [ createMandatoryNumberCriterionOption("gallery_count"), createMandatoryNumberCriterionOption("performer_count"), createMandatoryNumberCriterionOption("studio_count"), - createMandatoryNumberCriterionOption("movie_count", "group_count"), + createMandatoryNumberCriterionOption("group_count"), createMandatoryNumberCriterionOption("marker_count"), ParentTagsCriterionOption, new MandatoryNumberCriterionOption("parent_tag_count", "parent_count"), diff --git a/ui/v2.5/src/models/list-filter/types.ts b/ui/v2.5/src/models/list-filter/types.ts index 5a63179ad4b..9884c63c81c 100644 --- a/ui/v2.5/src/models/list-filter/types.ts +++ b/ui/v2.5/src/models/list-filter/types.ts @@ -147,7 +147,7 @@ export type CriterionType = | "performers" | "studios" | "scenes" - | "movies" + | "groups" | "galleries" | "birth_year" | "age" @@ -174,7 +174,7 @@ export type CriterionType = | "gallery_count" | "performer_count" | "studio_count" - | "movie_count" + | "group_count" | "death_year" | "url" | "interactive" diff --git a/ui/v2.5/src/pluginApi.d.ts b/ui/v2.5/src/pluginApi.d.ts index 9d3aad40f90..bbdc9fcff97 100644 --- a/ui/v2.5/src/pluginApi.d.ts +++ b/ui/v2.5/src/pluginApi.d.ts @@ -11,7 +11,7 @@ declare namespace PluginApi { const BlobsStorageType: { [key: string]: any }; const BulkGalleryUpdateDocument: { [key: string]: any }; const BulkImageUpdateDocument: { [key: string]: any }; - const BulkMovieUpdateDocument: { [key: string]: any }; + const BulkGroupUpdateDocument: { [key: string]: any }; const BulkPerformerUpdateDocument: { [key: string]: any }; const BulkSceneUpdateDocument: { [key: string]: any }; const BulkUpdateIdMode: { [key: string]: any }; @@ -46,9 +46,9 @@ declare namespace PluginApi { const FindImageDocument: { [key: string]: any }; const FindImagesDocument: { [key: string]: any }; const FindJobDocument: { [key: string]: any }; - const FindMovieDocument: { [key: string]: any }; - const FindMoviesDocument: { [key: string]: any }; - const FindMoviesForSelectDocument: { [key: string]: any }; + const FindGroupDocument: { [key: string]: any }; + const FindGroupsDocument: { [key: string]: any }; + const FindGroupsForSelectDocument: { [key: string]: any }; const FindPerformerDocument: { [key: string]: any }; const FindPerformersDocument: { [key: string]: any }; const FindPerformersForSelectDocument: { [key: string]: any }; @@ -109,7 +109,7 @@ declare namespace PluginApi { const JobsSubscribeDocument: { [key: string]: any }; const LatestVersionDocument: { [key: string]: any }; const ListGalleryScrapersDocument: { [key: string]: any }; - const ListMovieScrapersDocument: { [key: string]: any }; + const ListGroupScrapersDocument: { [key: string]: any }; const ListPerformerScrapersDocument: { [key: string]: any }; const ListSceneScrapersDocument: { [key: string]: any }; const LogEntryDataFragmentDoc: { [key: string]: any }; @@ -129,11 +129,11 @@ declare namespace PluginApi { const MigrateDocument: { [key: string]: any }; const MigrateHashNamingDocument: { [key: string]: any }; const MigrateSceneScreenshotsDocument: { [key: string]: any }; - const MovieCreateDocument: { [key: string]: any }; - const MovieDataFragmentDoc: { [key: string]: any }; - const MovieDestroyDocument: { [key: string]: any }; - const MovieUpdateDocument: { [key: string]: any }; - const MoviesDestroyDocument: { [key: string]: any }; + const GroupCreateDocument: { [key: string]: any }; + const GroupDataFragmentDoc: { [key: string]: any }; + const GroupDestroyDocument: { [key: string]: any }; + const GroupUpdateDocument: { [key: string]: any }; + const GroupsDestroyDocument: { [key: string]: any }; const OptimiseDatabaseDocument: { [key: string]: any }; const OrientationEnum: { [key: string]: any }; const PackageDataFragmentDoc: { [key: string]: any }; @@ -179,7 +179,7 @@ declare namespace PluginApi { const ScenesUpdateDocument: { [key: string]: any }; const ScrapeContentType: { [key: string]: any }; const ScrapeGalleryUrlDocument: { [key: string]: any }; - const ScrapeMovieUrlDocument: { [key: string]: any }; + const ScrapeGroupUrlDocument: { [key: string]: any }; const ScrapeMultiPerformersDocument: { [key: string]: any }; const ScrapeMultiScenesDocument: { [key: string]: any }; const ScrapePerformerUrlDocument: { [key: string]: any }; @@ -190,11 +190,11 @@ declare namespace PluginApi { const ScrapeSingleStudioDocument: { [key: string]: any }; const ScrapeType: { [key: string]: any }; const ScrapedGalleryDataFragmentDoc: { [key: string]: any }; - const ScrapedMovieDataFragmentDoc: { [key: string]: any }; - const ScrapedMovieStudioDataFragmentDoc: { [key: string]: any }; + const ScrapedGroupDataFragmentDoc: { [key: string]: any }; + const ScrapedGroupStudioDataFragmentDoc: { [key: string]: any }; const ScrapedPerformerDataFragmentDoc: { [key: string]: any }; const ScrapedSceneDataFragmentDoc: { [key: string]: any }; - const ScrapedSceneMovieDataFragmentDoc: { [key: string]: any }; + const ScrapedSceneGroupDataFragmentDoc: { [key: string]: any }; const ScrapedScenePerformerDataFragmentDoc: { [key: string]: any }; const ScrapedSceneStudioDataFragmentDoc: { [key: string]: any }; const ScrapedSceneTagDataFragmentDoc: { [key: string]: any }; @@ -203,7 +203,7 @@ declare namespace PluginApi { const ScrapedStudioDataFragmentDoc: { [key: string]: any }; const ScraperSourceDataFragmentDoc: { [key: string]: any }; const SelectGalleryDataFragmentDoc: { [key: string]: any }; - const SelectMovieDataFragmentDoc: { [key: string]: any }; + const SelectGroupDataFragmentDoc: { [key: string]: any }; const SelectPerformerDataFragmentDoc: { [key: string]: any }; const SelectStudioDataFragmentDoc: { [key: string]: any }; const SelectTagDataFragmentDoc: { [key: string]: any }; @@ -211,7 +211,7 @@ declare namespace PluginApi { const SetupDocument: { [key: string]: any }; const SlimGalleryDataFragmentDoc: { [key: string]: any }; const SlimImageDataFragmentDoc: { [key: string]: any }; - const SlimMovieDataFragmentDoc: { [key: string]: any }; + const SlimGroupDataFragmentDoc: { [key: string]: any }; const SlimPerformerDataFragmentDoc: { [key: string]: any }; const SlimSceneDataFragmentDoc: { [key: string]: any }; const SlimStudioDataFragmentDoc: { [key: string]: any }; @@ -259,9 +259,9 @@ declare namespace PluginApi { function refetchFindImageQuery(...args: any[]): any; function refetchFindImagesQuery(...args: any[]): any; function refetchFindJobQuery(...args: any[]): any; - function refetchFindMovieQuery(...args: any[]): any; - function refetchFindMoviesForSelectQuery(...args: any[]): any; - function refetchFindMoviesQuery(...args: any[]): any; + function refetchFindGroupQuery(...args: any[]): any; + function refetchFindGroupsForSelectQuery(...args: any[]): any; + function refetchFindGroupsQuery(...args: any[]): any; function refetchFindPerformerQuery(...args: any[]): any; function refetchFindPerformersForSelectQuery(...args: any[]): any; function refetchFindPerformersQuery(...args: any[]): any; @@ -285,7 +285,7 @@ declare namespace PluginApi { function refetchJobQueueQuery(...args: any[]): any; function refetchLatestVersionQuery(...args: any[]): any; function refetchListGalleryScrapersQuery(...args: any[]): any; - function refetchListMovieScrapersQuery(...args: any[]): any; + function refetchListGroupScrapersQuery(...args: any[]): any; function refetchListPerformerScrapersQuery(...args: any[]): any; function refetchListSceneScrapersQuery(...args: any[]): any; function refetchLogsQuery(...args: any[]): any; @@ -297,7 +297,7 @@ declare namespace PluginApi { function refetchSceneStreamsQuery(...args: any[]): any; function refetchSceneWallQuery(...args: any[]): any; function refetchScrapeGalleryUrlQuery(...args: any[]): any; - function refetchScrapeMovieUrlQuery(...args: any[]): any; + function refetchScrapeGroupUrlQuery(...args: any[]): any; function refetchScrapeMultiPerformersQuery(...args: any[]): any; function refetchScrapeMultiScenesQuery(...args: any[]): any; function refetchScrapePerformerUrlQuery(...args: any[]): any; @@ -322,7 +322,7 @@ declare namespace PluginApi { function useBackupDatabaseMutation(...args: any[]): any; function useBulkGalleryUpdateMutation(...args: any[]): any; function useBulkImageUpdateMutation(...args: any[]): any; - function useBulkMovieUpdateMutation(...args: any[]): any; + function useBulkGroupUpdateMutation(...args: any[]): any; function useBulkPerformerUpdateMutation(...args: any[]): any; function useBulkSceneUpdateMutation(...args: any[]): any; function useConfigurationLazyQuery(...args: any[]): any; @@ -367,15 +367,15 @@ declare namespace PluginApi { function useFindJobLazyQuery(...args: any[]): any; function useFindJobQuery(...args: any[]): any; function useFindJobSuspenseQuery(...args: any[]): any; - function useFindMovieLazyQuery(...args: any[]): any; - function useFindMovieQuery(...args: any[]): any; - function useFindMovieSuspenseQuery(...args: any[]): any; - function useFindMoviesForSelectLazyQuery(...args: any[]): any; - function useFindMoviesForSelectQuery(...args: any[]): any; - function useFindMoviesForSelectSuspenseQuery(...args: any[]): any; - function useFindMoviesLazyQuery(...args: any[]): any; - function useFindMoviesQuery(...args: any[]): any; - function useFindMoviesSuspenseQuery(...args: any[]): any; + function useFindGroupLazyQuery(...args: any[]): any; + function useFindGroupQuery(...args: any[]): any; + function useFindGroupSuspenseQuery(...args: any[]): any; + function useFindGroupsForSelectLazyQuery(...args: any[]): any; + function useFindGroupsForSelectQuery(...args: any[]): any; + function useFindGroupsForSelectSuspenseQuery(...args: any[]): any; + function useFindGroupsLazyQuery(...args: any[]): any; + function useFindGroupsQuery(...args: any[]): any; + function useFindGroupsSuspenseQuery(...args: any[]): any; function useFindPerformerLazyQuery(...args: any[]): any; function useFindPerformerQuery(...args: any[]): any; function useFindPerformerSuspenseQuery(...args: any[]): any; @@ -466,9 +466,9 @@ declare namespace PluginApi { function useListGalleryScrapersLazyQuery(...args: any[]): any; function useListGalleryScrapersQuery(...args: any[]): any; function useListGalleryScrapersSuspenseQuery(...args: any[]): any; - function useListMovieScrapersLazyQuery(...args: any[]): any; - function useListMovieScrapersQuery(...args: any[]): any; - function useListMovieScrapersSuspenseQuery(...args: any[]): any; + function useListGroupScrapersLazyQuery(...args: any[]): any; + function useListGroupScrapersQuery(...args: any[]): any; + function useListGroupScrapersSuspenseQuery(...args: any[]): any; function useListPerformerScrapersLazyQuery(...args: any[]): any; function useListPerformerScrapersQuery(...args: any[]): any; function useListPerformerScrapersSuspenseQuery(...args: any[]): any; @@ -496,10 +496,10 @@ declare namespace PluginApi { function useMigrateHashNamingMutation(...args: any[]): any; function useMigrateMutation(...args: any[]): any; function useMigrateSceneScreenshotsMutation(...args: any[]): any; - function useMovieCreateMutation(...args: any[]): any; - function useMovieDestroyMutation(...args: any[]): any; - function useMovieUpdateMutation(...args: any[]): any; - function useMoviesDestroyMutation(...args: any[]): any; + function useGroupCreateMutation(...args: any[]): any; + function useGroupDestroyMutation(...args: any[]): any; + function useGroupUpdateMutation(...args: any[]): any; + function useGroupsDestroyMutation(...args: any[]): any; function useOptimiseDatabaseMutation(...args: any[]): any; function useParseSceneFilenamesLazyQuery(...args: any[]): any; function useParseSceneFilenamesQuery(...args: any[]): any; @@ -546,9 +546,9 @@ declare namespace PluginApi { function useScrapeGalleryUrlLazyQuery(...args: any[]): any; function useScrapeGalleryUrlQuery(...args: any[]): any; function useScrapeGalleryUrlSuspenseQuery(...args: any[]): any; - function useScrapeMovieUrlLazyQuery(...args: any[]): any; - function useScrapeMovieUrlQuery(...args: any[]): any; - function useScrapeMovieUrlSuspenseQuery(...args: any[]): any; + function useScrapeGroupUrlLazyQuery(...args: any[]): any; + function useScrapeGroupUrlQuery(...args: any[]): any; + function useScrapeGroupUrlSuspenseQuery(...args: any[]): any; function useScrapeMultiPerformersLazyQuery(...args: any[]): any; function useScrapeMultiPerformersQuery(...args: any[]): any; function useScrapeMultiPerformersSuspenseQuery(...args: any[]): any; @@ -664,8 +664,8 @@ declare namespace PluginApi { StudioIDSelect: React.FC; GallerySelect: React.FC; GalleryIDSelect: React.FC; - MovieSelect: React.FC; - MovieIDSelect: React.FC; + GroupSelect: React.FC; + GroupIDSelect: React.FC; SceneSelect: React.FC; SceneIDSelect: React.FC; DateInput: React.FC; @@ -767,9 +767,9 @@ declare namespace PluginApi { function queryFindGalleriesByIDForSelect(...args: any[]): any; function queryFindGalleriesForSelect(...args: any[]): any; function queryFindImages(...args: any[]): any; - function queryFindMovies(...args: any[]): any; - function queryFindMoviesByIDForSelect(...args: any[]): any; - function queryFindMoviesForSelect(...args: any[]): any; + function queryFindGroups(...args: any[]): any; + function queryFindGroupsByIDForSelect(...args: any[]): any; + function queryFindGroupsForSelect(...args: any[]): any; function queryFindPerformer(...args: any[]): any; function queryFindPerformers(...args: any[]): any; function queryFindPerformersByIDForSelect(...args: any[]): any; @@ -789,7 +789,7 @@ declare namespace PluginApi { function querySceneByPathRegex(...args: any[]): any; function queryScrapeGallery(...args: any[]): any; function queryScrapeGalleryURL(...args: any[]): any; - function queryScrapeMovieURL(...args: any[]): any; + function queryScrapeGroupURL(...args: any[]): any; function queryScrapePerformer(...args: any[]): any; function queryScrapePerformerURL(...args: any[]): any; function queryScrapeScene(...args: any[]): any; @@ -802,7 +802,7 @@ declare namespace PluginApi { function useAddTempDLNAIP(...args: any[]): any; function useBulkGalleryUpdate(...args: any[]): any; function useBulkImageUpdate(...args: any[]): any; - function useBulkMovieUpdate(...args: any[]): any; + function useBulkGroupUpdate(...args: any[]): any; function useBulkPerformerUpdate(...args: any[]): any; function useBulkSceneUpdate(...args: any[]): any; function useConfiguration(...args: any[]): any; @@ -822,8 +822,8 @@ declare namespace PluginApi { function useFindGallery(...args: any[]): any; function useFindImage(...args: any[]): any; function useFindImages(...args: any[]): any; - function useFindMovie(...args: any[]): any; - function useFindMovies(...args: any[]): any; + function useFindGroup(...args: any[]): any; + function useFindGroups(...args: any[]): any; function useFindPerformer(...args: any[]): any; function useFindPerformers(...args: any[]): any; function useFindSavedFilter(...args: any[]): any; @@ -853,16 +853,16 @@ declare namespace PluginApi { function useJobsSubscribe(...args: any[]): any; function useLatestVersion(...args: any[]): any; function useListGalleryScrapers(...args: any[]): any; - function useListMovieScrapers(...args: any[]): any; + function useListGroupScrapers(...args: any[]): any; function useListPerformerScrapers(...args: any[]): any; function useListSceneScrapers(...args: any[]): any; function useLoggingSubscribe(...args: any[]): any; function useLogs(...args: any[]): any; function useMarkerStrings(...args: any[]): any; - function useMovieCreate(...args: any[]): any; - function useMovieDestroy(...args: any[]): any; - function useMovieUpdate(...args: any[]): any; - function useMoviesDestroy(...args: any[]): any; + function useGroupCreate(...args: any[]): any; + function useGroupDestroy(...args: any[]): any; + function useGroupUpdate(...args: any[]): any; + function useGroupsDestroy(...args: any[]): any; function usePerformerCreate(...args: any[]): any; function usePerformerDestroy(...args: any[]): any; function usePerformerUpdate(...args: any[]): any; diff --git a/ui/v2.5/src/utils/bulkUpdate.ts b/ui/v2.5/src/utils/bulkUpdate.ts index 9856c933690..70a5b38fca6 100644 --- a/ui/v2.5/src/utils/bulkUpdate.ts +++ b/ui/v2.5/src/utils/bulkUpdate.ts @@ -82,12 +82,12 @@ export function getAggregateTagIds(state: { tags: IHasID[] }[]) { } interface IGroup { - movie: IHasID; + group: IHasID; } -export function getAggregateGroupIds(state: { movies: IGroup[] }[]) { +export function getAggregateGroupIds(state: { groups: IGroup[] }[]) { const sortedLists = state.map((o) => - o.movies.map((oo) => oo.movie.id).sort() + o.groups.map((oo) => oo.group.id).sort() ); return getAggregateIds(sortedLists); } diff --git a/ui/v2.5/src/utils/navigation.ts b/ui/v2.5/src/utils/navigation.ts index c246d699a50..d30da8d7ac5 100644 --- a/ui/v2.5/src/utils/navigation.ts +++ b/ui/v2.5/src/utils/navigation.ts @@ -12,7 +12,7 @@ import { TagsCriterionOption, } from "src/models/list-filter/criteria/tags"; import { ListFilterModel } from "src/models/list-filter/filter"; -import { MoviesCriterion } from "src/models/list-filter/criteria/movies"; +import { GroupsCriterion } from "src/models/list-filter/criteria/movies"; import { Criterion, CriterionOption, @@ -109,7 +109,7 @@ const makePerformerGroupsUrl = ( extraCriteria?: Criterion[] ) => { if (!performer.id) return "#"; - const filter = new ListFilterModel(GQL.FilterMode.Movies, undefined); + const filter = new ListFilterModel(GQL.FilterMode.Groups, undefined); const criterion = new PerformersCriterion(); criterion.value.items = [ { id: performer.id, label: performer.name || `Performer ${performer.id}` }, @@ -176,7 +176,7 @@ const makeStudioGalleriesUrl = (studio: Partial) => { const makeStudioGroupsUrl = (studio: Partial) => { if (!studio.id) return "#"; - const filter = new ListFilterModel(GQL.FilterMode.Movies, undefined); + const filter = new ListFilterModel(GQL.FilterMode.Groups, undefined); const criterion = new StudiosCriterion(); criterion.value = { items: [{ id: studio.id, label: studio.name || `Studio ${studio.id}` }], @@ -211,10 +211,10 @@ const makeChildStudiosUrl = (studio: Partial) => { return `/studios?${filter.makeQueryParameters()}`; }; -const makeGroupScenesUrl = (group: Partial) => { +const makeGroupScenesUrl = (group: Partial) => { if (!group.id) return "#"; const filter = new ListFilterModel(GQL.FilterMode.Scenes, undefined); - const criterion = new MoviesCriterion(); + const criterion = new GroupsCriterion(); criterion.value = [ { id: group.id, label: group.name || `Group ${group.id}` }, ]; @@ -299,7 +299,7 @@ const makeTagImagesUrl = (tag: INamedObject) => { }; const makeTagGroupsUrl = (tag: INamedObject) => { - return `/groups?${makeTagFilter(GQL.FilterMode.Movies, tag)}`; + return `/groups?${makeTagFilter(GQL.FilterMode.Groups, tag)}`; }; type SceneMarkerDataFragment = Pick & { @@ -351,7 +351,7 @@ const makeDirectorScenesUrl = (director: string) => { const makeDirectorGroupsUrl = (director: string) => { if (director.length == 0) return "#"; - const filter = new ListFilterModel(GQL.FilterMode.Movies, undefined); + const filter = new ListFilterModel(GQL.FilterMode.Groups, undefined); filter.criteria.push( stringEqualsCriterion(createStringCriterionOption("director"), director) ); From a3e72b61ee423cd8084ee4442953b6af047a9235 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Wed, 3 Jul 2024 14:17:02 +1000 Subject: [PATCH 045/103] Rename movie components to group (#5038) --- ui/v2.5/src/App.tsx | 2 +- ui/v2.5/src/components/FrontPage/Control.tsx | 2 +- .../EditMoviesDialog.tsx => Groups/EditGroupsDialog.tsx} | 0 .../{Movies/MovieCard.tsx => Groups/GroupCard.tsx} | 0 .../{Movies/MovieCardGrid.tsx => Groups/GroupCardGrid.tsx} | 2 +- .../Movie.tsx => Groups/GroupDetails/Group.tsx} | 6 +++--- .../MovieCreate.tsx => Groups/GroupDetails/GroupCreate.tsx} | 2 +- .../GroupDetails/GroupDetailsPanel.tsx} | 0 .../GroupDetails/GroupEditPanel.tsx} | 2 +- .../GroupDetails/GroupScenesPanel.tsx} | 2 +- .../GroupDetails/GroupScrapeDialog.tsx} | 0 .../{Movies/MovieList.tsx => Groups/GroupList.tsx} | 4 ++-- .../GroupRecommendationRow.tsx} | 2 +- .../{Movies/MovieSelect.tsx => Groups/GroupSelect.tsx} | 0 .../src/components/{Movies/Movies.tsx => Groups/Groups.tsx} | 6 +++--- ui/v2.5/src/components/{Movies => Groups}/styles.scss | 0 .../components/Performers/PerformerDetails/Performer.tsx | 2 +- .../{PerformerMoviesPanel.tsx => PerformerGroupsPanel.tsx} | 2 +- ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx | 2 +- .../src/components/Scenes/SceneDetails/SceneEditPanel.tsx | 4 ++-- .../{SceneMoviePanel.tsx => SceneGroupPanel.tsx} | 2 +- .../{SceneMovieTable.tsx => SceneGroupTable.tsx} | 2 +- .../components/Scenes/SceneDetails/SceneScrapeDialog.tsx | 2 +- .../components/Shared/ScrapeDialog/ScrapedObjectsRow.tsx | 2 +- ui/v2.5/src/components/Shared/ScrapeDialog/createObjects.ts | 2 +- ui/v2.5/src/components/Shared/Select.tsx | 2 +- ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx | 2 +- .../{StudioMoviesPanel.tsx => StudioGroupsPanel.tsx} | 2 +- ui/v2.5/src/components/Tags/TagDetails/Tag.tsx | 2 +- .../TagDetails/{TagMoviesPanel.tsx => TagGroupsPanel.tsx} | 2 +- ui/v2.5/src/core/{movies.ts => groups.ts} | 0 ui/v2.5/src/index.scss | 2 +- .../models/list-filter/criteria/{movies.ts => groups.ts} | 0 ui/v2.5/src/models/list-filter/factory.ts | 2 +- ui/v2.5/src/models/list-filter/{movies.ts => groups.ts} | 0 ui/v2.5/src/models/list-filter/scenes.ts | 4 ++-- ui/v2.5/src/models/list-filter/tags.ts | 2 +- ui/v2.5/src/utils/navigation.ts | 2 +- 38 files changed, 36 insertions(+), 36 deletions(-) rename ui/v2.5/src/components/{Movies/EditMoviesDialog.tsx => Groups/EditGroupsDialog.tsx} (100%) rename ui/v2.5/src/components/{Movies/MovieCard.tsx => Groups/GroupCard.tsx} (100%) rename ui/v2.5/src/components/{Movies/MovieCardGrid.tsx => Groups/GroupCardGrid.tsx} (95%) rename ui/v2.5/src/components/{Movies/MovieDetails/Movie.tsx => Groups/GroupDetails/Group.tsx} (98%) rename ui/v2.5/src/components/{Movies/MovieDetails/MovieCreate.tsx => Groups/GroupDetails/GroupCreate.tsx} (98%) rename ui/v2.5/src/components/{Movies/MovieDetails/MovieDetailsPanel.tsx => Groups/GroupDetails/GroupDetailsPanel.tsx} (100%) rename ui/v2.5/src/components/{Movies/MovieDetails/MovieEditPanel.tsx => Groups/GroupDetails/GroupEditPanel.tsx} (99%) rename ui/v2.5/src/components/{Movies/MovieDetails/MovieScenesPanel.tsx => Groups/GroupDetails/GroupScenesPanel.tsx} (94%) rename ui/v2.5/src/components/{Movies/MovieDetails/MovieScrapeDialog.tsx => Groups/GroupDetails/GroupScrapeDialog.tsx} (100%) rename ui/v2.5/src/components/{Movies/MovieList.tsx => Groups/GroupList.tsx} (97%) rename ui/v2.5/src/components/{Movies/MovieRecommendationRow.tsx => Groups/GroupRecommendationRow.tsx} (97%) rename ui/v2.5/src/components/{Movies/MovieSelect.tsx => Groups/GroupSelect.tsx} (100%) rename ui/v2.5/src/components/{Movies/Movies.tsx => Groups/Groups.tsx} (85%) rename ui/v2.5/src/components/{Movies => Groups}/styles.scss (100%) rename ui/v2.5/src/components/Performers/PerformerDetails/{PerformerMoviesPanel.tsx => PerformerGroupsPanel.tsx} (90%) rename ui/v2.5/src/components/Scenes/SceneDetails/{SceneMoviePanel.tsx => SceneGroupPanel.tsx} (90%) rename ui/v2.5/src/components/Scenes/SceneDetails/{SceneMovieTable.tsx => SceneGroupTable.tsx} (98%) rename ui/v2.5/src/components/Studios/StudioDetails/{StudioMoviesPanel.tsx => StudioGroupsPanel.tsx} (89%) rename ui/v2.5/src/components/Tags/TagDetails/{TagMoviesPanel.tsx => TagGroupsPanel.tsx} (85%) rename ui/v2.5/src/core/{movies.ts => groups.ts} (100%) rename ui/v2.5/src/models/list-filter/criteria/{movies.ts => groups.ts} (100%) rename ui/v2.5/src/models/list-filter/{movies.ts => groups.ts} (100%) diff --git a/ui/v2.5/src/App.tsx b/ui/v2.5/src/App.tsx index 7aba652cd80..f3229d36f59 100644 --- a/ui/v2.5/src/App.tsx +++ b/ui/v2.5/src/App.tsx @@ -66,7 +66,7 @@ const Galleries = lazyComponent( () => import("./components/Galleries/Galleries") ); -const Groups = lazyComponent(() => import("./components/Movies/Movies")); +const Groups = lazyComponent(() => import("./components/Groups/Groups")); const Tags = lazyComponent(() => import("./components/Tags/Tags")); const Images = lazyComponent(() => import("./components/Images/Images")); const Setup = lazyComponent(() => import("./components/Setup/Setup")); diff --git a/ui/v2.5/src/components/FrontPage/Control.tsx b/ui/v2.5/src/components/FrontPage/Control.tsx index 495fbc852c0..7ce32cb4449 100644 --- a/ui/v2.5/src/components/FrontPage/Control.tsx +++ b/ui/v2.5/src/components/FrontPage/Control.tsx @@ -7,7 +7,7 @@ import { ConfigurationContext } from "src/hooks/Config"; import { ListFilterModel } from "src/models/list-filter/filter"; import { GalleryRecommendationRow } from "../Galleries/GalleryRecommendationRow"; import { ImageRecommendationRow } from "../Images/ImageRecommendationRow"; -import { GroupRecommendationRow } from "../Movies/MovieRecommendationRow"; +import { GroupRecommendationRow } from "../Groups/GroupRecommendationRow"; import { PerformerRecommendationRow } from "../Performers/PerformerRecommendationRow"; import { SceneRecommendationRow } from "../Scenes/SceneRecommendationRow"; import { StudioRecommendationRow } from "../Studios/StudioRecommendationRow"; diff --git a/ui/v2.5/src/components/Movies/EditMoviesDialog.tsx b/ui/v2.5/src/components/Groups/EditGroupsDialog.tsx similarity index 100% rename from ui/v2.5/src/components/Movies/EditMoviesDialog.tsx rename to ui/v2.5/src/components/Groups/EditGroupsDialog.tsx diff --git a/ui/v2.5/src/components/Movies/MovieCard.tsx b/ui/v2.5/src/components/Groups/GroupCard.tsx similarity index 100% rename from ui/v2.5/src/components/Movies/MovieCard.tsx rename to ui/v2.5/src/components/Groups/GroupCard.tsx diff --git a/ui/v2.5/src/components/Movies/MovieCardGrid.tsx b/ui/v2.5/src/components/Groups/GroupCardGrid.tsx similarity index 95% rename from ui/v2.5/src/components/Movies/MovieCardGrid.tsx rename to ui/v2.5/src/components/Groups/GroupCardGrid.tsx index a08ec58fecd..9b96e4b641c 100644 --- a/ui/v2.5/src/components/Movies/MovieCardGrid.tsx +++ b/ui/v2.5/src/components/Groups/GroupCardGrid.tsx @@ -1,6 +1,6 @@ import React from "react"; import * as GQL from "src/core/generated-graphql"; -import { GroupCard } from "./MovieCard"; +import { GroupCard } from "./GroupCard"; import { useContainerDimensions } from "../Shared/GridCard/GridCard"; interface IGroupCardGrid { diff --git a/ui/v2.5/src/components/Movies/MovieDetails/Movie.tsx b/ui/v2.5/src/components/Groups/GroupDetails/Group.tsx similarity index 98% rename from ui/v2.5/src/components/Movies/MovieDetails/Movie.tsx rename to ui/v2.5/src/components/Groups/GroupDetails/Group.tsx index 731818da4cc..1fddfa967b1 100644 --- a/ui/v2.5/src/components/Movies/MovieDetails/Movie.tsx +++ b/ui/v2.5/src/components/Groups/GroupDetails/Group.tsx @@ -17,12 +17,12 @@ import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; import { useLightbox } from "src/hooks/Lightbox/hooks"; import { ModalComponent } from "src/components/Shared/Modal"; import { useToast } from "src/hooks/Toast"; -import { GroupScenesPanel } from "./MovieScenesPanel"; +import { GroupScenesPanel } from "./GroupScenesPanel"; import { CompressedGroupDetailsPanel, GroupDetailsPanel, -} from "./MovieDetailsPanel"; -import { GroupEditPanel } from "./MovieEditPanel"; +} from "./GroupDetailsPanel"; +import { GroupEditPanel } from "./GroupEditPanel"; import { faChevronDown, faChevronUp, diff --git a/ui/v2.5/src/components/Movies/MovieDetails/MovieCreate.tsx b/ui/v2.5/src/components/Groups/GroupDetails/GroupCreate.tsx similarity index 98% rename from ui/v2.5/src/components/Movies/MovieDetails/MovieCreate.tsx rename to ui/v2.5/src/components/Groups/GroupDetails/GroupCreate.tsx index 5d15afbd088..9dd3e22b93a 100644 --- a/ui/v2.5/src/components/Movies/MovieDetails/MovieCreate.tsx +++ b/ui/v2.5/src/components/Groups/GroupDetails/GroupCreate.tsx @@ -5,7 +5,7 @@ import { useHistory, useLocation } from "react-router-dom"; import { useIntl } from "react-intl"; import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; import { useToast } from "src/hooks/Toast"; -import { GroupEditPanel } from "./MovieEditPanel"; +import { GroupEditPanel } from "./GroupEditPanel"; const GroupCreate: React.FC = () => { const history = useHistory(); diff --git a/ui/v2.5/src/components/Movies/MovieDetails/MovieDetailsPanel.tsx b/ui/v2.5/src/components/Groups/GroupDetails/GroupDetailsPanel.tsx similarity index 100% rename from ui/v2.5/src/components/Movies/MovieDetails/MovieDetailsPanel.tsx rename to ui/v2.5/src/components/Groups/GroupDetails/GroupDetailsPanel.tsx diff --git a/ui/v2.5/src/components/Movies/MovieDetails/MovieEditPanel.tsx b/ui/v2.5/src/components/Groups/GroupDetails/GroupEditPanel.tsx similarity index 99% rename from ui/v2.5/src/components/Movies/MovieDetails/MovieEditPanel.tsx rename to ui/v2.5/src/components/Groups/GroupDetails/GroupEditPanel.tsx index 5b8584f6a69..a3ccf5b8b33 100644 --- a/ui/v2.5/src/components/Movies/MovieDetails/MovieEditPanel.tsx +++ b/ui/v2.5/src/components/Groups/GroupDetails/GroupEditPanel.tsx @@ -15,7 +15,7 @@ import TextUtils from "src/utils/text"; import ImageUtils from "src/utils/image"; import { useFormik } from "formik"; import { Prompt } from "react-router-dom"; -import { GroupScrapeDialog } from "./MovieScrapeDialog"; +import { GroupScrapeDialog } from "./GroupScrapeDialog"; import isEqual from "lodash-es/isEqual"; import { handleUnsavedChanges } from "src/utils/navigation"; import { formikUtils } from "src/utils/form"; diff --git a/ui/v2.5/src/components/Movies/MovieDetails/MovieScenesPanel.tsx b/ui/v2.5/src/components/Groups/GroupDetails/GroupScenesPanel.tsx similarity index 94% rename from ui/v2.5/src/components/Movies/MovieDetails/MovieScenesPanel.tsx rename to ui/v2.5/src/components/Groups/GroupDetails/GroupScenesPanel.tsx index deb1f31a7f6..acca9f0aae1 100644 --- a/ui/v2.5/src/components/Movies/MovieDetails/MovieScenesPanel.tsx +++ b/ui/v2.5/src/components/Groups/GroupDetails/GroupScenesPanel.tsx @@ -1,6 +1,6 @@ import React from "react"; import * as GQL from "src/core/generated-graphql"; -import { GroupsCriterion as GroupsCriterion } from "src/models/list-filter/criteria/movies"; +import { GroupsCriterion } from "src/models/list-filter/criteria/groups"; import { ListFilterModel } from "src/models/list-filter/filter"; import { SceneList } from "src/components/Scenes/SceneList"; import { View } from "src/components/List/views"; diff --git a/ui/v2.5/src/components/Movies/MovieDetails/MovieScrapeDialog.tsx b/ui/v2.5/src/components/Groups/GroupDetails/GroupScrapeDialog.tsx similarity index 100% rename from ui/v2.5/src/components/Movies/MovieDetails/MovieScrapeDialog.tsx rename to ui/v2.5/src/components/Groups/GroupDetails/GroupScrapeDialog.tsx diff --git a/ui/v2.5/src/components/Movies/MovieList.tsx b/ui/v2.5/src/components/Groups/GroupList.tsx similarity index 97% rename from ui/v2.5/src/components/Movies/MovieList.tsx rename to ui/v2.5/src/components/Groups/GroupList.tsx index 28c4baadd59..72758749666 100644 --- a/ui/v2.5/src/components/Movies/MovieList.tsx +++ b/ui/v2.5/src/components/Groups/GroupList.tsx @@ -14,8 +14,8 @@ import { import { makeItemList, showWhenSelected } from "../List/ItemList"; import { ExportDialog } from "../Shared/ExportDialog"; import { DeleteEntityDialog } from "../Shared/DeleteEntityDialog"; -import { GroupCardGrid } from "./MovieCardGrid"; -import { EditGroupsDialog } from "./EditMoviesDialog"; +import { GroupCardGrid } from "./GroupCardGrid"; +import { EditGroupsDialog } from "./EditGroupsDialog"; import { View } from "../List/views"; const GroupItemList = makeItemList({ diff --git a/ui/v2.5/src/components/Movies/MovieRecommendationRow.tsx b/ui/v2.5/src/components/Groups/GroupRecommendationRow.tsx similarity index 97% rename from ui/v2.5/src/components/Movies/MovieRecommendationRow.tsx rename to ui/v2.5/src/components/Groups/GroupRecommendationRow.tsx index 5ecf3616158..3a8fee856a2 100644 --- a/ui/v2.5/src/components/Movies/MovieRecommendationRow.tsx +++ b/ui/v2.5/src/components/Groups/GroupRecommendationRow.tsx @@ -2,7 +2,7 @@ import React from "react"; import { Link } from "react-router-dom"; import { useFindGroups } from "src/core/StashService"; import Slider from "@ant-design/react-slick"; -import { GroupCard } from "./MovieCard"; +import { GroupCard } from "./GroupCard"; import { ListFilterModel } from "src/models/list-filter/filter"; import { getSlickSliderSettings } from "src/core/recommendations"; import { RecommendationRow } from "../FrontPage/RecommendationRow"; diff --git a/ui/v2.5/src/components/Movies/MovieSelect.tsx b/ui/v2.5/src/components/Groups/GroupSelect.tsx similarity index 100% rename from ui/v2.5/src/components/Movies/MovieSelect.tsx rename to ui/v2.5/src/components/Groups/GroupSelect.tsx diff --git a/ui/v2.5/src/components/Movies/Movies.tsx b/ui/v2.5/src/components/Groups/Groups.tsx similarity index 85% rename from ui/v2.5/src/components/Movies/Movies.tsx rename to ui/v2.5/src/components/Groups/Groups.tsx index 202d8f4945a..a2e4d90834d 100644 --- a/ui/v2.5/src/components/Movies/Movies.tsx +++ b/ui/v2.5/src/components/Groups/Groups.tsx @@ -2,9 +2,9 @@ import React from "react"; import { Route, Switch } from "react-router-dom"; import { Helmet } from "react-helmet"; import { useTitleProps } from "src/hooks/title"; -import Group from "./MovieDetails/Movie"; -import GroupCreate from "./MovieDetails/MovieCreate"; -import { GroupList } from "./MovieList"; +import Group from "./GroupDetails/Group"; +import GroupCreate from "./GroupDetails/GroupCreate"; +import { GroupList } from "./GroupList"; import { useScrollToTopOnMount } from "src/hooks/scrollToTop"; import { View } from "../List/views"; diff --git a/ui/v2.5/src/components/Movies/styles.scss b/ui/v2.5/src/components/Groups/styles.scss similarity index 100% rename from ui/v2.5/src/components/Movies/styles.scss rename to ui/v2.5/src/components/Groups/styles.scss diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx index 6d67ec88898..b5046e0fcbd 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx @@ -27,7 +27,7 @@ import { } from "./PerformerDetailsPanel"; import { PerformerScenesPanel } from "./PerformerScenesPanel"; import { PerformerGalleriesPanel } from "./PerformerGalleriesPanel"; -import { PerformerGroupsPanel } from "./PerformerMoviesPanel"; +import { PerformerGroupsPanel } from "./PerformerGroupsPanel"; import { PerformerImagesPanel } from "./PerformerImagesPanel"; import { PerformerAppearsWithPanel } from "./performerAppearsWithPanel"; import { PerformerEditPanel } from "./PerformerEditPanel"; diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerMoviesPanel.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerGroupsPanel.tsx similarity index 90% rename from ui/v2.5/src/components/Performers/PerformerDetails/PerformerMoviesPanel.tsx rename to ui/v2.5/src/components/Performers/PerformerDetails/PerformerGroupsPanel.tsx index f9a1f7f5a54..4e75ef27925 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerMoviesPanel.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerGroupsPanel.tsx @@ -1,6 +1,6 @@ import React from "react"; import * as GQL from "src/core/generated-graphql"; -import { GroupList } from "src/components/Movies/MovieList"; +import { GroupList } from "src/components/Groups/GroupList"; import { usePerformerFilterHook } from "src/core/performers"; import { View } from "src/components/List/views"; diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx index 84754f18456..a21c23290c9 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx @@ -70,7 +70,7 @@ const SceneMarkersPanel = lazyComponent(() => import("./SceneMarkersPanel")); const SceneFileInfoPanel = lazyComponent(() => import("./SceneFileInfoPanel")); const SceneDetailPanel = lazyComponent(() => import("./SceneDetailPanel")); const SceneHistoryPanel = lazyComponent(() => import("./SceneHistoryPanel")); -const SceneGroupPanel = lazyComponent(() => import("./SceneMoviePanel")); +const SceneGroupPanel = lazyComponent(() => import("./SceneGroupPanel")); const SceneGalleriesPanel = lazyComponent( () => import("./SceneGalleriesPanel") ); diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx index 7b853b98cab..d41d308179e 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx @@ -29,7 +29,7 @@ import { useFormik } from "formik"; import { Prompt } from "react-router-dom"; import { ConfigurationContext } from "src/hooks/Config"; import { stashboxDisplayName } from "src/utils/stashbox"; -import { IGroupEntry, SceneGroupTable } from "./SceneMovieTable"; +import { IGroupEntry, SceneGroupTable } from "./SceneGroupTable"; import { faSearch, faSyncAlt } from "@fortawesome/free-solid-svg-icons"; import { objectTitle } from "src/core/files"; import { galleryTitle } from "src/core/galleries"; @@ -47,7 +47,7 @@ import { import { formikUtils } from "src/utils/form"; import { Studio, StudioSelect } from "src/components/Studios/StudioSelect"; import { Gallery, GallerySelect } from "src/components/Galleries/GallerySelect"; -import { Group } from "src/components/Movies/MovieSelect"; +import { Group } from "src/components/Groups/GroupSelect"; import { useTagsEdit } from "src/hooks/tagsEdit"; const SceneScrapeDialog = lazyComponent(() => import("./SceneScrapeDialog")); diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneMoviePanel.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneGroupPanel.tsx similarity index 90% rename from ui/v2.5/src/components/Scenes/SceneDetails/SceneMoviePanel.tsx rename to ui/v2.5/src/components/Scenes/SceneDetails/SceneGroupPanel.tsx index 78fc7875a52..6f58b504cf9 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneMoviePanel.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneGroupPanel.tsx @@ -1,6 +1,6 @@ import React from "react"; import * as GQL from "src/core/generated-graphql"; -import { GroupCard } from "src/components/Movies/MovieCard"; +import { GroupCard } from "src/components/Groups/GroupCard"; interface ISceneGroupPanelProps { scene: GQL.SceneDataFragment; diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneMovieTable.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneGroupTable.tsx similarity index 98% rename from ui/v2.5/src/components/Scenes/SceneDetails/SceneMovieTable.tsx rename to ui/v2.5/src/components/Scenes/SceneDetails/SceneGroupTable.tsx index 5b85c83522b..ed721ab4bcb 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneMovieTable.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneGroupTable.tsx @@ -2,7 +2,7 @@ import React, { useMemo } from "react"; import { useIntl } from "react-intl"; import * as GQL from "src/core/generated-graphql"; import { Form, Row, Col } from "react-bootstrap"; -import { Group, GroupSelect } from "src/components/Movies/MovieSelect"; +import { Group, GroupSelect } from "src/components/Groups/GroupSelect"; import cx from "classnames"; export type GroupSceneIndexMap = Map; diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneScrapeDialog.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneScrapeDialog.tsx index 59f13b1ec0a..e66ee8b367b 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneScrapeDialog.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneScrapeDialog.tsx @@ -28,7 +28,7 @@ import { } from "src/components/Shared/ScrapeDialog/createObjects"; import { Tag } from "src/components/Tags/TagSelect"; import { Studio } from "src/components/Studios/StudioSelect"; -import { Group } from "src/components/Movies/MovieSelect"; +import { Group } from "src/components/Groups/GroupSelect"; import { useScrapedTags } from "src/components/Shared/ScrapeDialog/scrapedTags"; interface ISceneScrapeDialogProps { diff --git a/ui/v2.5/src/components/Shared/ScrapeDialog/ScrapedObjectsRow.tsx b/ui/v2.5/src/components/Shared/ScrapeDialog/ScrapedObjectsRow.tsx index 1ec41386b59..07865533866 100644 --- a/ui/v2.5/src/components/Shared/ScrapeDialog/ScrapedObjectsRow.tsx +++ b/ui/v2.5/src/components/Shared/ScrapeDialog/ScrapedObjectsRow.tsx @@ -8,7 +8,7 @@ import { } from "src/components/Shared/ScrapeDialog/scrapeResult"; import { TagSelect } from "src/components/Tags/TagSelect"; import { StudioSelect } from "src/components/Studios/StudioSelect"; -import { GroupSelect } from "src/components/Movies/MovieSelect"; +import { GroupSelect } from "src/components/Groups/GroupSelect"; interface IScrapedStudioRow { title: string; diff --git a/ui/v2.5/src/components/Shared/ScrapeDialog/createObjects.ts b/ui/v2.5/src/components/Shared/ScrapeDialog/createObjects.ts index 61311e5465e..c4ba3a3e7e2 100644 --- a/ui/v2.5/src/components/Shared/ScrapeDialog/createObjects.ts +++ b/ui/v2.5/src/components/Shared/ScrapeDialog/createObjects.ts @@ -9,7 +9,7 @@ import { import { ObjectScrapeResult, ScrapeResult } from "./scrapeResult"; import { useIntl } from "react-intl"; import { scrapedPerformerToCreateInput } from "src/core/performers"; -import { scrapedGroupToCreateInput } from "src/core/movies"; +import { scrapedGroupToCreateInput } from "src/core/groups"; function useCreateObject( entityTypeID: string, diff --git a/ui/v2.5/src/components/Shared/Select.tsx b/ui/v2.5/src/components/Shared/Select.tsx index e989c886de2..4ae547cfe12 100644 --- a/ui/v2.5/src/components/Shared/Select.tsx +++ b/ui/v2.5/src/components/Shared/Select.tsx @@ -26,7 +26,7 @@ import { faTableColumns } from "@fortawesome/free-solid-svg-icons"; import { TagIDSelect } from "../Tags/TagSelect"; import { StudioIDSelect } from "../Studios/StudioSelect"; import { GalleryIDSelect } from "../Galleries/GallerySelect"; -import { GroupIDSelect } from "../Movies/MovieSelect"; +import { GroupIDSelect } from "../Groups/GroupSelect"; import { SceneIDSelect } from "../Scenes/SceneSelect"; export type SelectObject = { diff --git a/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx b/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx index 20291e2a7de..63eb47ed43a 100644 --- a/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx +++ b/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx @@ -31,7 +31,7 @@ import { CompressedStudioDetailsPanel, StudioDetailsPanel, } from "./StudioDetailsPanel"; -import { StudioGroupsPanel } from "./StudioMoviesPanel"; +import { StudioGroupsPanel } from "./StudioGroupsPanel"; import { faTrashAlt, faLink, diff --git a/ui/v2.5/src/components/Studios/StudioDetails/StudioMoviesPanel.tsx b/ui/v2.5/src/components/Studios/StudioDetails/StudioGroupsPanel.tsx similarity index 89% rename from ui/v2.5/src/components/Studios/StudioDetails/StudioMoviesPanel.tsx rename to ui/v2.5/src/components/Studios/StudioDetails/StudioGroupsPanel.tsx index 1016ec2bcc7..f129ca687dd 100644 --- a/ui/v2.5/src/components/Studios/StudioDetails/StudioMoviesPanel.tsx +++ b/ui/v2.5/src/components/Studios/StudioDetails/StudioGroupsPanel.tsx @@ -1,6 +1,6 @@ import React from "react"; import * as GQL from "src/core/generated-graphql"; -import { GroupList } from "src/components/Movies/MovieList"; +import { GroupList } from "src/components/Groups/GroupList"; import { useStudioFilterHook } from "src/core/studios"; import { View } from "src/components/List/views"; diff --git a/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx b/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx index 7b794e9bf52..13fb2664f15 100644 --- a/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx +++ b/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx @@ -42,7 +42,7 @@ import { import { DetailImage } from "src/components/Shared/DetailImage"; import { useLoadStickyHeader } from "src/hooks/detailsPanel"; import { useScrollToTopOnMount } from "src/hooks/scrollToTop"; -import { TagGroupsPanel } from "./TagMoviesPanel"; +import { TagGroupsPanel } from "./TagGroupsPanel"; interface IProps { tag: GQL.TagDataFragment; diff --git a/ui/v2.5/src/components/Tags/TagDetails/TagMoviesPanel.tsx b/ui/v2.5/src/components/Tags/TagDetails/TagGroupsPanel.tsx similarity index 85% rename from ui/v2.5/src/components/Tags/TagDetails/TagMoviesPanel.tsx rename to ui/v2.5/src/components/Tags/TagDetails/TagGroupsPanel.tsx index bb6d6ba7250..f8903154eb9 100644 --- a/ui/v2.5/src/components/Tags/TagDetails/TagMoviesPanel.tsx +++ b/ui/v2.5/src/components/Tags/TagDetails/TagGroupsPanel.tsx @@ -1,7 +1,7 @@ import React from "react"; import * as GQL from "src/core/generated-graphql"; import { useTagFilterHook } from "src/core/tags"; -import { GroupList } from "src/components/Movies/MovieList"; +import { GroupList } from "src/components/Groups/GroupList"; export const TagGroupsPanel: React.FC<{ active: boolean; diff --git a/ui/v2.5/src/core/movies.ts b/ui/v2.5/src/core/groups.ts similarity index 100% rename from ui/v2.5/src/core/movies.ts rename to ui/v2.5/src/core/groups.ts diff --git a/ui/v2.5/src/index.scss b/ui/v2.5/src/index.scss index df3cd9e2406..7bdb49040ea 100755 --- a/ui/v2.5/src/index.scss +++ b/ui/v2.5/src/index.scss @@ -6,7 +6,7 @@ @import "src/components/Help/styles.scss"; @import "src/components/Images/styles.scss"; @import "src/components/List/styles.scss"; -@import "src/components/Movies/styles.scss"; +@import "src/components/Groups/styles.scss"; @import "src/components/Performers/styles.scss"; @import "src/components/FrontPage/styles.scss"; @import "src/components/Scenes/styles.scss"; diff --git a/ui/v2.5/src/models/list-filter/criteria/movies.ts b/ui/v2.5/src/models/list-filter/criteria/groups.ts similarity index 100% rename from ui/v2.5/src/models/list-filter/criteria/movies.ts rename to ui/v2.5/src/models/list-filter/criteria/groups.ts diff --git a/ui/v2.5/src/models/list-filter/factory.ts b/ui/v2.5/src/models/list-filter/factory.ts index 1677358e366..f2d76abc302 100644 --- a/ui/v2.5/src/models/list-filter/factory.ts +++ b/ui/v2.5/src/models/list-filter/factory.ts @@ -2,7 +2,7 @@ import { FilterMode } from "src/core/generated-graphql"; import { ListFilterOptions } from "./filter-options"; import { GalleryListFilterOptions } from "./galleries"; import { ImageListFilterOptions } from "./images"; -import { GroupListFilterOptions } from "./movies"; +import { GroupListFilterOptions } from "./groups"; import { PerformerListFilterOptions } from "./performers"; import { SceneMarkerListFilterOptions } from "./scene-markers"; import { SceneListFilterOptions } from "./scenes"; diff --git a/ui/v2.5/src/models/list-filter/movies.ts b/ui/v2.5/src/models/list-filter/groups.ts similarity index 100% rename from ui/v2.5/src/models/list-filter/movies.ts rename to ui/v2.5/src/models/list-filter/groups.ts diff --git a/ui/v2.5/src/models/list-filter/scenes.ts b/ui/v2.5/src/models/list-filter/scenes.ts index 92b9eadfa20..965fa31be10 100644 --- a/ui/v2.5/src/models/list-filter/scenes.ts +++ b/ui/v2.5/src/models/list-filter/scenes.ts @@ -8,7 +8,7 @@ import { } from "./criteria/criterion"; import { HasMarkersCriterionOption } from "./criteria/has-markers"; import { SceneIsMissingCriterionOption } from "./criteria/is-missing"; -import { GroupsCriterionOption } from "./criteria/movies"; +import { GroupsCriterionOption } from "./criteria/groups"; import { GalleriesCriterionOption } from "./criteria/galleries"; import { OrganizedCriterionOption } from "./criteria/organized"; import { PerformersCriterionOption } from "./criteria/performers"; @@ -60,7 +60,7 @@ const sortByOptions = [ }, { messageID: "group_scene_number", - value: "movie_scene_number", + value: "group_scene_number", }, ]); const displayModeOptions = [ diff --git a/ui/v2.5/src/models/list-filter/tags.ts b/ui/v2.5/src/models/list-filter/tags.ts index 9aa4c80e6a8..b400d9d60ef 100644 --- a/ui/v2.5/src/models/list-filter/tags.ts +++ b/ui/v2.5/src/models/list-filter/tags.ts @@ -37,7 +37,7 @@ const sortByOptions = ["name", "random"] }, { messageID: "group_count", - value: "movies_count", + value: "groups_count", }, { messageID: "marker_count", diff --git a/ui/v2.5/src/utils/navigation.ts b/ui/v2.5/src/utils/navigation.ts index d30da8d7ac5..f0e06b0b75b 100644 --- a/ui/v2.5/src/utils/navigation.ts +++ b/ui/v2.5/src/utils/navigation.ts @@ -12,7 +12,7 @@ import { TagsCriterionOption, } from "src/models/list-filter/criteria/tags"; import { ListFilterModel } from "src/models/list-filter/filter"; -import { GroupsCriterion } from "src/models/list-filter/criteria/movies"; +import { GroupsCriterion } from "src/models/list-filter/criteria/groups"; import { Criterion, CriterionOption, From 12917f51d0d442d4411be2e3bd2e4b843b10b47f Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Thu, 4 Jul 2024 09:01:35 +1000 Subject: [PATCH 046/103] Scraper menu filter (#5041) * Move scene scraper menu into reusable component * Reuse ScraperMenu for scene query menu * Reuse scraper menu in GalleryEditPanel * Add filter to scraper menu * Add divider between stashboxes and scrapers --- .../GalleryDetails/GalleryEditPanel.tsx | 72 ++++------- .../Scenes/SceneDetails/SceneEditPanel.tsx | 117 +++--------------- ui/v2.5/src/components/Shared/ScraperMenu.tsx | 100 +++++++++++++++ ui/v2.5/src/components/Shared/styles.scss | 9 ++ 4 files changed, 148 insertions(+), 150 deletions(-) create mode 100644 ui/v2.5/src/components/Shared/ScraperMenu.tsx diff --git a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryEditPanel.tsx b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryEditPanel.tsx index 9f018e0d140..6acefcf7dc3 100644 --- a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryEditPanel.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryEditPanel.tsx @@ -1,14 +1,7 @@ -import React, { useEffect, useState } from "react"; +import React, { useEffect, useMemo, useState } from "react"; import { FormattedMessage, useIntl } from "react-intl"; import { Prompt } from "react-router-dom"; -import { - Button, - Dropdown, - DropdownButton, - Form, - Col, - Row, -} from "react-bootstrap"; +import { Button, Form, Col, Row } from "react-bootstrap"; import Mousetrap from "mousetrap"; import * as GQL from "src/core/generated-graphql"; import * as yup from "yup"; @@ -18,12 +11,10 @@ import { useListGalleryScrapers, mutateReloadScrapers, } from "src/core/StashService"; -import { Icon } from "src/components/Shared/Icon"; import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; import { useToast } from "src/hooks/Toast"; import { useFormik } from "formik"; import { GalleryScrapeDialog } from "./GalleryScrapeDialog"; -import { faSyncAlt } from "@fortawesome/free-solid-svg-icons"; import isEqual from "lodash-es/isEqual"; import { handleUnsavedChanges } from "src/utils/navigation"; import { @@ -39,6 +30,7 @@ import { formikUtils } from "src/utils/form"; import { Studio, StudioSelect } from "src/components/Studios/StudioSelect"; import { Scene, SceneSelect } from "src/components/Scenes/SceneSelect"; import { useTagsEdit } from "src/hooks/tagsEdit"; +import { ScraperMenu } from "src/components/Shared/ScraperMenu"; interface IProps { gallery: Partial; @@ -62,8 +54,7 @@ export const GalleryEditPanel: React.FC = ({ const isNew = gallery.id === undefined; - const Scrapers = useListGalleryScrapers(); - const [queryableScrapers, setQueryableScrapers] = useState([]); + const scrapers = useListGalleryScrapers(); const [scrapedGallery, setScrapedGallery] = useState(); @@ -165,13 +156,11 @@ export const GalleryEditPanel: React.FC = ({ } }); - useEffect(() => { - const newQueryableScrapers = (Scrapers?.data?.listScrapers ?? []).filter( - (s) => s.gallery?.supported_scrapes.includes(GQL.ScrapeType.Fragment) + const fragmentScrapers = useMemo(() => { + return (scrapers?.data?.listScrapers ?? []).filter((s) => + s.gallery?.supported_scrapes.includes(GQL.ScrapeType.Fragment) ); - - setQueryableScrapers(newQueryableScrapers); - }, [Scrapers]); + }, [scrapers]); async function onSave(input: InputValues) { setIsLoading(true); @@ -184,12 +173,12 @@ export const GalleryEditPanel: React.FC = ({ setIsLoading(false); } - async function onScrapeClicked(scraper: GQL.Scraper) { + async function onScrapeClicked(s: GQL.ScraperSourceInput) { if (!gallery || !gallery.id) return; setIsLoading(true); try { - const result = await queryScrapeGallery(scraper.id, gallery.id); + const result = await queryScrapeGallery(s.scraper_id!, gallery.id); if (!result.data || !result.data.scrapeSingleGallery?.length) { Toast.success("No galleries found"); return; @@ -244,36 +233,8 @@ export const GalleryEditPanel: React.FC = ({ ); } - function renderScraperMenu() { - if (isNew) { - return; - } - - return ( - - {queryableScrapers.map((s) => ( - onScrapeClicked(s)}> - {s.name} - - ))} - onReloadScrapers()}> - - - - - - - - - ); - } - function urlScrapable(scrapedUrl: string): boolean { - return (Scrapers?.data?.listScrapers ?? []).some((s) => + return (scrapers?.data?.listScrapers ?? []).some((s) => (s?.gallery?.urls ?? []).some((u) => scrapedUrl.includes(u)) ); } @@ -461,7 +422,16 @@ export const GalleryEditPanel: React.FC = ({

    -
    {renderScraperMenu()}
    +
    + {!isNew && ( + + )} +
    diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx index d41d308179e..2eef3de1fb4 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx @@ -1,14 +1,6 @@ import React, { useEffect, useState, useMemo } from "react"; import { FormattedMessage, useIntl } from "react-intl"; -import { - Button, - Dropdown, - DropdownButton, - Form, - Col, - Row, - ButtonGroup, -} from "react-bootstrap"; +import { Button, Form, Col, Row, ButtonGroup } from "react-bootstrap"; import Mousetrap from "mousetrap"; import * as GQL from "src/core/generated-graphql"; import * as yup from "yup"; @@ -28,9 +20,8 @@ import { getStashIDs } from "src/utils/stashIds"; import { useFormik } from "formik"; import { Prompt } from "react-router-dom"; import { ConfigurationContext } from "src/hooks/Config"; -import { stashboxDisplayName } from "src/utils/stashbox"; import { IGroupEntry, SceneGroupTable } from "./SceneGroupTable"; -import { faSearch, faSyncAlt } from "@fortawesome/free-solid-svg-icons"; +import { faSearch } from "@fortawesome/free-solid-svg-icons"; import { objectTitle } from "src/core/files"; import { galleryTitle } from "src/core/galleries"; import { lazyComponent } from "src/utils/lazyComponent"; @@ -49,6 +40,7 @@ import { Studio, StudioSelect } from "src/components/Studios/StudioSelect"; import { Gallery, GallerySelect } from "src/components/Galleries/GallerySelect"; import { Group } from "src/components/Groups/GroupSelect"; import { useTagsEdit } from "src/hooks/tagsEdit"; +import { ScraperMenu } from "src/components/Shared/ScraperMenu"; const SceneScrapeDialog = lazyComponent(() => import("./SceneScrapeDialog")); const SceneQueryModal = lazyComponent(() => import("./SceneQueryModal")); @@ -394,51 +386,6 @@ export const SceneEditPanel: React.FC = ({ ); } - function renderScrapeQueryMenu() { - const stashBoxes = stashConfig?.general.stashBoxes ?? []; - - if (stashBoxes.length === 0 && queryableScrapers.length === 0) return; - - return ( - - - - - - - {stashBoxes.map((s, index) => ( - - onScrapeQueryClicked({ - stash_box_endpoint: s.endpoint, - }) - } - > - {stashboxDisplayName(s.name, index)} - - ))} - {queryableScrapers.map((s) => ( - onScrapeQueryClicked({ scraper_id: s.id })} - > - {s.name} - - ))} - onReloadScrapers()}> - - - - - - - - - - ); - } - function onSceneSelected(s: GQL.ScrapedSceneDataFragment) { if (!scraper) return; @@ -468,47 +415,6 @@ export const SceneEditPanel: React.FC = ({ ); }; - function renderScraperMenu() { - const stashBoxes = stashConfig?.general.stashBoxes ?? []; - - return ( - - {stashBoxes.map((s, index) => ( - - onScrapeClicked({ - stash_box_endpoint: s.endpoint, - }) - } - > - {stashboxDisplayName(s.name, index)} - - ))} - {fragmentScrapers.map((s) => ( - onScrapeClicked({ scraper_id: s.id })} - > - {s.name} - - ))} - onReloadScrapers()}> - - - - - - - - - ); - } - function urlScrapable(scrapedUrl: string): boolean { return (Scrapers?.data?.listScrapers ?? []).some((s) => (s?.scene?.urls ?? []).some((u) => scrapedUrl.includes(u)) @@ -801,8 +707,21 @@ export const SceneEditPanel: React.FC = ({ {!isNew && (
    - {renderScraperMenu()} - {renderScrapeQueryMenu()} + + } + stashBoxes={stashConfig?.general.stashBoxes ?? []} + scrapers={queryableScrapers} + onScraperClicked={onScrapeQueryClicked} + onReloadScrapers={onReloadScrapers} + />
    )} diff --git a/ui/v2.5/src/components/Shared/ScraperMenu.tsx b/ui/v2.5/src/components/Shared/ScraperMenu.tsx new file mode 100644 index 00000000000..2152854b022 --- /dev/null +++ b/ui/v2.5/src/components/Shared/ScraperMenu.tsx @@ -0,0 +1,100 @@ +import React, { useMemo, useState } from "react"; +import { Dropdown } from "react-bootstrap"; +import { FormattedMessage, useIntl } from "react-intl"; +import { Icon } from "./Icon"; +import { stashboxDisplayName } from "src/utils/stashbox"; +import { ScraperSourceInput, StashBox } from "src/core/generated-graphql"; +import { faSyncAlt } from "@fortawesome/free-solid-svg-icons"; +import { ClearableInput } from "./ClearableInput"; + +const minFilteredScrapers = 5; + +export const ScraperMenu: React.FC<{ + toggle: React.ReactNode; + variant?: string; + stashBoxes?: StashBox[]; + scrapers: { id: string; name: string }[]; + onScraperClicked: (s: ScraperSourceInput) => void; + onReloadScrapers: () => void; +}> = ({ + toggle, + variant, + stashBoxes, + scrapers, + onScraperClicked, + onReloadScrapers, +}) => { + const intl = useIntl(); + const [filter, setFilter] = useState(""); + + const filteredStashboxes = useMemo(() => { + if (!stashBoxes) return []; + if (!filter) return stashBoxes; + + return stashBoxes.filter((s) => + s.name.toLowerCase().includes(filter.toLowerCase()) + ); + }, [stashBoxes, filter]); + + const filteredScrapers = useMemo(() => { + if (!filter) return scrapers; + + return scrapers.filter( + (s) => + s.name.toLowerCase().includes(filter.toLowerCase()) || + s.id.toLowerCase().includes(filter.toLowerCase()) + ); + }, [scrapers, filter]); + + return ( + + {toggle} + + + {(stashBoxes?.length ?? 0) + scrapers.length > minFilteredScrapers && ( + + )} + {filteredStashboxes.map((s, index) => ( + + onScraperClicked({ + stash_box_endpoint: s.endpoint, + }) + } + > + {stashboxDisplayName(s.name, index)} + + ))} + + {filteredStashboxes.length > 0 && filteredScrapers.length > 0 && ( + + )} + + {filteredScrapers.map((s) => ( + onScraperClicked({ scraper_id: s.id })} + > + {s.name} + + ))} + onReloadScrapers()}> + + + + + + + + + + ); +}; diff --git a/ui/v2.5/src/components/Shared/styles.scss b/ui/v2.5/src/components/Shared/styles.scss index 983e517834b..00ce5e663f7 100644 --- a/ui/v2.5/src/components/Shared/styles.scss +++ b/ui/v2.5/src/components/Shared/styles.scss @@ -596,3 +596,12 @@ button.btn.favorite-button { .external-links-button { display: inline-block; } + +.scraper-menu .dropdown-menu { + min-width: 250px; + + .dropdown-divider { + border-top-color: $textfield-bg; + margin: 0; + } +} From b69d9cc8409c9dae371693dee311f5b98a7930b6 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Thu, 4 Jul 2024 09:09:31 +1000 Subject: [PATCH 047/103] Metadata Providers -> Scraper list improvements (#5040) * Refactor scraping settings panel * Add max-height to scraper table * Separate scraper section * Add filter to scrapers section * Add counters to scraper headings * Show all urls with a scrollbar * Sort URLs --- .../Settings/SettingsScrapingPanel.tsx | 512 +++++++++--------- ui/v2.5/src/components/Settings/styles.scss | 8 + .../src/components/Shared/CollapseButton.tsx | 2 +- 3 files changed, 265 insertions(+), 257 deletions(-) diff --git a/ui/v2.5/src/components/Settings/SettingsScrapingPanel.tsx b/ui/v2.5/src/components/Settings/SettingsScrapingPanel.tsx index 4b38100b53a..6859fce1862 100644 --- a/ui/v2.5/src/components/Settings/SettingsScrapingPanel.tsx +++ b/ui/v2.5/src/components/Settings/SettingsScrapingPanel.tsx @@ -1,4 +1,4 @@ -import React, { useState } from "react"; +import React, { PropsWithChildren, useMemo, useState } from "react"; import { FormattedMessage, useIntl } from "react-intl"; import { Button } from "react-bootstrap"; import { @@ -24,55 +24,154 @@ import { InstalledScraperPackages, } from "./ScraperPackageManager"; import { ExternalLink } from "../Shared/ExternalLink"; +import { ClearableInput } from "../Shared/ClearableInput"; +import { Counter } from "../Shared/Counter"; + +const ScraperTable: React.FC< + PropsWithChildren<{ + entityType: string; + count?: number; + }> +> = ({ entityType, count, children }) => { + const intl = useIntl(); -interface IURLList { - urls: string[]; -} + const titleEl = useMemo(() => { + const title = intl.formatMessage( + { id: "config.scraping.entity_scrapers" }, + { entityType: intl.formatMessage({ id: entityType }) } + ); -const URLList: React.FC = ({ urls }) => { - const maxCollapsedItems = 5; - const [expanded, setExpanded] = useState(false); + if (count) { + return ( + + {title} + + ); + } - function linkSite(url: string) { - const u = new URL(url); - return `${u.protocol}//${u.host}`; - } + return title; + }, [count, entityType, intl]); - function renderLink(url?: string) { - if (url) { - const sanitised = TextUtils.sanitiseURL(url); - const siteURL = linkSite(sanitised!); + return ( + + + + + + + + + + {children} +
    + + + + + +
    +
    + ); +}; - return {sanitised}; - } - } +const ScrapeTypeList: React.FC<{ + types: ScrapeType[]; + entityType: string; +}> = ({ types, entityType }) => { + const intl = useIntl(); - function getListItems() { - const items = urls.map((u) =>
  • {renderLink(u)}
  • ); + const typeStrings = useMemo( + () => + types.map((t) => { + switch (t) { + case ScrapeType.Fragment: + return intl.formatMessage( + { id: "config.scraping.entity_metadata" }, + { entityType: intl.formatMessage({ id: entityType }) } + ); + default: + return t; + } + }), + [types, entityType, intl] + ); - if (items.length > maxCollapsedItems) { - if (!expanded) { - items.length = maxCollapsedItems; - } + return ( +
      + {typeStrings.map((t) => ( +
    • {t}
    • + ))} +
    + ); +}; - items.push( -
  • - -
  • - ); +interface IURLList { + urls: string[]; +} + +const URLList: React.FC = ({ urls }) => { + const items = useMemo(() => { + function linkSite(url: string) { + const u = new URL(url); + return `${u.protocol}//${u.host}`; } - return items; - } + const ret = urls + .slice() + .sort() + .map((u) => { + const sanitised = TextUtils.sanitiseURL(u); + const siteURL = linkSite(sanitised!); + + return ( +
  • + {sanitised} +
  • + ); + }); + + return ret; + }, [urls]); - return
      {getListItems()}
    ; + return
      {items}
    ; }; -export const SettingsScrapingPanel: React.FC = () => { +const ScraperTableRow: React.FC<{ + name: string; + entityType: string; + supportedScrapes: ScrapeType[]; + urls: string[]; +}> = ({ name, entityType, supportedScrapes, urls }) => { + return ( + + {name} + + + + + + + + ); +}; + +function filterScraper(filter: string) { + return (name: string, urls: string[] | undefined | null) => { + if (!filter) return true; + + return ( + name.toLowerCase().includes(filter) || + urls?.some((url) => url.toLowerCase().includes(filter)) + ); + }; +} + +const ScrapersSection: React.FC = () => { const Toast = useToast(); const intl = useIntl(); + + const [filter, setFilter] = useState(""); + const { data: performerScrapers, loading: loadingPerformers } = useListPerformerScrapers(); const { data: sceneScrapers, loading: loadingScenes } = @@ -82,8 +181,29 @@ export const SettingsScrapingPanel: React.FC = () => { const { data: groupScrapers, loading: loadingGroups } = useListGroupScrapers(); - const { general, scraping, loading, error, saveGeneral, saveScraping } = - useSettings(); + const filteredScrapers = useMemo(() => { + const filterFn = filterScraper(filter.toLowerCase()); + return { + performers: performerScrapers?.listScrapers.filter((s) => + filterFn(s.name, s.performer?.urls) + ), + scenes: sceneScrapers?.listScrapers.filter((s) => + filterFn(s.name, s.scene?.urls) + ), + galleries: galleryScrapers?.listScrapers.filter((s) => + filterFn(s.name, s.gallery?.urls) + ), + groups: groupScrapers?.listScrapers.filter((s) => + filterFn(s.name, s.group?.urls) + ), + }; + }, [ + performerScrapers, + sceneScrapers, + galleryScrapers, + groupScrapers, + filter, + ]); async function onReloadScrapers() { try { @@ -93,213 +213,111 @@ export const SettingsScrapingPanel: React.FC = () => { } } - function renderPerformerScrapeTypes(types: ScrapeType[]) { - const typeStrings = types - .filter((t) => t !== ScrapeType.Fragment) - .map((t) => { - switch (t) { - case ScrapeType.Name: - return intl.formatMessage({ id: "config.scraping.search_by_name" }); - default: - return t; - } - }); - - return ( -
      - {typeStrings.map((t) => ( -
    • {t}
    • - ))} -
    - ); - } - - function renderSceneScrapeTypes(types: ScrapeType[]) { - const typeStrings = types.map((t) => { - switch (t) { - case ScrapeType.Fragment: - return intl.formatMessage( - { id: "config.scraping.entity_metadata" }, - { entityType: intl.formatMessage({ id: "scene" }) } - ); - default: - return t; - } - }); - - return ( -
      - {typeStrings.map((t) => ( -
    • {t}
    • - ))} -
    - ); - } - - function renderGalleryScrapeTypes(types: ScrapeType[]) { - const typeStrings = types.map((t) => { - switch (t) { - case ScrapeType.Fragment: - return intl.formatMessage( - { id: "config.scraping.entity_metadata" }, - { entityType: intl.formatMessage({ id: "gallery" }) } - ); - default: - return t; - } - }); - - return ( -
      - {typeStrings.map((t) => ( -
    • {t}
    • - ))} -
    - ); - } - - function renderGroupScrapeTypes(types: ScrapeType[]) { - const typeStrings = types.map((t) => { - switch (t) { - case ScrapeType.Fragment: - return intl.formatMessage( - { id: "config.scraping.entity_metadata" }, - { entityType: intl.formatMessage({ id: "group" }) } - ); - default: - return t; - } - }); - + if (loadingScenes || loadingGalleries || loadingPerformers || loadingGroups) return ( -
      - {typeStrings.map((t) => ( -
    • {t}
    • - ))} -
    - ); - } - - function renderURLs(urls: string[]) { - return ; - } - - function renderSceneScrapers() { - const elements = (sceneScrapers?.listScrapers ?? []).map((scraper) => ( - - {scraper.name} - - {renderSceneScrapeTypes(scraper.scene?.supported_scrapes ?? [])} - - {renderURLs(scraper.scene?.urls ?? [])} - - )); - - return renderTable( - intl.formatMessage( - { id: "config.scraping.entity_scrapers" }, - { entityType: intl.formatMessage({ id: "scene" }) } - ), - elements - ); - } - - function renderGalleryScrapers() { - const elements = (galleryScrapers?.listScrapers ?? []).map((scraper) => ( - - {scraper.name} - - {renderGalleryScrapeTypes(scraper.gallery?.supported_scrapes ?? [])} - - {renderURLs(scraper.gallery?.urls ?? [])} - - )); - - return renderTable( - intl.formatMessage( - { id: "config.scraping.entity_scrapers" }, - { entityType: intl.formatMessage({ id: "gallery" }) } - ), - elements + + + ); - } - function renderPerformerScrapers() { - const elements = (performerScrapers?.listScrapers ?? []).map((scraper) => ( - - {scraper.name} - - {renderPerformerScrapeTypes( - scraper.performer?.supported_scrapes ?? [] - )} - - {renderURLs(scraper.performer?.urls ?? [])} - - )); - - return renderTable( - intl.formatMessage( - { id: "config.scraping.entity_scrapers" }, - { entityType: intl.formatMessage({ id: "performer" }) } - ), - elements - ); - } + return ( + +
    + setFilter(v)} + /> - function renderGroupScrapers() { - const elements = (groupScrapers?.listScrapers ?? []).map((scraper) => ( - - {scraper.name} - - {renderGroupScrapeTypes(scraper.group?.supported_scrapes ?? [])} - - {renderURLs(scraper.group?.urls ?? [])} - - )); - - return renderTable( - intl.formatMessage( - { id: "config.scraping.entity_scrapers" }, - { entityType: intl.formatMessage({ id: "group" }) } - ), - elements - ); - } + +
    + +
    + {!!filteredScrapers.scenes?.length && ( + + {filteredScrapers.scenes?.map((scraper) => ( + + ))} + + )} + + {!!filteredScrapers.galleries?.length && ( + + {filteredScrapers.galleries?.map((scraper) => ( + + ))} + + )} + + {!!filteredScrapers.performers?.length && ( + + {filteredScrapers.performers?.map((scraper) => ( + + ))} + + )} + + {!!filteredScrapers.groups?.length && ( + + {filteredScrapers.groups?.map((scraper) => ( + + ))} + + )} +
    +
    + ); +}; - function renderTable(title: string, elements: JSX.Element[]) { - if (elements.length > 0) { - return ( - - - - - - - - - - {elements} -
    {intl.formatMessage({ id: "name" })} - {intl.formatMessage({ - id: "config.scraping.supported_types", - })} - - {intl.formatMessage({ id: "config.scraping.supported_urls" })} -
    -
    - ); - } - } +export const SettingsScrapingPanel: React.FC = () => { + const { general, scraping, loading, error, saveGeneral, saveScraping } = + useSettings(); if (error) return

    {error.message}

    ; - if ( - loading || - loadingScenes || - loadingGalleries || - loadingPerformers || - loadingGroups - ) - return ; + if (loading) return ; return ( <> @@ -345,25 +363,7 @@ export const SettingsScrapingPanel: React.FC = () => { - -
    - -
    - -
    - {renderSceneScrapers()} - {renderGalleryScrapers()} - {renderPerformerScrapers()} - {renderGroupScrapers()} -
    -
    + ); }; diff --git a/ui/v2.5/src/components/Settings/styles.scss b/ui/v2.5/src/components/Settings/styles.scss index b7899d8d13d..8861a8122cc 100644 --- a/ui/v2.5/src/components/Settings/styles.scss +++ b/ui/v2.5/src/components/Settings/styles.scss @@ -228,6 +228,7 @@ .scraper-table { display: block; margin-bottom: 16px; + max-height: 300px; overflow: auto; width: 100%; @@ -247,6 +248,8 @@ ul { margin-bottom: 0; + max-height: 100px; + overflow: auto; padding-left: 0; } @@ -255,6 +258,11 @@ } } +.scraper-toolbar { + display: flex; + justify-content: space-between; +} + .job-table.card { background-color: $card-bg; height: 10em; diff --git a/ui/v2.5/src/components/Shared/CollapseButton.tsx b/ui/v2.5/src/components/Shared/CollapseButton.tsx index 78099d0e85a..7f70cf0ed37 100644 --- a/ui/v2.5/src/components/Shared/CollapseButton.tsx +++ b/ui/v2.5/src/components/Shared/CollapseButton.tsx @@ -7,7 +7,7 @@ import { Button, Collapse } from "react-bootstrap"; import { Icon } from "./Icon"; interface IProps { - text: string; + text: React.ReactNode; } export const CollapseButton: React.FC> = ( From 15a7b8a8594bf0557d130ceaf91e05d3d0297c0a Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Thu, 4 Jul 2024 09:10:26 +1000 Subject: [PATCH 048/103] Movie group renames (#5039) * Rename Movie and MoviePartial to Group/GroupPartial * Rename Movie interfaces * Update movie url builders to use group * Rename movieRoutes to groupRoutes * Update dataloader * Update names in sqlite package * Rename in resolvers * Add GroupByURL to scraper config * Scraper backward compatibility hacks --- gqlgen.yml | 10 +- internal/api/changeset_translator.go | 50 +-- internal/api/context_keys.go | 2 +- internal/api/loaders/dataloaders.go | 14 +- internal/api/loaders/movieloader_gen.go | 80 ++-- internal/api/resolver.go | 12 +- internal/api/resolver_model_movie.go | 38 +- internal/api/resolver_model_performer.go | 8 +- internal/api/resolver_model_scene.go | 22 +- internal/api/resolver_model_studio.go | 8 +- internal/api/resolver_model_tag.go | 2 +- internal/api/resolver_mutation_group.go | 152 +++---- internal/api/resolver_mutation_movie.go | 150 +++---- internal/api/resolver_mutation_scene.go | 14 +- internal/api/resolver_query_find_group.go | 16 +- internal/api/resolver_query_find_movie.go | 20 +- internal/api/resolver_query_scraper.go | 8 +- internal/api/routes_movie.go | 50 +-- internal/api/server.go | 8 +- internal/api/urlbuilders/movie.go | 23 +- internal/dlna/cds.go | 26 +- internal/dlna/dms.go | 4 +- internal/dlna/service.go | 4 +- internal/manager/json_utils.go | 4 +- internal/manager/task_export.go | 98 ++--- internal/manager/task_import.go | 30 +- internal/static/embed.go | 4 +- pkg/match/scraped.go | 24 +- pkg/models/jsonschema/movie.go | 10 +- pkg/models/jsonschema/scene.go | 6 +- ...ieReaderWriter.go => GroupReaderWriter.go} | 202 ++++----- pkg/models/mocks/SavedFilterReaderWriter.go | 37 -- pkg/models/mocks/SceneReaderWriter.go | 66 +-- pkg/models/mocks/TagReaderWriter.go | 20 +- pkg/models/mocks/database.go | 8 +- pkg/models/model_joins.go | 38 +- pkg/models/model_movie.go | 16 +- pkg/models/model_scene.go | 14 +- pkg/models/model_scraped_item.go | 48 +++ pkg/models/movie.go | 4 +- pkg/models/paths/paths_json.go | 10 +- pkg/models/relationships.go | 38 +- pkg/models/repository.go | 2 +- pkg/models/repository_movie.go | 106 ++--- pkg/models/repository_scene.go | 6 +- pkg/models/repository_tag.go | 2 +- pkg/models/scene.go | 4 +- pkg/movie/export.go | 4 +- pkg/movie/export_test.go | 44 +- pkg/movie/import.go | 76 ++-- pkg/movie/import_test.go | 84 ++-- pkg/movie/query.go | 8 +- pkg/scene/export.go | 38 +- pkg/scene/export_test.go | 78 ++-- pkg/scene/filename_parser.go | 52 +-- pkg/scene/import.go | 48 +-- pkg/scene/import_test.go | 58 +-- pkg/scraper/cache.go | 4 +- pkg/scraper/config.go | 27 +- pkg/scraper/group.go | 2 +- pkg/scraper/image.go | 34 ++ pkg/scraper/json.go | 2 +- pkg/scraper/mapped.go | 2 +- pkg/scraper/postprocessing.go | 71 ++- pkg/scraper/xpath.go | 2 +- pkg/sqlite/anonymise.go | 12 +- pkg/sqlite/blob_test.go | 2 +- pkg/sqlite/database.go | 4 +- pkg/sqlite/movies.go | 204 ++++----- pkg/sqlite/movies_filter.go | 98 ++--- pkg/sqlite/movies_test.go | 404 +++++++++--------- pkg/sqlite/scene.go | 46 +- pkg/sqlite/scene_filter.go | 12 +- pkg/sqlite/scene_test.go | 212 ++++----- pkg/sqlite/setup_test.go | 118 ++--- pkg/sqlite/studio_test.go | 10 +- pkg/sqlite/table.go | 40 +- pkg/sqlite/tables.go | 34 +- pkg/sqlite/tag.go | 5 +- pkg/sqlite/tag_filter.go | 6 +- pkg/sqlite/tag_test.go | 12 +- pkg/sqlite/transaction.go | 2 +- .../src/docs/en/Manual/ScraperDevelopment.md | 16 +- 83 files changed, 1754 insertions(+), 1635 deletions(-) rename pkg/models/mocks/{MovieReaderWriter.go => GroupReaderWriter.go} (59%) diff --git a/gqlgen.yml b/gqlgen.yml index 36febdaae01..9f22ccb49e2 100644 --- a/gqlgen.yml +++ b/gqlgen.yml @@ -51,11 +51,11 @@ models: fieldName: DurationFinite frame_rate: fieldName: FrameRateFinite - # group is movie under the hood - Group: - model: github.com/stashapp/stash/pkg/models.Movie - GroupFilterType: - model: github.com/stashapp/stash/pkg/models.MovieFilterType + # movie is group under the hood + Movie: + model: github.com/stashapp/stash/pkg/models.Group + MovieFilterType: + model: github.com/stashapp/stash/pkg/models.GroupFilterType # autobind on config causes generation issues BlobsStorageType: model: github.com/stashapp/stash/internal/manager/config.BlobsStorageType diff --git a/internal/api/changeset_translator.go b/internal/api/changeset_translator.go index b5bd5835a58..efac25087d6 100644 --- a/internal/api/changeset_translator.go +++ b/internal/api/changeset_translator.go @@ -346,17 +346,17 @@ func (t changesetTranslator) updateStashIDs(value []models.StashID, field string } } -func (t changesetTranslator) relatedMovies(value []models.SceneMovieInput) (models.RelatedMovies, error) { - moviesScenes, err := models.MoviesScenesFromInput(value) +func (t changesetTranslator) relatedGroupsFromMovies(value []models.SceneMovieInput) (models.RelatedGroups, error) { + groupsScenes, err := models.GroupsScenesFromInput(value) if err != nil { - return models.RelatedMovies{}, err + return models.RelatedGroups{}, err } - return models.NewRelatedMovies(moviesScenes), nil + return models.NewRelatedGroups(groupsScenes), nil } -func moviesScenesFromGroupInput(input []models.SceneGroupInput) ([]models.MoviesScenes, error) { - ret := make([]models.MoviesScenes, len(input)) +func groupsScenesFromGroupInput(input []models.SceneGroupInput) ([]models.GroupsScenes, error) { + ret := make([]models.GroupsScenes, len(input)) for i, v := range input { mID, err := strconv.Atoi(v.GroupID) @@ -364,8 +364,8 @@ func moviesScenesFromGroupInput(input []models.SceneGroupInput) ([]models.Movies return nil, fmt.Errorf("invalid group ID: %s", v.GroupID) } - ret[i] = models.MoviesScenes{ - MovieID: mID, + ret[i] = models.GroupsScenes{ + GroupID: mID, SceneIndex: v.SceneIndex, } } @@ -373,48 +373,48 @@ func moviesScenesFromGroupInput(input []models.SceneGroupInput) ([]models.Movies return ret, nil } -func (t changesetTranslator) relatedMoviesFromGroups(value []models.SceneGroupInput) (models.RelatedMovies, error) { - moviesScenes, err := moviesScenesFromGroupInput(value) +func (t changesetTranslator) relatedGroups(value []models.SceneGroupInput) (models.RelatedGroups, error) { + groupsScenes, err := groupsScenesFromGroupInput(value) if err != nil { - return models.RelatedMovies{}, err + return models.RelatedGroups{}, err } - return models.NewRelatedMovies(moviesScenes), nil + return models.NewRelatedGroups(groupsScenes), nil } -func (t changesetTranslator) updateMovieIDs(value []models.SceneMovieInput, field string) (*models.UpdateMovieIDs, error) { +func (t changesetTranslator) updateGroupIDsFromMovies(value []models.SceneMovieInput, field string) (*models.UpdateGroupIDs, error) { if !t.hasField(field) { return nil, nil } - moviesScenes, err := models.MoviesScenesFromInput(value) + groupsScenes, err := models.GroupsScenesFromInput(value) if err != nil { return nil, err } - return &models.UpdateMovieIDs{ - Movies: moviesScenes, + return &models.UpdateGroupIDs{ + Groups: groupsScenes, Mode: models.RelationshipUpdateModeSet, }, nil } -func (t changesetTranslator) updateMovieIDsFromGroups(value []models.SceneGroupInput, field string) (*models.UpdateMovieIDs, error) { +func (t changesetTranslator) updateGroupIDs(value []models.SceneGroupInput, field string) (*models.UpdateGroupIDs, error) { if !t.hasField(field) { return nil, nil } - moviesScenes, err := moviesScenesFromGroupInput(value) + groupsScenes, err := groupsScenesFromGroupInput(value) if err != nil { return nil, err } - return &models.UpdateMovieIDs{ - Movies: moviesScenes, + return &models.UpdateGroupIDs{ + Groups: groupsScenes, Mode: models.RelationshipUpdateModeSet, }, nil } -func (t changesetTranslator) updateMovieIDsBulk(value *BulkUpdateIds, field string) (*models.UpdateMovieIDs, error) { +func (t changesetTranslator) updateGroupIDsBulk(value *BulkUpdateIds, field string) (*models.UpdateGroupIDs, error) { if !t.hasField(field) || value == nil { return nil, nil } @@ -424,13 +424,13 @@ func (t changesetTranslator) updateMovieIDsBulk(value *BulkUpdateIds, field stri return nil, fmt.Errorf("converting ids [%v]: %w", value.Ids, err) } - movies := make([]models.MoviesScenes, len(ids)) + groups := make([]models.GroupsScenes, len(ids)) for i, id := range ids { - movies[i] = models.MoviesScenes{MovieID: id} + groups[i] = models.GroupsScenes{GroupID: id} } - return &models.UpdateMovieIDs{ - Movies: movies, + return &models.UpdateGroupIDs{ + Groups: groups, Mode: value.Mode, }, nil } diff --git a/internal/api/context_keys.go b/internal/api/context_keys.go index a8ab0afb50e..df61139f859 100644 --- a/internal/api/context_keys.go +++ b/internal/api/context_keys.go @@ -9,7 +9,7 @@ const ( performerKey key = iota + 1 sceneKey studioKey - movieKey + groupKey tagKey downloadKey imageKey diff --git a/internal/api/loaders/dataloaders.go b/internal/api/loaders/dataloaders.go index 397b57d04f6..d1b13db6991 100644 --- a/internal/api/loaders/dataloaders.go +++ b/internal/api/loaders/dataloaders.go @@ -4,7 +4,7 @@ //go:generate go run github.com/vektah/dataloaden PerformerLoader int *github.com/stashapp/stash/pkg/models.Performer //go:generate go run github.com/vektah/dataloaden StudioLoader int *github.com/stashapp/stash/pkg/models.Studio //go:generate go run github.com/vektah/dataloaden TagLoader int *github.com/stashapp/stash/pkg/models.Tag -//go:generate go run github.com/vektah/dataloaden MovieLoader int *github.com/stashapp/stash/pkg/models.Movie +//go:generate go run github.com/vektah/dataloaden GroupLoader int *github.com/stashapp/stash/pkg/models.Group //go:generate go run github.com/vektah/dataloaden FileLoader github.com/stashapp/stash/pkg/models.FileID github.com/stashapp/stash/pkg/models.File //go:generate go run github.com/vektah/dataloaden SceneFileIDsLoader int []github.com/stashapp/stash/pkg/models.FileID //go:generate go run github.com/vektah/dataloaden ImageFileIDsLoader int []github.com/stashapp/stash/pkg/models.FileID @@ -52,7 +52,7 @@ type Loaders struct { PerformerByID *PerformerLoader StudioByID *StudioLoader TagByID *TagLoader - MovieByID *MovieLoader + GroupByID *GroupLoader FileByID *FileLoader } @@ -94,10 +94,10 @@ func (m Middleware) Middleware(next http.Handler) http.Handler { maxBatch: maxBatch, fetch: m.fetchTags(ctx), }, - MovieByID: &MovieLoader{ + GroupByID: &GroupLoader{ wait: wait, maxBatch: maxBatch, - fetch: m.fetchMovies(ctx), + fetch: m.fetchGroups(ctx), }, FileByID: &FileLoader{ wait: wait, @@ -232,11 +232,11 @@ func (m Middleware) fetchTags(ctx context.Context) func(keys []int) ([]*models.T } } -func (m Middleware) fetchMovies(ctx context.Context) func(keys []int) ([]*models.Movie, []error) { - return func(keys []int) (ret []*models.Movie, errs []error) { +func (m Middleware) fetchGroups(ctx context.Context) func(keys []int) ([]*models.Group, []error) { + return func(keys []int) (ret []*models.Group, errs []error) { err := m.Repository.WithDB(ctx, func(ctx context.Context) error { var err error - ret, err = m.Repository.Movie.FindMany(ctx, keys) + ret, err = m.Repository.Group.FindMany(ctx, keys) return err }) return ret, toErrorSlice(err) diff --git a/internal/api/loaders/movieloader_gen.go b/internal/api/loaders/movieloader_gen.go index 3783d3a4125..e892f63c20e 100644 --- a/internal/api/loaders/movieloader_gen.go +++ b/internal/api/loaders/movieloader_gen.go @@ -9,10 +9,10 @@ import ( "github.com/stashapp/stash/pkg/models" ) -// MovieLoaderConfig captures the config to create a new MovieLoader -type MovieLoaderConfig struct { +// GroupLoaderConfig captures the config to create a new GroupLoader +type GroupLoaderConfig struct { // Fetch is a method that provides the data for the loader - Fetch func(keys []int) ([]*models.Movie, []error) + Fetch func(keys []int) ([]*models.Group, []error) // Wait is how long wait before sending a batch Wait time.Duration @@ -21,19 +21,19 @@ type MovieLoaderConfig struct { MaxBatch int } -// NewMovieLoader creates a new MovieLoader given a fetch, wait, and maxBatch -func NewMovieLoader(config MovieLoaderConfig) *MovieLoader { - return &MovieLoader{ +// NewGroupLoader creates a new GroupLoader given a fetch, wait, and maxBatch +func NewGroupLoader(config GroupLoaderConfig) *GroupLoader { + return &GroupLoader{ fetch: config.Fetch, wait: config.Wait, maxBatch: config.MaxBatch, } } -// MovieLoader batches and caches requests -type MovieLoader struct { +// GroupLoader batches and caches requests +type GroupLoader struct { // this method provides the data for the loader - fetch func(keys []int) ([]*models.Movie, []error) + fetch func(keys []int) ([]*models.Group, []error) // how long to done before sending a batch wait time.Duration @@ -44,51 +44,51 @@ type MovieLoader struct { // INTERNAL // lazily created cache - cache map[int]*models.Movie + cache map[int]*models.Group // the current batch. keys will continue to be collected until timeout is hit, // then everything will be sent to the fetch method and out to the listeners - batch *movieLoaderBatch + batch *groupLoaderBatch // mutex to prevent races mu sync.Mutex } -type movieLoaderBatch struct { +type groupLoaderBatch struct { keys []int - data []*models.Movie + data []*models.Group error []error closing bool done chan struct{} } -// Load a Movie by key, batching and caching will be applied automatically -func (l *MovieLoader) Load(key int) (*models.Movie, error) { +// Load a Group by key, batching and caching will be applied automatically +func (l *GroupLoader) Load(key int) (*models.Group, error) { return l.LoadThunk(key)() } -// LoadThunk returns a function that when called will block waiting for a Movie. +// LoadThunk returns a function that when called will block waiting for a Group. // This method should be used if you want one goroutine to make requests to many // different data loaders without blocking until the thunk is called. -func (l *MovieLoader) LoadThunk(key int) func() (*models.Movie, error) { +func (l *GroupLoader) LoadThunk(key int) func() (*models.Group, error) { l.mu.Lock() if it, ok := l.cache[key]; ok { l.mu.Unlock() - return func() (*models.Movie, error) { + return func() (*models.Group, error) { return it, nil } } if l.batch == nil { - l.batch = &movieLoaderBatch{done: make(chan struct{})} + l.batch = &groupLoaderBatch{done: make(chan struct{})} } batch := l.batch pos := batch.keyIndex(l, key) l.mu.Unlock() - return func() (*models.Movie, error) { + return func() (*models.Group, error) { <-batch.done - var data *models.Movie + var data *models.Group if pos < len(batch.data) { data = batch.data[pos] } @@ -113,43 +113,43 @@ func (l *MovieLoader) LoadThunk(key int) func() (*models.Movie, error) { // LoadAll fetches many keys at once. It will be broken into appropriate sized // sub batches depending on how the loader is configured -func (l *MovieLoader) LoadAll(keys []int) ([]*models.Movie, []error) { - results := make([]func() (*models.Movie, error), len(keys)) +func (l *GroupLoader) LoadAll(keys []int) ([]*models.Group, []error) { + results := make([]func() (*models.Group, error), len(keys)) for i, key := range keys { results[i] = l.LoadThunk(key) } - movies := make([]*models.Movie, len(keys)) + groups := make([]*models.Group, len(keys)) errors := make([]error, len(keys)) for i, thunk := range results { - movies[i], errors[i] = thunk() + groups[i], errors[i] = thunk() } - return movies, errors + return groups, errors } -// LoadAllThunk returns a function that when called will block waiting for a Movies. +// LoadAllThunk returns a function that when called will block waiting for a Groups. // This method should be used if you want one goroutine to make requests to many // different data loaders without blocking until the thunk is called. -func (l *MovieLoader) LoadAllThunk(keys []int) func() ([]*models.Movie, []error) { - results := make([]func() (*models.Movie, error), len(keys)) +func (l *GroupLoader) LoadAllThunk(keys []int) func() ([]*models.Group, []error) { + results := make([]func() (*models.Group, error), len(keys)) for i, key := range keys { results[i] = l.LoadThunk(key) } - return func() ([]*models.Movie, []error) { - movies := make([]*models.Movie, len(keys)) + return func() ([]*models.Group, []error) { + groups := make([]*models.Group, len(keys)) errors := make([]error, len(keys)) for i, thunk := range results { - movies[i], errors[i] = thunk() + groups[i], errors[i] = thunk() } - return movies, errors + return groups, errors } } // Prime the cache with the provided key and value. If the key already exists, no change is made // and false is returned. // (To forcefully prime the cache, clear the key first with loader.clear(key).prime(key, value).) -func (l *MovieLoader) Prime(key int, value *models.Movie) bool { +func (l *GroupLoader) Prime(key int, value *models.Group) bool { l.mu.Lock() var found bool if _, found = l.cache[key]; !found { @@ -163,22 +163,22 @@ func (l *MovieLoader) Prime(key int, value *models.Movie) bool { } // Clear the value at key from the cache, if it exists -func (l *MovieLoader) Clear(key int) { +func (l *GroupLoader) Clear(key int) { l.mu.Lock() delete(l.cache, key) l.mu.Unlock() } -func (l *MovieLoader) unsafeSet(key int, value *models.Movie) { +func (l *GroupLoader) unsafeSet(key int, value *models.Group) { if l.cache == nil { - l.cache = map[int]*models.Movie{} + l.cache = map[int]*models.Group{} } l.cache[key] = value } // keyIndex will return the location of the key in the batch, if its not found // it will add the key to the batch -func (b *movieLoaderBatch) keyIndex(l *MovieLoader, key int) int { +func (b *groupLoaderBatch) keyIndex(l *GroupLoader, key int) int { for i, existingKey := range b.keys { if key == existingKey { return i @@ -202,7 +202,7 @@ func (b *movieLoaderBatch) keyIndex(l *MovieLoader, key int) int { return pos } -func (b *movieLoaderBatch) startTimer(l *MovieLoader) { +func (b *groupLoaderBatch) startTimer(l *GroupLoader) { time.Sleep(l.wait) l.mu.Lock() @@ -218,7 +218,7 @@ func (b *movieLoaderBatch) startTimer(l *MovieLoader) { b.end(l) } -func (b *movieLoaderBatch) end(l *MovieLoader) { +func (b *groupLoaderBatch) end(l *GroupLoader) { b.data, b.error = l.fetch(b.keys) close(b.done) } diff --git a/internal/api/resolver.go b/internal/api/resolver.go index 78ec0fc580d..e5c635b9a7d 100644 --- a/internal/api/resolver.go +++ b/internal/api/resolver.go @@ -74,10 +74,10 @@ func (r *Resolver) Studio() StudioResolver { } func (r *Resolver) Group() GroupResolver { - return &groupResolver{&movieResolver{r}} + return &groupResolver{r} } func (r *Resolver) Movie() MovieResolver { - return &movieResolver{r} + return &movieResolver{&groupResolver{r}} } func (r *Resolver) Subscription() SubscriptionResolver { @@ -117,9 +117,9 @@ type sceneMarkerResolver struct{ *Resolver } type imageResolver struct{ *Resolver } type studioResolver struct{ *Resolver } -// group is movie under the hood -type movieResolver struct{ *Resolver } -type groupResolver struct{ *movieResolver } +// movie is group under the hood +type groupResolver struct{ *Resolver } +type movieResolver struct{ *groupResolver } type tagResolver struct{ *Resolver } type galleryFileResolver struct{ *Resolver } @@ -182,7 +182,7 @@ func (r *queryResolver) Stats(ctx context.Context) (*StatsResultType, error) { galleryQB := repo.Gallery studioQB := repo.Studio performerQB := repo.Performer - movieQB := repo.Movie + movieQB := repo.Group tagQB := repo.Tag // embrace the error diff --git a/internal/api/resolver_model_movie.go b/internal/api/resolver_model_movie.go index d1509c7a18a..abbbccaf10a 100644 --- a/internal/api/resolver_model_movie.go +++ b/internal/api/resolver_model_movie.go @@ -8,7 +8,7 @@ import ( "github.com/stashapp/stash/pkg/models" ) -func (r *movieResolver) Date(ctx context.Context, obj *models.Movie) (*string, error) { +func (r *groupResolver) Date(ctx context.Context, obj *models.Group) (*string, error) { if obj.Date != nil { result := obj.Date.String() return &result, nil @@ -16,14 +16,14 @@ func (r *movieResolver) Date(ctx context.Context, obj *models.Movie) (*string, e return nil, nil } -func (r *movieResolver) Rating100(ctx context.Context, obj *models.Movie) (*int, error) { +func (r *groupResolver) Rating100(ctx context.Context, obj *models.Group) (*int, error) { return obj.Rating, nil } -func (r *movieResolver) URL(ctx context.Context, obj *models.Movie) (*string, error) { +func (r *groupResolver) URL(ctx context.Context, obj *models.Group) (*string, error) { if !obj.URLs.Loaded() { if err := r.withReadTxn(ctx, func(ctx context.Context) error { - return obj.LoadURLs(ctx, r.repository.Movie) + return obj.LoadURLs(ctx, r.repository.Group) }); err != nil { return nil, err } @@ -37,10 +37,10 @@ func (r *movieResolver) URL(ctx context.Context, obj *models.Movie) (*string, er return &urls[0], nil } -func (r *movieResolver) Urls(ctx context.Context, obj *models.Movie) ([]string, error) { +func (r *groupResolver) Urls(ctx context.Context, obj *models.Group) ([]string, error) { if !obj.URLs.Loaded() { if err := r.withReadTxn(ctx, func(ctx context.Context) error { - return obj.LoadURLs(ctx, r.repository.Movie) + return obj.LoadURLs(ctx, r.repository.Group) }); err != nil { return nil, err } @@ -49,7 +49,7 @@ func (r *movieResolver) Urls(ctx context.Context, obj *models.Movie) ([]string, return obj.URLs.List(), nil } -func (r *movieResolver) Studio(ctx context.Context, obj *models.Movie) (ret *models.Studio, err error) { +func (r *groupResolver) Studio(ctx context.Context, obj *models.Group) (ret *models.Studio, err error) { if obj.StudioID == nil { return nil, nil } @@ -57,10 +57,10 @@ func (r *movieResolver) Studio(ctx context.Context, obj *models.Movie) (ret *mod return loaders.From(ctx).StudioByID.Load(*obj.StudioID) } -func (r movieResolver) Tags(ctx context.Context, obj *models.Movie) (ret []*models.Tag, err error) { +func (r groupResolver) Tags(ctx context.Context, obj *models.Group) (ret []*models.Tag, err error) { if !obj.TagIDs.Loaded() { if err := r.withReadTxn(ctx, func(ctx context.Context) error { - return obj.LoadTagIDs(ctx, r.repository.Movie) + return obj.LoadTagIDs(ctx, r.repository.Group) }); err != nil { return nil, err } @@ -71,26 +71,26 @@ func (r movieResolver) Tags(ctx context.Context, obj *models.Movie) (ret []*mode return ret, firstError(errs) } -func (r *movieResolver) FrontImagePath(ctx context.Context, obj *models.Movie) (*string, error) { +func (r *groupResolver) FrontImagePath(ctx context.Context, obj *models.Group) (*string, error) { var hasImage bool if err := r.withReadTxn(ctx, func(ctx context.Context) error { var err error - hasImage, err = r.repository.Movie.HasFrontImage(ctx, obj.ID) + hasImage, err = r.repository.Group.HasFrontImage(ctx, obj.ID) return err }); err != nil { return nil, err } baseURL, _ := ctx.Value(BaseURLCtxKey).(string) - imagePath := urlbuilders.NewMovieURLBuilder(baseURL, obj).GetMovieFrontImageURL(hasImage) + imagePath := urlbuilders.NewGroupURLBuilder(baseURL, obj).GetGroupFrontImageURL(hasImage) return &imagePath, nil } -func (r *movieResolver) BackImagePath(ctx context.Context, obj *models.Movie) (*string, error) { +func (r *groupResolver) BackImagePath(ctx context.Context, obj *models.Group) (*string, error) { var hasImage bool if err := r.withReadTxn(ctx, func(ctx context.Context) error { var err error - hasImage, err = r.repository.Movie.HasBackImage(ctx, obj.ID) + hasImage, err = r.repository.Group.HasBackImage(ctx, obj.ID) return err }); err != nil { return nil, err @@ -102,13 +102,13 @@ func (r *movieResolver) BackImagePath(ctx context.Context, obj *models.Movie) (* } baseURL, _ := ctx.Value(BaseURLCtxKey).(string) - imagePath := urlbuilders.NewMovieURLBuilder(baseURL, obj).GetMovieBackImageURL() + imagePath := urlbuilders.NewGroupURLBuilder(baseURL, obj).GetGroupBackImageURL() return &imagePath, nil } -func (r *movieResolver) SceneCount(ctx context.Context, obj *models.Movie) (ret int, err error) { +func (r *groupResolver) SceneCount(ctx context.Context, obj *models.Group) (ret int, err error) { if err := r.withReadTxn(ctx, func(ctx context.Context) error { - ret, err = r.repository.Scene.CountByMovieID(ctx, obj.ID) + ret, err = r.repository.Scene.CountByGroupID(ctx, obj.ID) return err }); err != nil { return 0, err @@ -117,10 +117,10 @@ func (r *movieResolver) SceneCount(ctx context.Context, obj *models.Movie) (ret return ret, nil } -func (r *movieResolver) Scenes(ctx context.Context, obj *models.Movie) (ret []*models.Scene, err error) { +func (r *groupResolver) Scenes(ctx context.Context, obj *models.Group) (ret []*models.Scene, err error) { if err := r.withReadTxn(ctx, func(ctx context.Context) error { var err error - ret, err = r.repository.Scene.FindByMovieID(ctx, obj.ID) + ret, err = r.repository.Scene.FindByGroupID(ctx, obj.ID) return err }); err != nil { return nil, err diff --git a/internal/api/resolver_model_performer.go b/internal/api/resolver_model_performer.go index 224e733bdac..b6f6af369ad 100644 --- a/internal/api/resolver_model_performer.go +++ b/internal/api/resolver_model_performer.go @@ -181,7 +181,7 @@ func (r *performerResolver) GalleryCount(ctx context.Context, obj *models.Perfor func (r *performerResolver) GroupCount(ctx context.Context, obj *models.Performer) (ret int, err error) { if err := r.withReadTxn(ctx, func(ctx context.Context) error { - ret, err = r.repository.Movie.CountByPerformerID(ctx, obj.ID) + ret, err = r.repository.Group.CountByPerformerID(ctx, obj.ID) return err }); err != nil { return 0, err @@ -257,9 +257,9 @@ func (r *performerResolver) DeathDate(ctx context.Context, obj *models.Performer return nil, nil } -func (r *performerResolver) Groups(ctx context.Context, obj *models.Performer) (ret []*models.Movie, err error) { +func (r *performerResolver) Groups(ctx context.Context, obj *models.Performer) (ret []*models.Group, err error) { if err := r.withReadTxn(ctx, func(ctx context.Context) error { - ret, err = r.repository.Movie.FindByPerformerID(ctx, obj.ID) + ret, err = r.repository.Group.FindByPerformerID(ctx, obj.ID) return err }); err != nil { return nil, err @@ -269,6 +269,6 @@ func (r *performerResolver) Groups(ctx context.Context, obj *models.Performer) ( } // deprecated -func (r *performerResolver) Movies(ctx context.Context, obj *models.Performer) (ret []*models.Movie, err error) { +func (r *performerResolver) Movies(ctx context.Context, obj *models.Performer) (ret []*models.Group, err error) { return r.Groups(ctx, obj) } diff --git a/internal/api/resolver_model_scene.go b/internal/api/resolver_model_scene.go index 987c6e7b80e..2600c9538aa 100644 --- a/internal/api/resolver_model_scene.go +++ b/internal/api/resolver_model_scene.go @@ -184,20 +184,20 @@ 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 !obj.Groups.Loaded() { if err := r.withReadTxn(ctx, func(ctx context.Context) error { qb := r.repository.Scene - return obj.LoadMovies(ctx, qb) + return obj.LoadGroups(ctx, qb) }); err != nil { return nil, err } } - loader := loaders.From(ctx).MovieByID + loader := loaders.From(ctx).GroupByID - for _, sm := range obj.Movies.List() { - movie, err := loader.Load(sm.MovieID) + for _, sm := range obj.Groups.List() { + movie, err := loader.Load(sm.GroupID) if err != nil { return nil, err } @@ -215,27 +215,27 @@ func (r *sceneResolver) Movies(ctx context.Context, obj *models.Scene) (ret []*S } func (r *sceneResolver) Groups(ctx context.Context, obj *models.Scene) (ret []*SceneGroup, err error) { - if !obj.Movies.Loaded() { + if !obj.Groups.Loaded() { if err := r.withReadTxn(ctx, func(ctx context.Context) error { qb := r.repository.Scene - return obj.LoadMovies(ctx, qb) + return obj.LoadGroups(ctx, qb) }); err != nil { return nil, err } } - loader := loaders.From(ctx).MovieByID + loader := loaders.From(ctx).GroupByID - for _, sm := range obj.Movies.List() { - movie, err := loader.Load(sm.MovieID) + for _, sm := range obj.Groups.List() { + group, err := loader.Load(sm.GroupID) if err != nil { return nil, err } sceneIdx := sm.SceneIndex sceneGroup := &SceneGroup{ - Group: movie, + Group: group, SceneIndex: sceneIdx, } diff --git a/internal/api/resolver_model_studio.go b/internal/api/resolver_model_studio.go index 1f8142e9997..9dcfd5df5b8 100644 --- a/internal/api/resolver_model_studio.go +++ b/internal/api/resolver_model_studio.go @@ -100,7 +100,7 @@ func (r *studioResolver) PerformerCount(ctx context.Context, obj *models.Studio, func (r *studioResolver) GroupCount(ctx context.Context, obj *models.Studio, depth *int) (ret int, err error) { if err := r.withReadTxn(ctx, func(ctx context.Context) error { - ret, err = movie.CountByStudioID(ctx, r.repository.Movie, obj.ID, depth) + ret, err = movie.CountByStudioID(ctx, r.repository.Group, obj.ID, depth) return err }); err != nil { return 0, err @@ -149,9 +149,9 @@ func (r *studioResolver) Rating100(ctx context.Context, obj *models.Studio) (*in return obj.Rating, nil } -func (r *studioResolver) Groups(ctx context.Context, obj *models.Studio) (ret []*models.Movie, err error) { +func (r *studioResolver) Groups(ctx context.Context, obj *models.Studio) (ret []*models.Group, err error) { if err := r.withReadTxn(ctx, func(ctx context.Context) error { - ret, err = r.repository.Movie.FindByStudioID(ctx, obj.ID) + ret, err = r.repository.Group.FindByStudioID(ctx, obj.ID) return err }); err != nil { return nil, err @@ -161,6 +161,6 @@ func (r *studioResolver) Groups(ctx context.Context, obj *models.Studio) (ret [] } // deprecated -func (r *studioResolver) Movies(ctx context.Context, obj *models.Studio) (ret []*models.Movie, err error) { +func (r *studioResolver) Movies(ctx context.Context, obj *models.Studio) (ret []*models.Group, err error) { return r.Groups(ctx, obj) } diff --git a/internal/api/resolver_model_tag.go b/internal/api/resolver_model_tag.go index 3cf0bd1d9ab..11b9f935b5e 100644 --- a/internal/api/resolver_model_tag.go +++ b/internal/api/resolver_model_tag.go @@ -122,7 +122,7 @@ func (r *tagResolver) StudioCount(ctx context.Context, obj *models.Tag, depth *i func (r *tagResolver) GroupCount(ctx context.Context, obj *models.Tag, depth *int) (ret int, err error) { if err := r.withReadTxn(ctx, func(ctx context.Context) error { - ret, err = movie.CountByTagID(ctx, r.repository.Movie, obj.ID, depth) + ret, err = movie.CountByTagID(ctx, r.repository.Group, obj.ID, depth) return err }); err != nil { return 0, err diff --git a/internal/api/resolver_mutation_group.go b/internal/api/resolver_mutation_group.go index 1645d80b37c..d455dd1058c 100644 --- a/internal/api/resolver_mutation_group.go +++ b/internal/api/resolver_mutation_group.go @@ -12,46 +12,46 @@ import ( "github.com/stashapp/stash/pkg/utils" ) -func movieFromGroupCreateInput(ctx context.Context, input GroupCreateInput) (*models.Movie, error) { +func groupFromGroupCreateInput(ctx context.Context, input GroupCreateInput) (*models.Group, error) { translator := changesetTranslator{ inputMap: getUpdateInputMap(ctx), } - // Populate a new movie from the input - newMovie := models.NewMovie() + // Populate a new group from the input + newGroup := models.NewGroup() - newMovie.Name = input.Name - newMovie.Aliases = translator.string(input.Aliases) - newMovie.Duration = input.Duration - newMovie.Rating = input.Rating100 - newMovie.Director = translator.string(input.Director) - newMovie.Synopsis = translator.string(input.Synopsis) + newGroup.Name = input.Name + newGroup.Aliases = translator.string(input.Aliases) + newGroup.Duration = input.Duration + newGroup.Rating = input.Rating100 + newGroup.Director = translator.string(input.Director) + newGroup.Synopsis = translator.string(input.Synopsis) var err error - newMovie.Date, err = translator.datePtr(input.Date) + newGroup.Date, err = translator.datePtr(input.Date) if err != nil { return nil, fmt.Errorf("converting date: %w", err) } - newMovie.StudioID, err = translator.intPtrFromString(input.StudioID) + newGroup.StudioID, err = translator.intPtrFromString(input.StudioID) if err != nil { return nil, fmt.Errorf("converting studio id: %w", err) } - newMovie.TagIDs, err = translator.relatedIds(input.TagIds) + newGroup.TagIDs, err = translator.relatedIds(input.TagIds) if err != nil { return nil, fmt.Errorf("converting tag ids: %w", err) } if input.Urls != nil { - newMovie.URLs = models.NewRelatedStrings(input.Urls) + newGroup.URLs = models.NewRelatedStrings(input.Urls) } - return &newMovie, nil + return &newGroup, nil } -func (r *mutationResolver) GroupCreate(ctx context.Context, input GroupCreateInput) (*models.Movie, error) { - newMovie, err := movieFromGroupCreateInput(ctx, input) +func (r *mutationResolver) GroupCreate(ctx context.Context, input GroupCreateInput) (*models.Group, error) { + newGroup, err := groupFromGroupCreateInput(ctx, input) if err != nil { return nil, err } @@ -77,27 +77,27 @@ func (r *mutationResolver) GroupCreate(ctx context.Context, input GroupCreateInp // HACK: if back image is being set, set the front image to the default. // This is because we can't have a null front image with a non-null back image. if len(frontimageData) == 0 && len(backimageData) != 0 { - frontimageData = static.ReadAll(static.DefaultMovieImage) + frontimageData = static.ReadAll(static.DefaultGroupImage) } - // Start the transaction and save the movie + // Start the transaction and save the group if err := r.withTxn(ctx, func(ctx context.Context) error { - qb := r.repository.Movie + qb := r.repository.Group - err = qb.Create(ctx, newMovie) + err = qb.Create(ctx, newGroup) if err != nil { return err } // update image table if len(frontimageData) > 0 { - if err := qb.UpdateFrontImage(ctx, newMovie.ID, frontimageData); err != nil { + if err := qb.UpdateFrontImage(ctx, newGroup.ID, frontimageData); err != nil { return err } } if len(backimageData) > 0 { - if err := qb.UpdateBackImage(ctx, newMovie.ID, backimageData); err != nil { + if err := qb.UpdateBackImage(ctx, newGroup.ID, backimageData); err != nil { return err } } @@ -108,46 +108,46 @@ func (r *mutationResolver) GroupCreate(ctx context.Context, input GroupCreateInp } // for backwards compatibility - run both movie and group hooks - r.hookExecutor.ExecutePostHooks(ctx, newMovie.ID, hook.GroupCreatePost, input, nil) - r.hookExecutor.ExecutePostHooks(ctx, newMovie.ID, hook.MovieCreatePost, input, nil) - return r.getMovie(ctx, newMovie.ID) + r.hookExecutor.ExecutePostHooks(ctx, newGroup.ID, hook.GroupCreatePost, input, nil) + r.hookExecutor.ExecutePostHooks(ctx, newGroup.ID, hook.MovieCreatePost, input, nil) + return r.getGroup(ctx, newGroup.ID) } -func moviePartialFromGroupUpdateInput(translator changesetTranslator, input GroupUpdateInput) (ret models.MoviePartial, err error) { - // Populate movie from the input - updatedMovie := models.NewMoviePartial() +func groupPartialFromGroupUpdateInput(translator changesetTranslator, input GroupUpdateInput) (ret models.GroupPartial, err error) { + // Populate group from the input + updatedGroup := models.NewGroupPartial() - updatedMovie.Name = translator.optionalString(input.Name, "name") - updatedMovie.Aliases = translator.optionalString(input.Aliases, "aliases") - updatedMovie.Duration = translator.optionalInt(input.Duration, "duration") - updatedMovie.Rating = translator.optionalInt(input.Rating100, "rating100") - updatedMovie.Director = translator.optionalString(input.Director, "director") - updatedMovie.Synopsis = translator.optionalString(input.Synopsis, "synopsis") + updatedGroup.Name = translator.optionalString(input.Name, "name") + updatedGroup.Aliases = translator.optionalString(input.Aliases, "aliases") + updatedGroup.Duration = translator.optionalInt(input.Duration, "duration") + updatedGroup.Rating = translator.optionalInt(input.Rating100, "rating100") + updatedGroup.Director = translator.optionalString(input.Director, "director") + updatedGroup.Synopsis = translator.optionalString(input.Synopsis, "synopsis") - updatedMovie.Date, err = translator.optionalDate(input.Date, "date") + updatedGroup.Date, err = translator.optionalDate(input.Date, "date") if err != nil { err = fmt.Errorf("converting date: %w", err) return } - updatedMovie.StudioID, err = translator.optionalIntFromString(input.StudioID, "studio_id") + updatedGroup.StudioID, err = translator.optionalIntFromString(input.StudioID, "studio_id") if err != nil { err = fmt.Errorf("converting studio id: %w", err) return } - updatedMovie.TagIDs, err = translator.updateIds(input.TagIds, "tag_ids") + updatedGroup.TagIDs, err = translator.updateIds(input.TagIds, "tag_ids") if err != nil { err = fmt.Errorf("converting tag ids: %w", err) return } - updatedMovie.URLs = translator.updateStrings(input.Urls, "urls") + updatedGroup.URLs = translator.updateStrings(input.Urls, "urls") - return updatedMovie, nil + return updatedGroup, nil } -func (r *mutationResolver) GroupUpdate(ctx context.Context, input GroupUpdateInput) (*models.Movie, error) { - movieID, err := strconv.Atoi(input.ID) +func (r *mutationResolver) GroupUpdate(ctx context.Context, input GroupUpdateInput) (*models.Group, error) { + groupID, err := strconv.Atoi(input.ID) if err != nil { return nil, fmt.Errorf("converting id: %w", err) } @@ -156,7 +156,7 @@ func (r *mutationResolver) GroupUpdate(ctx context.Context, input GroupUpdateInp inputMap: getUpdateInputMap(ctx), } - updatedMovie, err := moviePartialFromGroupUpdateInput(translator, input) + updatedGroup, err := groupPartialFromGroupUpdateInput(translator, input) if err != nil { return nil, err } @@ -179,24 +179,24 @@ func (r *mutationResolver) GroupUpdate(ctx context.Context, input GroupUpdateInp } } - // Start the transaction and save the movie - var movie *models.Movie + // Start the transaction and save the group + var group *models.Group if err := r.withTxn(ctx, func(ctx context.Context) error { - qb := r.repository.Movie - movie, err = qb.UpdatePartial(ctx, movieID, updatedMovie) + qb := r.repository.Group + group, err = qb.UpdatePartial(ctx, groupID, updatedGroup) if err != nil { return err } // update image table if frontImageIncluded { - if err := qb.UpdateFrontImage(ctx, movie.ID, frontimageData); err != nil { + if err := qb.UpdateFrontImage(ctx, group.ID, frontimageData); err != nil { return err } } if backImageIncluded { - if err := qb.UpdateBackImage(ctx, movie.ID, backimageData); err != nil { + if err := qb.UpdateBackImage(ctx, group.ID, backimageData); err != nil { return err } } @@ -207,36 +207,36 @@ func (r *mutationResolver) GroupUpdate(ctx context.Context, input GroupUpdateInp } // for backwards compatibility - run both movie and group hooks - r.hookExecutor.ExecutePostHooks(ctx, movie.ID, hook.GroupUpdatePost, input, translator.getFields()) - r.hookExecutor.ExecutePostHooks(ctx, movie.ID, hook.MovieUpdatePost, input, translator.getFields()) - return r.getMovie(ctx, movie.ID) + r.hookExecutor.ExecutePostHooks(ctx, group.ID, hook.GroupUpdatePost, input, translator.getFields()) + r.hookExecutor.ExecutePostHooks(ctx, group.ID, hook.MovieUpdatePost, input, translator.getFields()) + return r.getGroup(ctx, group.ID) } -func moviePartialFromBulkGroupUpdateInput(translator changesetTranslator, input BulkGroupUpdateInput) (ret models.MoviePartial, err error) { - updatedMovie := models.NewMoviePartial() +func groupPartialFromBulkGroupUpdateInput(translator changesetTranslator, input BulkGroupUpdateInput) (ret models.GroupPartial, err error) { + updatedGroup := models.NewGroupPartial() - updatedMovie.Rating = translator.optionalInt(input.Rating100, "rating100") - updatedMovie.Director = translator.optionalString(input.Director, "director") + updatedGroup.Rating = translator.optionalInt(input.Rating100, "rating100") + updatedGroup.Director = translator.optionalString(input.Director, "director") - updatedMovie.StudioID, err = translator.optionalIntFromString(input.StudioID, "studio_id") + updatedGroup.StudioID, err = translator.optionalIntFromString(input.StudioID, "studio_id") if err != nil { err = fmt.Errorf("converting studio id: %w", err) return } - updatedMovie.TagIDs, err = translator.updateIdsBulk(input.TagIds, "tag_ids") + updatedGroup.TagIDs, err = translator.updateIdsBulk(input.TagIds, "tag_ids") if err != nil { err = fmt.Errorf("converting tag ids: %w", err) return } - updatedMovie.URLs = translator.optionalURLsBulk(input.Urls, nil) + updatedGroup.URLs = translator.optionalURLsBulk(input.Urls, nil) - return updatedMovie, nil + return updatedGroup, nil } -func (r *mutationResolver) BulkGroupUpdate(ctx context.Context, input BulkGroupUpdateInput) ([]*models.Movie, error) { - movieIDs, err := stringslice.StringSliceToIntSlice(input.Ids) +func (r *mutationResolver) BulkGroupUpdate(ctx context.Context, input BulkGroupUpdateInput) ([]*models.Group, error) { + groupIDs, err := stringslice.StringSliceToIntSlice(input.Ids) if err != nil { return nil, fmt.Errorf("converting ids: %w", err) } @@ -245,24 +245,24 @@ func (r *mutationResolver) BulkGroupUpdate(ctx context.Context, input BulkGroupU inputMap: getUpdateInputMap(ctx), } - // Populate movie from the input - updatedMovie, err := moviePartialFromBulkGroupUpdateInput(translator, input) + // Populate group from the input + updatedGroup, err := groupPartialFromBulkGroupUpdateInput(translator, input) if err != nil { return nil, err } - ret := []*models.Movie{} + ret := []*models.Group{} if err := r.withTxn(ctx, func(ctx context.Context) error { - qb := r.repository.Movie + qb := r.repository.Group - for _, movieID := range movieIDs { - movie, err := qb.UpdatePartial(ctx, movieID, updatedMovie) + for _, groupID := range groupIDs { + group, err := qb.UpdatePartial(ctx, groupID, updatedGroup) if err != nil { return err } - ret = append(ret, movie) + ret = append(ret, group) } return nil @@ -270,18 +270,18 @@ func (r *mutationResolver) BulkGroupUpdate(ctx context.Context, input BulkGroupU return nil, err } - var newRet []*models.Movie - for _, movie := range ret { + var newRet []*models.Group + for _, group := range ret { // for backwards compatibility - run both movie and group hooks - r.hookExecutor.ExecutePostHooks(ctx, movie.ID, hook.GroupUpdatePost, input, translator.getFields()) - r.hookExecutor.ExecutePostHooks(ctx, movie.ID, hook.MovieUpdatePost, input, translator.getFields()) + r.hookExecutor.ExecutePostHooks(ctx, group.ID, hook.GroupUpdatePost, input, translator.getFields()) + r.hookExecutor.ExecutePostHooks(ctx, group.ID, hook.MovieUpdatePost, input, translator.getFields()) - movie, err = r.getMovie(ctx, movie.ID) + group, err = r.getGroup(ctx, group.ID) if err != nil { return nil, err } - newRet = append(newRet, movie) + newRet = append(newRet, group) } return newRet, nil @@ -294,7 +294,7 @@ func (r *mutationResolver) GroupDestroy(ctx context.Context, input GroupDestroyI } if err := r.withTxn(ctx, func(ctx context.Context) error { - return r.repository.Movie.Destroy(ctx, id) + return r.repository.Group.Destroy(ctx, id) }); err != nil { return false, err } @@ -313,7 +313,7 @@ func (r *mutationResolver) GroupsDestroy(ctx context.Context, groupIDs []string) } if err := r.withTxn(ctx, func(ctx context.Context) error { - qb := r.repository.Movie + qb := r.repository.Group for _, id := range ids { if err := qb.Destroy(ctx, id); err != nil { return err diff --git a/internal/api/resolver_mutation_movie.go b/internal/api/resolver_mutation_movie.go index 3e73f32ddd3..2e1011083c0 100644 --- a/internal/api/resolver_mutation_movie.go +++ b/internal/api/resolver_mutation_movie.go @@ -12,10 +12,10 @@ import ( "github.com/stashapp/stash/pkg/utils" ) -// used to refetch movie after hooks run -func (r *mutationResolver) getMovie(ctx context.Context, id int) (ret *models.Movie, err error) { +// used to refetch group after hooks run +func (r *mutationResolver) getGroup(ctx context.Context, id int) (ret *models.Group, err error) { if err := r.withTxn(ctx, func(ctx context.Context) error { - ret, err = r.repository.Movie.Find(ctx, id) + ret, err = r.repository.Group.Find(ctx, id) return err }); err != nil { return nil, err @@ -24,41 +24,41 @@ func (r *mutationResolver) getMovie(ctx context.Context, id int) (ret *models.Mo return ret, nil } -func (r *mutationResolver) MovieCreate(ctx context.Context, input MovieCreateInput) (*models.Movie, error) { +func (r *mutationResolver) MovieCreate(ctx context.Context, input MovieCreateInput) (*models.Group, error) { translator := changesetTranslator{ inputMap: getUpdateInputMap(ctx), } - // Populate a new movie from the input - newMovie := models.NewMovie() + // Populate a new group from the input + newGroup := models.NewGroup() - newMovie.Name = input.Name - newMovie.Aliases = translator.string(input.Aliases) - newMovie.Duration = input.Duration - newMovie.Rating = input.Rating100 - newMovie.Director = translator.string(input.Director) - newMovie.Synopsis = translator.string(input.Synopsis) + newGroup.Name = input.Name + newGroup.Aliases = translator.string(input.Aliases) + newGroup.Duration = input.Duration + newGroup.Rating = input.Rating100 + newGroup.Director = translator.string(input.Director) + newGroup.Synopsis = translator.string(input.Synopsis) var err error - newMovie.Date, err = translator.datePtr(input.Date) + newGroup.Date, err = translator.datePtr(input.Date) if err != nil { return nil, fmt.Errorf("converting date: %w", err) } - newMovie.StudioID, err = translator.intPtrFromString(input.StudioID) + newGroup.StudioID, err = translator.intPtrFromString(input.StudioID) if err != nil { return nil, fmt.Errorf("converting studio id: %w", err) } - newMovie.TagIDs, err = translator.relatedIds(input.TagIds) + newGroup.TagIDs, err = translator.relatedIds(input.TagIds) if err != nil { return nil, fmt.Errorf("converting tag ids: %w", err) } if input.Urls != nil { - newMovie.URLs = models.NewRelatedStrings(input.Urls) + newGroup.URLs = models.NewRelatedStrings(input.Urls) } else if input.URL != nil { - newMovie.URLs = models.NewRelatedStrings([]string{*input.URL}) + newGroup.URLs = models.NewRelatedStrings([]string{*input.URL}) } // Process the base 64 encoded image string @@ -82,27 +82,27 @@ func (r *mutationResolver) MovieCreate(ctx context.Context, input MovieCreateInp // HACK: if back image is being set, set the front image to the default. // This is because we can't have a null front image with a non-null back image. if len(frontimageData) == 0 && len(backimageData) != 0 { - frontimageData = static.ReadAll(static.DefaultMovieImage) + frontimageData = static.ReadAll(static.DefaultGroupImage) } - // Start the transaction and save the movie + // Start the transaction and save the group if err := r.withTxn(ctx, func(ctx context.Context) error { - qb := r.repository.Movie + qb := r.repository.Group - err = qb.Create(ctx, &newMovie) + err = qb.Create(ctx, &newGroup) if err != nil { return err } // update image table if len(frontimageData) > 0 { - if err := qb.UpdateFrontImage(ctx, newMovie.ID, frontimageData); err != nil { + if err := qb.UpdateFrontImage(ctx, newGroup.ID, frontimageData); err != nil { return err } } if len(backimageData) > 0 { - if err := qb.UpdateBackImage(ctx, newMovie.ID, backimageData); err != nil { + if err := qb.UpdateBackImage(ctx, newGroup.ID, backimageData); err != nil { return err } } @@ -113,13 +113,13 @@ func (r *mutationResolver) MovieCreate(ctx context.Context, input MovieCreateInp } // for backwards compatibility - run both movie and group hooks - r.hookExecutor.ExecutePostHooks(ctx, newMovie.ID, hook.GroupCreatePost, input, nil) - r.hookExecutor.ExecutePostHooks(ctx, newMovie.ID, hook.MovieCreatePost, input, nil) - return r.getMovie(ctx, newMovie.ID) + r.hookExecutor.ExecutePostHooks(ctx, newGroup.ID, hook.GroupCreatePost, input, nil) + r.hookExecutor.ExecutePostHooks(ctx, newGroup.ID, hook.MovieCreatePost, input, nil) + return r.getGroup(ctx, newGroup.ID) } -func (r *mutationResolver) MovieUpdate(ctx context.Context, input MovieUpdateInput) (*models.Movie, error) { - movieID, err := strconv.Atoi(input.ID) +func (r *mutationResolver) MovieUpdate(ctx context.Context, input MovieUpdateInput) (*models.Group, error) { + groupID, err := strconv.Atoi(input.ID) if err != nil { return nil, fmt.Errorf("converting id: %w", err) } @@ -128,31 +128,31 @@ func (r *mutationResolver) MovieUpdate(ctx context.Context, input MovieUpdateInp inputMap: getUpdateInputMap(ctx), } - // Populate movie from the input - updatedMovie := models.NewMoviePartial() + // Populate group from the input + updatedGroup := models.NewGroupPartial() - updatedMovie.Name = translator.optionalString(input.Name, "name") - updatedMovie.Aliases = translator.optionalString(input.Aliases, "aliases") - updatedMovie.Duration = translator.optionalInt(input.Duration, "duration") - updatedMovie.Rating = translator.optionalInt(input.Rating100, "rating100") - updatedMovie.Director = translator.optionalString(input.Director, "director") - updatedMovie.Synopsis = translator.optionalString(input.Synopsis, "synopsis") + updatedGroup.Name = translator.optionalString(input.Name, "name") + updatedGroup.Aliases = translator.optionalString(input.Aliases, "aliases") + updatedGroup.Duration = translator.optionalInt(input.Duration, "duration") + updatedGroup.Rating = translator.optionalInt(input.Rating100, "rating100") + updatedGroup.Director = translator.optionalString(input.Director, "director") + updatedGroup.Synopsis = translator.optionalString(input.Synopsis, "synopsis") - updatedMovie.Date, err = translator.optionalDate(input.Date, "date") + updatedGroup.Date, err = translator.optionalDate(input.Date, "date") if err != nil { return nil, fmt.Errorf("converting date: %w", err) } - updatedMovie.StudioID, err = translator.optionalIntFromString(input.StudioID, "studio_id") + updatedGroup.StudioID, err = translator.optionalIntFromString(input.StudioID, "studio_id") if err != nil { return nil, fmt.Errorf("converting studio id: %w", err) } - updatedMovie.TagIDs, err = translator.updateIds(input.TagIds, "tag_ids") + updatedGroup.TagIDs, err = translator.updateIds(input.TagIds, "tag_ids") if err != nil { return nil, fmt.Errorf("converting tag ids: %w", err) } - updatedMovie.URLs = translator.optionalURLs(input.Urls, input.URL) + updatedGroup.URLs = translator.optionalURLs(input.Urls, input.URL) var frontimageData []byte frontImageIncluded := translator.hasField("front_image") @@ -172,24 +172,24 @@ func (r *mutationResolver) MovieUpdate(ctx context.Context, input MovieUpdateInp } } - // Start the transaction and save the movie - var movie *models.Movie + // Start the transaction and save the group + var group *models.Group if err := r.withTxn(ctx, func(ctx context.Context) error { - qb := r.repository.Movie - movie, err = qb.UpdatePartial(ctx, movieID, updatedMovie) + qb := r.repository.Group + group, err = qb.UpdatePartial(ctx, groupID, updatedGroup) if err != nil { return err } // update image table if frontImageIncluded { - if err := qb.UpdateFrontImage(ctx, movie.ID, frontimageData); err != nil { + if err := qb.UpdateFrontImage(ctx, group.ID, frontimageData); err != nil { return err } } if backImageIncluded { - if err := qb.UpdateBackImage(ctx, movie.ID, backimageData); err != nil { + if err := qb.UpdateBackImage(ctx, group.ID, backimageData); err != nil { return err } } @@ -200,13 +200,13 @@ func (r *mutationResolver) MovieUpdate(ctx context.Context, input MovieUpdateInp } // for backwards compatibility - run both movie and group hooks - r.hookExecutor.ExecutePostHooks(ctx, movie.ID, hook.GroupUpdatePost, input, translator.getFields()) - r.hookExecutor.ExecutePostHooks(ctx, movie.ID, hook.MovieUpdatePost, input, translator.getFields()) - return r.getMovie(ctx, movie.ID) + r.hookExecutor.ExecutePostHooks(ctx, group.ID, hook.GroupUpdatePost, input, translator.getFields()) + r.hookExecutor.ExecutePostHooks(ctx, group.ID, hook.MovieUpdatePost, input, translator.getFields()) + return r.getGroup(ctx, group.ID) } -func (r *mutationResolver) BulkMovieUpdate(ctx context.Context, input BulkMovieUpdateInput) ([]*models.Movie, error) { - movieIDs, err := stringslice.StringSliceToIntSlice(input.Ids) +func (r *mutationResolver) BulkMovieUpdate(ctx context.Context, input BulkMovieUpdateInput) ([]*models.Group, error) { + groupIDs, err := stringslice.StringSliceToIntSlice(input.Ids) if err != nil { return nil, fmt.Errorf("converting ids: %w", err) } @@ -215,36 +215,36 @@ func (r *mutationResolver) BulkMovieUpdate(ctx context.Context, input BulkMovieU inputMap: getUpdateInputMap(ctx), } - // Populate movie from the input - updatedMovie := models.NewMoviePartial() + // Populate group from the input + updatedGroup := models.NewGroupPartial() - updatedMovie.Rating = translator.optionalInt(input.Rating100, "rating100") - updatedMovie.Director = translator.optionalString(input.Director, "director") + updatedGroup.Rating = translator.optionalInt(input.Rating100, "rating100") + updatedGroup.Director = translator.optionalString(input.Director, "director") - updatedMovie.StudioID, err = translator.optionalIntFromString(input.StudioID, "studio_id") + updatedGroup.StudioID, err = translator.optionalIntFromString(input.StudioID, "studio_id") if err != nil { return nil, fmt.Errorf("converting studio id: %w", err) } - updatedMovie.TagIDs, err = translator.updateIdsBulk(input.TagIds, "tag_ids") + updatedGroup.TagIDs, err = translator.updateIdsBulk(input.TagIds, "tag_ids") if err != nil { return nil, fmt.Errorf("converting tag ids: %w", err) } - updatedMovie.URLs = translator.optionalURLsBulk(input.Urls, nil) + updatedGroup.URLs = translator.optionalURLsBulk(input.Urls, nil) - ret := []*models.Movie{} + ret := []*models.Group{} if err := r.withTxn(ctx, func(ctx context.Context) error { - qb := r.repository.Movie + qb := r.repository.Group - for _, movieID := range movieIDs { - movie, err := qb.UpdatePartial(ctx, movieID, updatedMovie) + for _, groupID := range groupIDs { + group, err := qb.UpdatePartial(ctx, groupID, updatedGroup) if err != nil { return err } - ret = append(ret, movie) + ret = append(ret, group) } return nil @@ -252,18 +252,18 @@ func (r *mutationResolver) BulkMovieUpdate(ctx context.Context, input BulkMovieU return nil, err } - var newRet []*models.Movie - for _, movie := range ret { + var newRet []*models.Group + for _, group := range ret { // for backwards compatibility - run both movie and group hooks - r.hookExecutor.ExecutePostHooks(ctx, movie.ID, hook.GroupUpdatePost, input, translator.getFields()) - r.hookExecutor.ExecutePostHooks(ctx, movie.ID, hook.MovieUpdatePost, input, translator.getFields()) + r.hookExecutor.ExecutePostHooks(ctx, group.ID, hook.GroupUpdatePost, input, translator.getFields()) + r.hookExecutor.ExecutePostHooks(ctx, group.ID, hook.MovieUpdatePost, input, translator.getFields()) - movie, err = r.getMovie(ctx, movie.ID) + group, err = r.getGroup(ctx, group.ID) if err != nil { return nil, err } - newRet = append(newRet, movie) + newRet = append(newRet, group) } return newRet, nil @@ -276,7 +276,7 @@ func (r *mutationResolver) MovieDestroy(ctx context.Context, input MovieDestroyI } if err := r.withTxn(ctx, func(ctx context.Context) error { - return r.repository.Movie.Destroy(ctx, id) + return r.repository.Group.Destroy(ctx, id) }); err != nil { return false, err } @@ -288,14 +288,14 @@ func (r *mutationResolver) MovieDestroy(ctx context.Context, input MovieDestroyI return true, nil } -func (r *mutationResolver) MoviesDestroy(ctx context.Context, movieIDs []string) (bool, error) { - ids, err := stringslice.StringSliceToIntSlice(movieIDs) +func (r *mutationResolver) MoviesDestroy(ctx context.Context, groupIDs []string) (bool, error) { + ids, err := stringslice.StringSliceToIntSlice(groupIDs) if err != nil { return false, fmt.Errorf("converting ids: %w", err) } if err := r.withTxn(ctx, func(ctx context.Context) error { - qb := r.repository.Movie + qb := r.repository.Group for _, id := range ids { if err := qb.Destroy(ctx, id); err != nil { return err @@ -309,8 +309,8 @@ func (r *mutationResolver) MoviesDestroy(ctx context.Context, movieIDs []string) for _, id := range ids { // for backwards compatibility - run both movie and group hooks - r.hookExecutor.ExecutePostHooks(ctx, id, hook.GroupDestroyPost, movieIDs, nil) - r.hookExecutor.ExecutePostHooks(ctx, id, hook.MovieDestroyPost, movieIDs, nil) + r.hookExecutor.ExecutePostHooks(ctx, id, hook.GroupDestroyPost, groupIDs, nil) + r.hookExecutor.ExecutePostHooks(ctx, id, hook.MovieDestroyPost, groupIDs, nil) } return true, nil diff --git a/internal/api/resolver_mutation_scene.go b/internal/api/resolver_mutation_scene.go index d3616cc4c0b..3020f13fdc8 100644 --- a/internal/api/resolver_mutation_scene.go +++ b/internal/api/resolver_mutation_scene.go @@ -82,12 +82,12 @@ func (r *mutationResolver) SceneCreate(ctx context.Context, input models.SceneCr // prefer groups over movies if len(input.Groups) > 0 { - newScene.Movies, err = translator.relatedMoviesFromGroups(input.Groups) + newScene.Groups, err = translator.relatedGroups(input.Groups) if err != nil { return nil, fmt.Errorf("converting groups: %w", err) } } else if len(input.Movies) > 0 { - newScene.Movies, err = translator.relatedMovies(input.Movies) + newScene.Groups, err = translator.relatedGroupsFromMovies(input.Movies) if err != nil { return nil, fmt.Errorf("converting movies: %w", err) } @@ -225,12 +225,12 @@ func scenePartialFromInput(input models.SceneUpdateInput, translator changesetTr } if translator.hasField("groups") { - updatedScene.MovieIDs, err = translator.updateMovieIDsFromGroups(input.Groups, "groups") + updatedScene.GroupIDs, err = translator.updateGroupIDs(input.Groups, "groups") if err != nil { - return nil, fmt.Errorf("converting movies: %w", err) + return nil, fmt.Errorf("converting groups: %w", err) } } else if translator.hasField("movies") { - updatedScene.MovieIDs, err = translator.updateMovieIDs(input.Movies, "movies") + updatedScene.GroupIDs, err = translator.updateGroupIDsFromMovies(input.Movies, "movies") if err != nil { return nil, fmt.Errorf("converting movies: %w", err) } @@ -374,12 +374,12 @@ func (r *mutationResolver) BulkSceneUpdate(ctx context.Context, input BulkSceneU } if translator.hasField("groups") { - updatedScene.MovieIDs, err = translator.updateMovieIDsBulk(input.GroupIds, "group_ids") + updatedScene.GroupIDs, err = translator.updateGroupIDsBulk(input.GroupIds, "group_ids") if err != nil { return nil, fmt.Errorf("converting group ids: %w", err) } } else if translator.hasField("movies") { - updatedScene.MovieIDs, err = translator.updateMovieIDsBulk(input.MovieIds, "movie_ids") + updatedScene.GroupIDs, err = translator.updateGroupIDsBulk(input.MovieIds, "movie_ids") if err != nil { return nil, fmt.Errorf("converting movie ids: %w", err) } diff --git a/internal/api/resolver_query_find_group.go b/internal/api/resolver_query_find_group.go index f5fdde50a66..6f8a6c6bac4 100644 --- a/internal/api/resolver_query_find_group.go +++ b/internal/api/resolver_query_find_group.go @@ -8,14 +8,14 @@ import ( "github.com/stashapp/stash/pkg/sliceutil/stringslice" ) -func (r *queryResolver) FindGroup(ctx context.Context, id string) (ret *models.Movie, err error) { +func (r *queryResolver) FindGroup(ctx context.Context, id string) (ret *models.Group, err error) { idInt, err := strconv.Atoi(id) if err != nil { return nil, err } if err := r.withReadTxn(ctx, func(ctx context.Context) error { - ret, err = r.repository.Movie.Find(ctx, idInt) + ret, err = r.repository.Group.Find(ctx, idInt) return err }); err != nil { return nil, err @@ -24,22 +24,22 @@ func (r *queryResolver) FindGroup(ctx context.Context, id string) (ret *models.M return ret, nil } -func (r *queryResolver) FindGroups(ctx context.Context, movieFilter *models.MovieFilterType, filter *models.FindFilterType, ids []string) (ret *FindGroupsResultType, err error) { +func (r *queryResolver) FindGroups(ctx context.Context, groupFilter *models.GroupFilterType, filter *models.FindFilterType, ids []string) (ret *FindGroupsResultType, err error) { idInts, err := stringslice.StringSliceToIntSlice(ids) if err != nil { return nil, err } if err := r.withReadTxn(ctx, func(ctx context.Context) error { - var movies []*models.Movie + var groups []*models.Group var err error var total int if len(idInts) > 0 { - movies, err = r.repository.Movie.FindMany(ctx, idInts) - total = len(movies) + groups, err = r.repository.Group.FindMany(ctx, idInts) + total = len(groups) } else { - movies, total, err = r.repository.Movie.Query(ctx, movieFilter, filter) + groups, total, err = r.repository.Group.Query(ctx, groupFilter, filter) } if err != nil { @@ -48,7 +48,7 @@ func (r *queryResolver) FindGroups(ctx context.Context, movieFilter *models.Movi ret = &FindGroupsResultType{ Count: total, - Groups: movies, + Groups: groups, } return nil }); err != nil { diff --git a/internal/api/resolver_query_find_movie.go b/internal/api/resolver_query_find_movie.go index 84e01e28276..2f80d6f5999 100644 --- a/internal/api/resolver_query_find_movie.go +++ b/internal/api/resolver_query_find_movie.go @@ -8,14 +8,14 @@ import ( "github.com/stashapp/stash/pkg/sliceutil/stringslice" ) -func (r *queryResolver) FindMovie(ctx context.Context, id string) (ret *models.Movie, err error) { +func (r *queryResolver) FindMovie(ctx context.Context, id string) (ret *models.Group, err error) { idInt, err := strconv.Atoi(id) if err != nil { return nil, err } if err := r.withReadTxn(ctx, func(ctx context.Context) error { - ret, err = r.repository.Movie.Find(ctx, idInt) + ret, err = r.repository.Group.Find(ctx, idInt) return err }); err != nil { return nil, err @@ -24,22 +24,22 @@ func (r *queryResolver) FindMovie(ctx context.Context, id string) (ret *models.M return ret, nil } -func (r *queryResolver) FindMovies(ctx context.Context, movieFilter *models.MovieFilterType, filter *models.FindFilterType, ids []string) (ret *FindMoviesResultType, err error) { +func (r *queryResolver) FindMovies(ctx context.Context, movieFilter *models.GroupFilterType, filter *models.FindFilterType, ids []string) (ret *FindMoviesResultType, err error) { idInts, err := stringslice.StringSliceToIntSlice(ids) if err != nil { return nil, err } if err := r.withReadTxn(ctx, func(ctx context.Context) error { - var movies []*models.Movie + var groups []*models.Group var err error var total int if len(idInts) > 0 { - movies, err = r.repository.Movie.FindMany(ctx, idInts) - total = len(movies) + groups, err = r.repository.Group.FindMany(ctx, idInts) + total = len(groups) } else { - movies, total, err = r.repository.Movie.Query(ctx, movieFilter, filter) + groups, total, err = r.repository.Group.Query(ctx, movieFilter, filter) } if err != nil { @@ -48,7 +48,7 @@ func (r *queryResolver) FindMovies(ctx context.Context, movieFilter *models.Movi ret = &FindMoviesResultType{ Count: total, - Movies: movies, + Movies: groups, } return nil }); err != nil { @@ -58,9 +58,9 @@ func (r *queryResolver) FindMovies(ctx context.Context, movieFilter *models.Movi return ret, nil } -func (r *queryResolver) AllMovies(ctx context.Context) (ret []*models.Movie, err error) { +func (r *queryResolver) AllMovies(ctx context.Context) (ret []*models.Group, err error) { if err := r.withReadTxn(ctx, func(ctx context.Context) error { - ret, err = r.repository.Movie.All(ctx) + ret, err = r.repository.Group.All(ctx) return err }); err != nil { return nil, err diff --git a/internal/api/resolver_query_scraper.go b/internal/api/resolver_query_scraper.go index 9d69843bc99..b9690cea76c 100644 --- a/internal/api/resolver_query_scraper.go +++ b/internal/api/resolver_query_scraper.go @@ -144,8 +144,8 @@ func filterPerformerTags(p []*models.ScrapedPerformer) { } } -// filterMovieTags removes tags matching excluded tag patterns from the provided scraped movies -func filterMovieTags(p []*models.ScrapedMovie) { +// filterGroupTags removes tags matching excluded tag patterns from the provided scraped movies +func filterGroupTags(p []*models.ScrapedMovie) { excludeRegexps := compileRegexps(manager.GetInstance().Config.GetScraperExcludeTagPatterns()) var ignoredTags []string @@ -208,7 +208,7 @@ func (r *queryResolver) ScrapeMovieURL(ctx context.Context, url string) (*models return nil, err } - filterMovieTags([]*models.ScrapedMovie{ret}) + filterGroupTags([]*models.ScrapedMovie{ret}) return ret, nil } @@ -224,7 +224,7 @@ func (r *queryResolver) ScrapeGroupURL(ctx context.Context, url string) (*models return nil, err } - filterMovieTags([]*models.ScrapedMovie{ret}) + filterGroupTags([]*models.ScrapedMovie{ret}) // convert to scraped group group := &models.ScrapedGroup{ diff --git a/internal/api/routes_movie.go b/internal/api/routes_movie.go index cd422468172..20eaa4c23e5 100644 --- a/internal/api/routes_movie.go +++ b/internal/api/routes_movie.go @@ -14,22 +14,22 @@ import ( "github.com/stashapp/stash/pkg/utils" ) -type MovieFinder interface { - models.MovieGetter - GetFrontImage(ctx context.Context, movieID int) ([]byte, error) - GetBackImage(ctx context.Context, movieID int) ([]byte, error) +type GroupFinder interface { + models.GroupGetter + GetFrontImage(ctx context.Context, groupID int) ([]byte, error) + GetBackImage(ctx context.Context, groupID int) ([]byte, error) } -type movieRoutes struct { +type groupRoutes struct { routes - movieFinder MovieFinder + groupFinder GroupFinder } -func (rs movieRoutes) Routes() chi.Router { +func (rs groupRoutes) Routes() chi.Router { r := chi.NewRouter() - r.Route("/{movieId}", func(r chi.Router) { - r.Use(rs.MovieCtx) + r.Route("/{groupId}", func(r chi.Router) { + r.Use(rs.GroupCtx) r.Get("/frontimage", rs.FrontImage) r.Get("/backimage", rs.BackImage) }) @@ -37,77 +37,77 @@ func (rs movieRoutes) Routes() chi.Router { return r } -func (rs movieRoutes) FrontImage(w http.ResponseWriter, r *http.Request) { - movie := r.Context().Value(movieKey).(*models.Movie) +func (rs groupRoutes) FrontImage(w http.ResponseWriter, r *http.Request) { + group := r.Context().Value(groupKey).(*models.Group) defaultParam := r.URL.Query().Get("default") var image []byte if defaultParam != "true" { readTxnErr := rs.withReadTxn(r, func(ctx context.Context) error { var err error - image, err = rs.movieFinder.GetFrontImage(ctx, movie.ID) + image, err = rs.groupFinder.GetFrontImage(ctx, group.ID) return err }) if errors.Is(readTxnErr, context.Canceled) { return } if readTxnErr != nil { - logger.Warnf("read transaction error on fetch movie front image: %v", readTxnErr) + logger.Warnf("read transaction error on fetch group front image: %v", readTxnErr) } } // fallback to default image if len(image) == 0 { - image = static.ReadAll(static.DefaultMovieImage) + image = static.ReadAll(static.DefaultGroupImage) } utils.ServeImage(w, r, image) } -func (rs movieRoutes) BackImage(w http.ResponseWriter, r *http.Request) { - movie := r.Context().Value(movieKey).(*models.Movie) +func (rs groupRoutes) BackImage(w http.ResponseWriter, r *http.Request) { + group := r.Context().Value(groupKey).(*models.Group) defaultParam := r.URL.Query().Get("default") var image []byte if defaultParam != "true" { readTxnErr := rs.withReadTxn(r, func(ctx context.Context) error { var err error - image, err = rs.movieFinder.GetBackImage(ctx, movie.ID) + image, err = rs.groupFinder.GetBackImage(ctx, group.ID) return err }) if errors.Is(readTxnErr, context.Canceled) { return } if readTxnErr != nil { - logger.Warnf("read transaction error on fetch movie back image: %v", readTxnErr) + logger.Warnf("read transaction error on fetch group back image: %v", readTxnErr) } } // fallback to default image if len(image) == 0 { - image = static.ReadAll(static.DefaultMovieImage) + image = static.ReadAll(static.DefaultGroupImage) } utils.ServeImage(w, r, image) } -func (rs movieRoutes) MovieCtx(next http.Handler) http.Handler { +func (rs groupRoutes) GroupCtx(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - movieID, err := strconv.Atoi(chi.URLParam(r, "movieId")) + groupID, err := strconv.Atoi(chi.URLParam(r, "groupId")) if err != nil { http.Error(w, http.StatusText(404), 404) return } - var movie *models.Movie + var group *models.Group _ = rs.withReadTxn(r, func(ctx context.Context) error { - movie, _ = rs.movieFinder.Find(ctx, movieID) + group, _ = rs.groupFinder.Find(ctx, groupID) return nil }) - if movie == nil { + if group == nil { http.Error(w, http.StatusText(404), 404) return } - ctx := context.WithValue(r.Context(), movieKey, movie) + ctx := context.WithValue(r.Context(), groupKey, group) next.ServeHTTP(w, r.WithContext(ctx)) }) } diff --git a/internal/api/server.go b/internal/api/server.go index b9fd1eeed8c..679bd3f1c85 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -209,7 +209,7 @@ func Initialize() (*Server, error) { r.Mount("/scene", server.getSceneRoutes()) r.Mount("/image", server.getImageRoutes()) r.Mount("/studio", server.getStudioRoutes()) - r.Mount("/movie", server.getMovieRoutes()) + r.Mount("/group", server.getGroupRoutes()) r.Mount("/tag", server.getTagRoutes()) r.Mount("/downloads", server.getDownloadsRoutes()) r.Mount("/plugin", server.getPluginRoutes()) @@ -343,11 +343,11 @@ func (s *Server) getStudioRoutes() chi.Router { }.Routes() } -func (s *Server) getMovieRoutes() chi.Router { +func (s *Server) getGroupRoutes() chi.Router { repo := s.manager.Repository - return movieRoutes{ + return groupRoutes{ routes: routes{txnManager: repo.TxnManager}, - movieFinder: repo.Movie, + groupFinder: repo.Group, }.Routes() } diff --git a/internal/api/urlbuilders/movie.go b/internal/api/urlbuilders/movie.go index a9ca6831078..26abd8dbdc6 100644 --- a/internal/api/urlbuilders/movie.go +++ b/internal/api/urlbuilders/movie.go @@ -1,32 +1,33 @@ package urlbuilders import ( - "github.com/stashapp/stash/pkg/models" "strconv" + + "github.com/stashapp/stash/pkg/models" ) -type MovieURLBuilder struct { +type GroupURLBuilder struct { BaseURL string - MovieID string + GroupID string UpdatedAt string } -func NewMovieURLBuilder(baseURL string, movie *models.Movie) MovieURLBuilder { - return MovieURLBuilder{ +func NewGroupURLBuilder(baseURL string, group *models.Group) GroupURLBuilder { + return GroupURLBuilder{ BaseURL: baseURL, - MovieID: strconv.Itoa(movie.ID), - UpdatedAt: strconv.FormatInt(movie.UpdatedAt.Unix(), 10), + GroupID: strconv.Itoa(group.ID), + UpdatedAt: strconv.FormatInt(group.UpdatedAt.Unix(), 10), } } -func (b MovieURLBuilder) GetMovieFrontImageURL(hasImage bool) string { - url := b.BaseURL + "/movie/" + b.MovieID + "/frontimage?t=" + b.UpdatedAt +func (b GroupURLBuilder) GetGroupFrontImageURL(hasImage bool) string { + url := b.BaseURL + "/group/" + b.GroupID + "/frontimage?t=" + b.UpdatedAt if !hasImage { url += "&default=true" } return url } -func (b MovieURLBuilder) GetMovieBackImageURL() string { - return b.BaseURL + "/movie/" + b.MovieID + "/backimage?t=" + b.UpdatedAt +func (b GroupURLBuilder) GetGroupBackImageURL() string { + return b.BaseURL + "/group/" + b.GroupID + "/backimage?t=" + b.UpdatedAt } diff --git a/internal/dlna/cds.go b/internal/dlna/cds.go index 6e1604bdca3..b6a4014b140 100644 --- a/internal/dlna/cds.go +++ b/internal/dlna/cds.go @@ -316,13 +316,13 @@ func (me *contentDirectoryService) handleBrowseDirectChildren(obj object, host s objs = me.getPerformerScenes(childPath(paths), host) } - // Movies - if obj.Path == "movies" { - objs = me.getMovies() + // Groups - deprecated + if obj.Path == "groups" { + objs = me.getGroups() } - if strings.HasPrefix(obj.Path, "movies/") { - objs = me.getMovieScenes(childPath(paths), host) + if strings.HasPrefix(obj.Path, "groups/") { + objs = me.getGroupScenes(childPath(paths), host) } // Rating @@ -433,7 +433,7 @@ func getRootObjects() []interface{} { objs = append(objs, makeStorageFolder("performers", "performers", rootID)) objs = append(objs, makeStorageFolder("tags", "tags", rootID)) objs = append(objs, makeStorageFolder("studios", "studios", rootID)) - objs = append(objs, makeStorageFolder("movies", "movies", rootID)) + objs = append(objs, makeStorageFolder("groups", "groups", rootID)) objs = append(objs, makeStorageFolder("rating", "rating", rootID)) return objs @@ -658,18 +658,18 @@ func (me *contentDirectoryService) getPerformerScenes(paths []string, host strin return me.getVideos(sceneFilter, parentID, host) } -func (me *contentDirectoryService) getMovies() []interface{} { +func (me *contentDirectoryService) getGroups() []interface{} { var objs []interface{} r := me.repository if err := r.WithReadTxn(context.TODO(), func(ctx context.Context) error { - movies, err := r.MovieFinder.All(ctx) + groups, err := r.GroupFinder.All(ctx) if err != nil { return err } - for _, s := range movies { - objs = append(objs, makeStorageFolder("movies/"+strconv.Itoa(s.ID), s.Name, "movies")) + for _, s := range groups { + objs = append(objs, makeStorageFolder("groups/"+strconv.Itoa(s.ID), s.Name, "groups")) } return nil @@ -680,15 +680,15 @@ func (me *contentDirectoryService) getMovies() []interface{} { return objs } -func (me *contentDirectoryService) getMovieScenes(paths []string, host string) []interface{} { +func (me *contentDirectoryService) getGroupScenes(paths []string, host string) []interface{} { sceneFilter := &models.SceneFilterType{ - Movies: &models.MultiCriterionInput{ + Groups: &models.MultiCriterionInput{ Modifier: models.CriterionModifierIncludes, Value: []string{paths[0]}, }, } - parentID := "movies/" + strings.Join(paths, "/") + parentID := "groups/" + strings.Join(paths, "/") page := getPageFromID(paths) if page != nil { diff --git a/internal/dlna/dms.go b/internal/dlna/dms.go index 0df483ccac9..8c5074d30ef 100644 --- a/internal/dlna/dms.go +++ b/internal/dlna/dms.go @@ -67,8 +67,8 @@ type PerformerFinder interface { All(ctx context.Context) ([]*models.Performer, error) } -type MovieFinder interface { - All(ctx context.Context) ([]*models.Movie, error) +type GroupFinder interface { + All(ctx context.Context) ([]*models.Group, error) } const ( diff --git a/internal/dlna/service.go b/internal/dlna/service.go index 07f41608441..6ef825bacae 100644 --- a/internal/dlna/service.go +++ b/internal/dlna/service.go @@ -22,7 +22,7 @@ type Repository struct { StudioFinder StudioFinder TagFinder TagFinder PerformerFinder PerformerFinder - MovieFinder MovieFinder + GroupFinder GroupFinder } func NewRepository(repo models.Repository) Repository { @@ -33,7 +33,7 @@ func NewRepository(repo models.Repository) Repository { StudioFinder: repo.Studio, TagFinder: repo.Tag, PerformerFinder: repo.Performer, - MovieFinder: repo.Movie, + GroupFinder: repo.Group, } } diff --git a/internal/manager/json_utils.go b/internal/manager/json_utils.go index c90c9502942..f1ce60404f1 100644 --- a/internal/manager/json_utils.go +++ b/internal/manager/json_utils.go @@ -23,8 +23,8 @@ func (jp *jsonUtils) saveTag(fn string, tag *jsonschema.Tag) error { return jsonschema.SaveTagFile(filepath.Join(jp.json.Tags, fn), tag) } -func (jp *jsonUtils) saveMovie(fn string, movie *jsonschema.Movie) error { - return jsonschema.SaveMovieFile(filepath.Join(jp.json.Movies, fn), movie) +func (jp *jsonUtils) saveGroup(fn string, group *jsonschema.Group) error { + return jsonschema.SaveGroupFile(filepath.Join(jp.json.Groups, fn), group) } func (jp *jsonUtils) saveScene(fn string, scene *jsonschema.Scene) error { diff --git a/internal/manager/task_export.go b/internal/manager/task_export.go index cbf304fb6cd..15089491424 100644 --- a/internal/manager/task_export.go +++ b/internal/manager/task_export.go @@ -120,7 +120,7 @@ func CreateExportTask(a models.HashAlgorithm, input ExportObjectsInput) *ExportT func (t *ExportTask) Start(ctx context.Context, wg *sync.WaitGroup) { defer wg.Done() - // @manager.total = Scene.count + Gallery.count + Performer.count + Studio.count + Movie.count + // @manager.total = Scene.count + Gallery.count + Performer.count + Studio.count + Group.count workerCount := runtime.GOMAXPROCS(0) // set worker count to number of cpus available startTime := time.Now() @@ -156,11 +156,11 @@ func (t *ExportTask) Start(ctx context.Context, wg *sync.WaitGroup) { paths.EnsureJSONDirs(t.baseDir) txnErr := t.repository.WithTxn(ctx, func(ctx context.Context) error { - // include movie scenes and gallery images + // include group scenes and gallery images if !t.full { - // only include movie scenes if includeDependencies is also set + // only include group scenes if includeDependencies is also set if !t.scenes.all && t.includeDependencies { - t.populateMovieScenes(ctx) + t.populateGroupScenes(ctx) } // always export gallery images @@ -172,7 +172,7 @@ func (t *ExportTask) Start(ctx context.Context, wg *sync.WaitGroup) { t.ExportScenes(ctx, workerCount) t.ExportImages(ctx, workerCount) t.ExportGalleries(ctx, workerCount) - t.ExportMovies(ctx, workerCount) + t.ExportGroups(ctx, workerCount) t.ExportPerformers(ctx, workerCount) t.ExportStudios(ctx, workerCount) t.ExportTags(ctx, workerCount) @@ -229,7 +229,7 @@ func (t *ExportTask) zipFiles(w io.Writer) error { walkWarn(t.json.json.Galleries, t.zipWalkFunc(u.json.Galleries, z)) walkWarn(t.json.json.Performers, t.zipWalkFunc(u.json.Performers, z)) walkWarn(t.json.json.Studios, t.zipWalkFunc(u.json.Studios, z)) - walkWarn(t.json.json.Movies, t.zipWalkFunc(u.json.Movies, z)) + walkWarn(t.json.json.Groups, t.zipWalkFunc(u.json.Groups, z)) walkWarn(t.json.json.Scenes, t.zipWalkFunc(u.json.Scenes, z)) walkWarn(t.json.json.Images, t.zipWalkFunc(u.json.Images, z)) @@ -282,28 +282,28 @@ func (t *ExportTask) zipFile(fn, outDir string, z *zip.Writer) error { return nil } -func (t *ExportTask) populateMovieScenes(ctx context.Context) { +func (t *ExportTask) populateGroupScenes(ctx context.Context) { r := t.repository - reader := r.Movie + reader := r.Group sceneReader := r.Scene - var movies []*models.Movie + var groups []*models.Group var err error all := t.full || (t.groups != nil && t.groups.all) if all { - movies, err = reader.All(ctx) + groups, err = reader.All(ctx) } else if t.groups != nil && len(t.groups.IDs) > 0 { - movies, err = reader.FindMany(ctx, t.groups.IDs) + groups, err = reader.FindMany(ctx, t.groups.IDs) } if err != nil { - logger.Errorf("[movies] failed to fetch movies: %v", err) + logger.Errorf("[groups] failed to fetch groups: %v", err) } - for _, m := range movies { - scenes, err := sceneReader.FindByMovieID(ctx, m.ID) + for _, m := range groups { + scenes, err := sceneReader.FindByGroupID(ctx, m.ID) if err != nil { - logger.Errorf("[movies] <%s> failed to fetch scenes for movie: %v", m.Name, err) + logger.Errorf("[groups] <%s> failed to fetch scenes for group: %v", m.Name, err) continue } @@ -488,7 +488,7 @@ func (t *ExportTask) exportScene(ctx context.Context, wg *sync.WaitGroup, jobCha r := t.repository sceneReader := r.Scene studioReader := r.Studio - movieReader := r.Movie + groupReader := r.Group galleryReader := r.Gallery performerReader := r.Performer tagReader := r.Tag @@ -556,9 +556,9 @@ func (t *ExportTask) exportScene(ctx context.Context, wg *sync.WaitGroup, jobCha continue } - newSceneJSON.Movies, err = scene.GetSceneMoviesJSON(ctx, movieReader, s) + newSceneJSON.Groups, err = scene.GetSceneGroupsJSON(ctx, groupReader, s) if err != nil { - logger.Errorf("[scenes] <%s> error getting scene movies JSON: %v", sceneHash, err) + logger.Errorf("[scenes] <%s> error getting scene groups JSON: %v", sceneHash, err) continue } @@ -576,12 +576,12 @@ func (t *ExportTask) exportScene(ctx context.Context, wg *sync.WaitGroup, jobCha } t.tags.IDs = sliceutil.AppendUniques(t.tags.IDs, tagIDs) - movieIDs, err := scene.GetDependentMovieIDs(ctx, s) + groupIDs, err := scene.GetDependentGroupIDs(ctx, s) if err != nil { - logger.Errorf("[scenes] <%s> error getting scene movies: %v", sceneHash, err) + logger.Errorf("[scenes] <%s> error getting scene groups: %v", sceneHash, err) continue } - t.groups.IDs = sliceutil.AppendUniques(t.groups.IDs, movieIDs) + t.groups.IDs = sliceutil.AppendUniques(t.groups.IDs, groupIDs) t.performers.IDs = sliceutil.AppendUniques(t.performers.IDs, performer.GetIDs(performers)) } @@ -1081,74 +1081,74 @@ func (t *ExportTask) exportTag(ctx context.Context, wg *sync.WaitGroup, jobChan } } -func (t *ExportTask) ExportMovies(ctx context.Context, workers int) { - var moviesWg sync.WaitGroup +func (t *ExportTask) ExportGroups(ctx context.Context, workers int) { + var groupsWg sync.WaitGroup - reader := t.repository.Movie - var movies []*models.Movie + reader := t.repository.Group + var groups []*models.Group var err error all := t.full || (t.groups != nil && t.groups.all) if all { - movies, err = reader.All(ctx) + groups, err = reader.All(ctx) } else if t.groups != nil && len(t.groups.IDs) > 0 { - movies, err = reader.FindMany(ctx, t.groups.IDs) + groups, err = reader.FindMany(ctx, t.groups.IDs) } if err != nil { - logger.Errorf("[movies] failed to fetch movies: %v", err) + logger.Errorf("[groups] failed to fetch groups: %v", err) } - logger.Info("[movies] exporting") + logger.Info("[groups] exporting") startTime := time.Now() - jobCh := make(chan *models.Movie, workers*2) // make a buffered channel to feed workers + jobCh := make(chan *models.Group, workers*2) // make a buffered channel to feed workers for w := 0; w < workers; w++ { // create export Studio workers - moviesWg.Add(1) - go t.exportMovie(ctx, &moviesWg, jobCh) + groupsWg.Add(1) + go t.exportGroup(ctx, &groupsWg, jobCh) } - for i, movie := range movies { + for i, group := range groups { index := i + 1 - logger.Progressf("[movies] %d of %d", index, len(movies)) + logger.Progressf("[groups] %d of %d", index, len(groups)) - jobCh <- movie // feed workers + jobCh <- group // feed workers } close(jobCh) - moviesWg.Wait() + groupsWg.Wait() - logger.Infof("[movies] export complete in %s. %d workers used.", time.Since(startTime), workers) + logger.Infof("[groups] export complete in %s. %d workers used.", time.Since(startTime), workers) } -func (t *ExportTask) exportMovie(ctx context.Context, wg *sync.WaitGroup, jobChan <-chan *models.Movie) { +func (t *ExportTask) exportGroup(ctx context.Context, wg *sync.WaitGroup, jobChan <-chan *models.Group) { defer wg.Done() r := t.repository - movieReader := r.Movie + groupReader := r.Group studioReader := r.Studio tagReader := r.Tag for m := range jobChan { - if err := m.LoadURLs(ctx, r.Movie); err != nil { - logger.Errorf("[movies] <%s> error getting movie urls: %v", m.Name, err) + if err := m.LoadURLs(ctx, r.Group); err != nil { + logger.Errorf("[groups] <%s> error getting group urls: %v", m.Name, err) continue } - newMovieJSON, err := movie.ToJSON(ctx, movieReader, studioReader, m) + newGroupJSON, err := movie.ToJSON(ctx, groupReader, studioReader, m) if err != nil { - logger.Errorf("[movies] <%s> error getting tag JSON: %v", m.Name, err) + logger.Errorf("[groups] <%s> error getting tag JSON: %v", m.Name, err) continue } - tags, err := tagReader.FindByMovieID(ctx, m.ID) + tags, err := tagReader.FindByGroupID(ctx, m.ID) if err != nil { - logger.Errorf("[movies] <%s> error getting image tag names: %v", m.Name, err) + logger.Errorf("[groups] <%s> error getting image tag names: %v", m.Name, err) continue } - newMovieJSON.Tags = tag.GetNames(tags) + newGroupJSON.Tags = tag.GetNames(tags) if t.includeDependencies { if m.StudioID != nil { @@ -1156,10 +1156,10 @@ func (t *ExportTask) exportMovie(ctx context.Context, wg *sync.WaitGroup, jobCha } } - fn := newMovieJSON.Filename() + fn := newGroupJSON.Filename() - if err := t.json.saveMovie(fn, newMovieJSON); err != nil { - logger.Errorf("[movies] <%s> failed to save json: %v", m.Name, err) + if err := t.json.saveGroup(fn, newGroupJSON); err != nil { + logger.Errorf("[groups] <%s> failed to save json: %v", m.Name, err) } } } diff --git a/internal/manager/task_import.go b/internal/manager/task_import.go index 47fbf0cd1d5..b83ca0b3cc7 100644 --- a/internal/manager/task_import.go +++ b/internal/manager/task_import.go @@ -127,7 +127,7 @@ func (t *ImportTask) Start(ctx context.Context) { t.ImportTags(ctx) t.ImportPerformers(ctx) t.ImportStudios(ctx) - t.ImportMovies(ctx) + t.ImportGroups(ctx) t.ImportFiles(ctx) t.ImportGalleries(ctx) @@ -325,14 +325,14 @@ func (t *ImportTask) importStudio(ctx context.Context, studioJSON *jsonschema.St return nil } -func (t *ImportTask) ImportMovies(ctx context.Context) { - logger.Info("[movies] importing") +func (t *ImportTask) ImportGroups(ctx context.Context) { + logger.Info("[groups] importing") - path := t.json.json.Movies + path := t.json.json.Groups files, err := os.ReadDir(path) if err != nil { if !errors.Is(err, os.ErrNotExist) { - logger.Errorf("[movies] failed to read movies directory: %v", err) + logger.Errorf("[groups] failed to read movies directory: %v", err) } return @@ -342,31 +342,31 @@ func (t *ImportTask) ImportMovies(ctx context.Context) { for i, fi := range files { index := i + 1 - movieJSON, err := jsonschema.LoadMovieFile(filepath.Join(path, fi.Name())) + groupJSON, err := jsonschema.LoadGroupFile(filepath.Join(path, fi.Name())) if err != nil { - logger.Errorf("[movies] failed to read json: %v", err) + logger.Errorf("[groups] failed to read json: %v", err) continue } - logger.Progressf("[movies] %d of %d", index, len(files)) + logger.Progressf("[groups] %d of %d", index, len(files)) if err := r.WithTxn(ctx, func(ctx context.Context) error { - movieImporter := &movie.Importer{ - ReaderWriter: r.Movie, + groupImporter := &movie.Importer{ + ReaderWriter: r.Group, StudioWriter: r.Studio, TagWriter: r.Tag, - Input: *movieJSON, + Input: *groupJSON, MissingRefBehaviour: t.MissingRefBehaviour, } - return performImport(ctx, movieImporter, t.DuplicateBehaviour) + return performImport(ctx, groupImporter, t.DuplicateBehaviour) }); err != nil { - logger.Errorf("[movies] <%s> import failed: %v", fi.Name(), err) + logger.Errorf("[groups] <%s> import failed: %v", fi.Name(), err) continue } } - logger.Info("[movies] import complete") + logger.Info("[groups] import complete") } func (t *ImportTask) ImportFiles(ctx context.Context) { @@ -648,7 +648,7 @@ func (t *ImportTask) ImportScenes(ctx context.Context) { MissingRefBehaviour: t.MissingRefBehaviour, GalleryFinder: r.Gallery, - MovieWriter: r.Movie, + GroupWriter: r.Group, PerformerWriter: r.Performer, StudioWriter: r.Studio, TagWriter: r.Tag, diff --git a/internal/static/embed.go b/internal/static/embed.go index d82c0b66bd7..7c06c611b1d 100644 --- a/internal/static/embed.go +++ b/internal/static/embed.go @@ -26,8 +26,8 @@ const ( Studio = "studio" DefaultStudioImage = "studio/studio.svg" - Movie = "movie" - DefaultMovieImage = "movie/movie.png" + Group = "movie" + DefaultGroupImage = "movie/movie.png" ) // Sub returns an FS rooted at path, using fs.Sub. diff --git a/pkg/match/scraped.go b/pkg/match/scraped.go index 0b8a8d69615..637d48bfce7 100644 --- a/pkg/match/scraped.go +++ b/pkg/match/scraped.go @@ -16,8 +16,8 @@ type PerformerFinder interface { FindByStashID(ctx context.Context, stashID models.StashID) ([]*models.Performer, error) } -type MovieNamesFinder interface { - FindByNames(ctx context.Context, names []string, nocase bool) ([]*models.Movie, error) +type GroupNamesFinder interface { + FindByNames(ctx context.Context, names []string, nocase bool) ([]*models.Group, error) } // ScrapedPerformer matches the provided performer with the @@ -118,27 +118,27 @@ func ScrapedStudio(ctx context.Context, qb StudioFinder, s *models.ScrapedStudio return nil } -// ScrapedMovie matches the provided movie with the movies -// in the database and sets the ID field if one is found. -func ScrapedMovie(ctx context.Context, qb MovieNamesFinder, m *models.ScrapedMovie) error { - if m.StoredID != nil || m.Name == nil { - return nil +// ScrapedGroup matches the provided movie with the movies +// in the database and returns the ID field if one is found. +func ScrapedGroup(ctx context.Context, qb GroupNamesFinder, storedID *string, name *string) (matchedID *string, err error) { + if storedID != nil || name == nil { + return } - movies, err := qb.FindByNames(ctx, []string{*m.Name}, true) + movies, err := qb.FindByNames(ctx, []string{*name}, true) if err != nil { - return err + return } if len(movies) != 1 { // ignore - cannot match - return nil + return } id := strconv.Itoa(movies[0].ID) - m.StoredID = &id - return nil + matchedID = &id + return } // ScrapedTag matches the provided tag with the tags diff --git a/pkg/models/jsonschema/movie.go b/pkg/models/jsonschema/movie.go index eeefe1ed17d..fcf1ffe60a0 100644 --- a/pkg/models/jsonschema/movie.go +++ b/pkg/models/jsonschema/movie.go @@ -11,7 +11,7 @@ import ( "github.com/stashapp/stash/pkg/models/json" ) -type Movie struct { +type Group struct { Name string `json:"name,omitempty"` Aliases string `json:"aliases,omitempty"` Duration int `json:"duration,omitempty"` @@ -31,7 +31,7 @@ type Movie struct { URL string `json:"url,omitempty"` } -func (s Movie) Filename() string { +func (s Group) Filename() string { return fsutil.SanitiseBasename(s.Name) + ".json" } @@ -40,8 +40,8 @@ type MovieSynopsisBC struct { Synopsis string `json:"sypnopsis,omitempty"` } -func LoadMovieFile(filePath string) (*Movie, error) { - var movie Movie +func LoadGroupFile(filePath string) (*Group, error) { + var movie Group file, err := os.Open(filePath) if err != nil { return nil, err @@ -72,7 +72,7 @@ func LoadMovieFile(filePath string) (*Movie, error) { return &movie, nil } -func SaveMovieFile(filePath string, movie *Movie) error { +func SaveGroupFile(filePath string, movie *Group) error { if movie == nil { return fmt.Errorf("movie must not be nil") } diff --git a/pkg/models/jsonschema/scene.go b/pkg/models/jsonschema/scene.go index 9c59610ab79..757f520b869 100644 --- a/pkg/models/jsonschema/scene.go +++ b/pkg/models/jsonschema/scene.go @@ -33,8 +33,8 @@ type SceneFile struct { Bitrate int `json:"bitrate"` } -type SceneMovie struct { - MovieName string `json:"movieName,omitempty"` +type SceneGroup struct { + GroupName string `json:"movieName,omitempty"` SceneIndex int `json:"scene_index,omitempty"` } @@ -58,7 +58,7 @@ type Scene struct { Director string `json:"director,omitempty"` Galleries []GalleryRef `json:"galleries,omitempty"` Performers []string `json:"performers,omitempty"` - Movies []SceneMovie `json:"movies,omitempty"` + Groups []SceneGroup `json:"movies,omitempty"` Tags []string `json:"tags,omitempty"` Markers []SceneMarker `json:"markers,omitempty"` Files []string `json:"files,omitempty"` diff --git a/pkg/models/mocks/MovieReaderWriter.go b/pkg/models/mocks/GroupReaderWriter.go similarity index 59% rename from pkg/models/mocks/MovieReaderWriter.go rename to pkg/models/mocks/GroupReaderWriter.go index 0da8c8a196f..5e3a2644ca8 100644 --- a/pkg/models/mocks/MovieReaderWriter.go +++ b/pkg/models/mocks/GroupReaderWriter.go @@ -9,21 +9,21 @@ import ( mock "github.com/stretchr/testify/mock" ) -// MovieReaderWriter is an autogenerated mock type for the MovieReaderWriter type -type MovieReaderWriter struct { +// GroupReaderWriter is an autogenerated mock type for the GroupReaderWriter type +type GroupReaderWriter struct { mock.Mock } // All provides a mock function with given fields: ctx -func (_m *MovieReaderWriter) All(ctx context.Context) ([]*models.Movie, error) { +func (_m *GroupReaderWriter) All(ctx context.Context) ([]*models.Group, error) { ret := _m.Called(ctx) - var r0 []*models.Movie - if rf, ok := ret.Get(0).(func(context.Context) []*models.Movie); ok { + var r0 []*models.Group + if rf, ok := ret.Get(0).(func(context.Context) []*models.Group); ok { r0 = rf(ctx) } else { if ret.Get(0) != nil { - r0 = ret.Get(0).([]*models.Movie) + r0 = ret.Get(0).([]*models.Group) } } @@ -38,7 +38,7 @@ func (_m *MovieReaderWriter) All(ctx context.Context) ([]*models.Movie, error) { } // Count provides a mock function with given fields: ctx -func (_m *MovieReaderWriter) Count(ctx context.Context) (int, error) { +func (_m *GroupReaderWriter) Count(ctx context.Context) (int, error) { ret := _m.Called(ctx) var r0 int @@ -59,7 +59,7 @@ func (_m *MovieReaderWriter) Count(ctx context.Context) (int, error) { } // CountByPerformerID provides a mock function with given fields: ctx, performerID -func (_m *MovieReaderWriter) CountByPerformerID(ctx context.Context, performerID int) (int, error) { +func (_m *GroupReaderWriter) CountByPerformerID(ctx context.Context, performerID int) (int, error) { ret := _m.Called(ctx, performerID) var r0 int @@ -80,7 +80,7 @@ func (_m *MovieReaderWriter) CountByPerformerID(ctx context.Context, performerID } // CountByStudioID provides a mock function with given fields: ctx, studioID -func (_m *MovieReaderWriter) CountByStudioID(ctx context.Context, studioID int) (int, error) { +func (_m *GroupReaderWriter) CountByStudioID(ctx context.Context, studioID int) (int, error) { ret := _m.Called(ctx, studioID) var r0 int @@ -100,13 +100,13 @@ func (_m *MovieReaderWriter) CountByStudioID(ctx context.Context, studioID int) return r0, r1 } -// Create provides a mock function with given fields: ctx, newMovie -func (_m *MovieReaderWriter) Create(ctx context.Context, newMovie *models.Movie) error { - ret := _m.Called(ctx, newMovie) +// Create provides a mock function with given fields: ctx, newGroup +func (_m *GroupReaderWriter) Create(ctx context.Context, newGroup *models.Group) error { + ret := _m.Called(ctx, newGroup) var r0 error - if rf, ok := ret.Get(0).(func(context.Context, *models.Movie) error); ok { - r0 = rf(ctx, newMovie) + if rf, ok := ret.Get(0).(func(context.Context, *models.Group) error); ok { + r0 = rf(ctx, newGroup) } else { r0 = ret.Error(0) } @@ -115,7 +115,7 @@ func (_m *MovieReaderWriter) Create(ctx context.Context, newMovie *models.Movie) } // Destroy provides a mock function with given fields: ctx, id -func (_m *MovieReaderWriter) Destroy(ctx context.Context, id int) error { +func (_m *GroupReaderWriter) Destroy(ctx context.Context, id int) error { ret := _m.Called(ctx, id) var r0 error @@ -129,15 +129,15 @@ func (_m *MovieReaderWriter) Destroy(ctx context.Context, id int) error { } // Find provides a mock function with given fields: ctx, id -func (_m *MovieReaderWriter) Find(ctx context.Context, id int) (*models.Movie, error) { +func (_m *GroupReaderWriter) Find(ctx context.Context, id int) (*models.Group, error) { ret := _m.Called(ctx, id) - var r0 *models.Movie - if rf, ok := ret.Get(0).(func(context.Context, int) *models.Movie); ok { + var r0 *models.Group + if rf, ok := ret.Get(0).(func(context.Context, int) *models.Group); ok { r0 = rf(ctx, id) } else { if ret.Get(0) != nil { - r0 = ret.Get(0).(*models.Movie) + r0 = ret.Get(0).(*models.Group) } } @@ -152,15 +152,15 @@ func (_m *MovieReaderWriter) Find(ctx context.Context, id int) (*models.Movie, e } // FindByName provides a mock function with given fields: ctx, name, nocase -func (_m *MovieReaderWriter) FindByName(ctx context.Context, name string, nocase bool) (*models.Movie, error) { +func (_m *GroupReaderWriter) FindByName(ctx context.Context, name string, nocase bool) (*models.Group, error) { ret := _m.Called(ctx, name, nocase) - var r0 *models.Movie - if rf, ok := ret.Get(0).(func(context.Context, string, bool) *models.Movie); ok { + var r0 *models.Group + if rf, ok := ret.Get(0).(func(context.Context, string, bool) *models.Group); ok { r0 = rf(ctx, name, nocase) } else { if ret.Get(0) != nil { - r0 = ret.Get(0).(*models.Movie) + r0 = ret.Get(0).(*models.Group) } } @@ -175,15 +175,15 @@ func (_m *MovieReaderWriter) FindByName(ctx context.Context, name string, nocase } // FindByNames provides a mock function with given fields: ctx, names, nocase -func (_m *MovieReaderWriter) FindByNames(ctx context.Context, names []string, nocase bool) ([]*models.Movie, error) { +func (_m *GroupReaderWriter) FindByNames(ctx context.Context, names []string, nocase bool) ([]*models.Group, error) { ret := _m.Called(ctx, names, nocase) - var r0 []*models.Movie - if rf, ok := ret.Get(0).(func(context.Context, []string, bool) []*models.Movie); ok { + var r0 []*models.Group + if rf, ok := ret.Get(0).(func(context.Context, []string, bool) []*models.Group); ok { r0 = rf(ctx, names, nocase) } else { if ret.Get(0) != nil { - r0 = ret.Get(0).([]*models.Movie) + r0 = ret.Get(0).([]*models.Group) } } @@ -198,15 +198,15 @@ func (_m *MovieReaderWriter) FindByNames(ctx context.Context, names []string, no } // FindByPerformerID provides a mock function with given fields: ctx, performerID -func (_m *MovieReaderWriter) FindByPerformerID(ctx context.Context, performerID int) ([]*models.Movie, error) { +func (_m *GroupReaderWriter) FindByPerformerID(ctx context.Context, performerID int) ([]*models.Group, error) { ret := _m.Called(ctx, performerID) - var r0 []*models.Movie - if rf, ok := ret.Get(0).(func(context.Context, int) []*models.Movie); ok { + var r0 []*models.Group + if rf, ok := ret.Get(0).(func(context.Context, int) []*models.Group); ok { r0 = rf(ctx, performerID) } else { if ret.Get(0) != nil { - r0 = ret.Get(0).([]*models.Movie) + r0 = ret.Get(0).([]*models.Group) } } @@ -221,15 +221,15 @@ func (_m *MovieReaderWriter) FindByPerformerID(ctx context.Context, performerID } // FindByStudioID provides a mock function with given fields: ctx, studioID -func (_m *MovieReaderWriter) FindByStudioID(ctx context.Context, studioID int) ([]*models.Movie, error) { +func (_m *GroupReaderWriter) FindByStudioID(ctx context.Context, studioID int) ([]*models.Group, error) { ret := _m.Called(ctx, studioID) - var r0 []*models.Movie - if rf, ok := ret.Get(0).(func(context.Context, int) []*models.Movie); ok { + var r0 []*models.Group + if rf, ok := ret.Get(0).(func(context.Context, int) []*models.Group); ok { r0 = rf(ctx, studioID) } else { if ret.Get(0) != nil { - r0 = ret.Get(0).([]*models.Movie) + r0 = ret.Get(0).([]*models.Group) } } @@ -244,15 +244,15 @@ func (_m *MovieReaderWriter) FindByStudioID(ctx context.Context, studioID int) ( } // FindMany provides a mock function with given fields: ctx, ids -func (_m *MovieReaderWriter) FindMany(ctx context.Context, ids []int) ([]*models.Movie, error) { +func (_m *GroupReaderWriter) FindMany(ctx context.Context, ids []int) ([]*models.Group, error) { ret := _m.Called(ctx, ids) - var r0 []*models.Movie - if rf, ok := ret.Get(0).(func(context.Context, []int) []*models.Movie); ok { + var r0 []*models.Group + if rf, ok := ret.Get(0).(func(context.Context, []int) []*models.Group); ok { r0 = rf(ctx, ids) } else { if ret.Get(0) != nil { - r0 = ret.Get(0).([]*models.Movie) + r0 = ret.Get(0).([]*models.Group) } } @@ -266,13 +266,13 @@ func (_m *MovieReaderWriter) FindMany(ctx context.Context, ids []int) ([]*models return r0, r1 } -// GetBackImage provides a mock function with given fields: ctx, movieID -func (_m *MovieReaderWriter) GetBackImage(ctx context.Context, movieID int) ([]byte, error) { - ret := _m.Called(ctx, movieID) +// GetBackImage provides a mock function with given fields: ctx, groupID +func (_m *GroupReaderWriter) GetBackImage(ctx context.Context, groupID int) ([]byte, error) { + ret := _m.Called(ctx, groupID) var r0 []byte if rf, ok := ret.Get(0).(func(context.Context, int) []byte); ok { - r0 = rf(ctx, movieID) + r0 = rf(ctx, groupID) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]byte) @@ -281,7 +281,7 @@ func (_m *MovieReaderWriter) GetBackImage(ctx context.Context, movieID int) ([]b var r1 error if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { - r1 = rf(ctx, movieID) + r1 = rf(ctx, groupID) } else { r1 = ret.Error(1) } @@ -289,13 +289,13 @@ func (_m *MovieReaderWriter) GetBackImage(ctx context.Context, movieID int) ([]b return r0, r1 } -// GetFrontImage provides a mock function with given fields: ctx, movieID -func (_m *MovieReaderWriter) GetFrontImage(ctx context.Context, movieID int) ([]byte, error) { - ret := _m.Called(ctx, movieID) +// GetFrontImage provides a mock function with given fields: ctx, groupID +func (_m *GroupReaderWriter) GetFrontImage(ctx context.Context, groupID int) ([]byte, error) { + ret := _m.Called(ctx, groupID) var r0 []byte if rf, ok := ret.Get(0).(func(context.Context, int) []byte); ok { - r0 = rf(ctx, movieID) + r0 = rf(ctx, groupID) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]byte) @@ -304,7 +304,7 @@ func (_m *MovieReaderWriter) GetFrontImage(ctx context.Context, movieID int) ([] var r1 error if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { - r1 = rf(ctx, movieID) + r1 = rf(ctx, groupID) } else { r1 = ret.Error(1) } @@ -313,7 +313,7 @@ func (_m *MovieReaderWriter) GetFrontImage(ctx context.Context, movieID int) ([] } // GetTagIDs provides a mock function with given fields: ctx, relatedID -func (_m *MovieReaderWriter) GetTagIDs(ctx context.Context, relatedID int) ([]int, error) { +func (_m *GroupReaderWriter) GetTagIDs(ctx context.Context, relatedID int) ([]int, error) { ret := _m.Called(ctx, relatedID) var r0 []int @@ -336,7 +336,7 @@ func (_m *MovieReaderWriter) GetTagIDs(ctx context.Context, relatedID int) ([]in } // GetURLs provides a mock function with given fields: ctx, relatedID -func (_m *MovieReaderWriter) GetURLs(ctx context.Context, relatedID int) ([]string, error) { +func (_m *GroupReaderWriter) GetURLs(ctx context.Context, relatedID int) ([]string, error) { ret := _m.Called(ctx, relatedID) var r0 []string @@ -358,20 +358,20 @@ func (_m *MovieReaderWriter) GetURLs(ctx context.Context, relatedID int) ([]stri return r0, r1 } -// HasBackImage provides a mock function with given fields: ctx, movieID -func (_m *MovieReaderWriter) HasBackImage(ctx context.Context, movieID int) (bool, error) { - ret := _m.Called(ctx, movieID) +// HasBackImage provides a mock function with given fields: ctx, groupID +func (_m *GroupReaderWriter) HasBackImage(ctx context.Context, groupID int) (bool, error) { + ret := _m.Called(ctx, groupID) var r0 bool if rf, ok := ret.Get(0).(func(context.Context, int) bool); ok { - r0 = rf(ctx, movieID) + r0 = rf(ctx, groupID) } else { r0 = ret.Get(0).(bool) } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { - r1 = rf(ctx, movieID) + r1 = rf(ctx, groupID) } else { r1 = ret.Error(1) } @@ -379,20 +379,20 @@ func (_m *MovieReaderWriter) HasBackImage(ctx context.Context, movieID int) (boo return r0, r1 } -// HasFrontImage provides a mock function with given fields: ctx, movieID -func (_m *MovieReaderWriter) HasFrontImage(ctx context.Context, movieID int) (bool, error) { - ret := _m.Called(ctx, movieID) +// HasFrontImage provides a mock function with given fields: ctx, groupID +func (_m *GroupReaderWriter) HasFrontImage(ctx context.Context, groupID int) (bool, error) { + ret := _m.Called(ctx, groupID) var r0 bool if rf, ok := ret.Get(0).(func(context.Context, int) bool); ok { - r0 = rf(ctx, movieID) + r0 = rf(ctx, groupID) } else { r0 = ret.Get(0).(bool) } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { - r1 = rf(ctx, movieID) + r1 = rf(ctx, groupID) } else { r1 = ret.Error(1) } @@ -400,29 +400,29 @@ func (_m *MovieReaderWriter) HasFrontImage(ctx context.Context, movieID int) (bo return r0, r1 } -// Query provides a mock function with given fields: ctx, movieFilter, findFilter -func (_m *MovieReaderWriter) Query(ctx context.Context, movieFilter *models.MovieFilterType, findFilter *models.FindFilterType) ([]*models.Movie, int, error) { - ret := _m.Called(ctx, movieFilter, findFilter) +// Query provides a mock function with given fields: ctx, groupFilter, findFilter +func (_m *GroupReaderWriter) Query(ctx context.Context, groupFilter *models.GroupFilterType, findFilter *models.FindFilterType) ([]*models.Group, int, error) { + ret := _m.Called(ctx, groupFilter, findFilter) - var r0 []*models.Movie - if rf, ok := ret.Get(0).(func(context.Context, *models.MovieFilterType, *models.FindFilterType) []*models.Movie); ok { - r0 = rf(ctx, movieFilter, findFilter) + var r0 []*models.Group + if rf, ok := ret.Get(0).(func(context.Context, *models.GroupFilterType, *models.FindFilterType) []*models.Group); ok { + r0 = rf(ctx, groupFilter, findFilter) } else { if ret.Get(0) != nil { - r0 = ret.Get(0).([]*models.Movie) + r0 = ret.Get(0).([]*models.Group) } } var r1 int - if rf, ok := ret.Get(1).(func(context.Context, *models.MovieFilterType, *models.FindFilterType) int); ok { - r1 = rf(ctx, movieFilter, findFilter) + if rf, ok := ret.Get(1).(func(context.Context, *models.GroupFilterType, *models.FindFilterType) int); ok { + r1 = rf(ctx, groupFilter, findFilter) } else { r1 = ret.Get(1).(int) } var r2 error - if rf, ok := ret.Get(2).(func(context.Context, *models.MovieFilterType, *models.FindFilterType) error); ok { - r2 = rf(ctx, movieFilter, findFilter) + if rf, ok := ret.Get(2).(func(context.Context, *models.GroupFilterType, *models.FindFilterType) error); ok { + r2 = rf(ctx, groupFilter, findFilter) } else { r2 = ret.Error(2) } @@ -430,20 +430,20 @@ func (_m *MovieReaderWriter) Query(ctx context.Context, movieFilter *models.Movi return r0, r1, r2 } -// QueryCount provides a mock function with given fields: ctx, movieFilter, findFilter -func (_m *MovieReaderWriter) QueryCount(ctx context.Context, movieFilter *models.MovieFilterType, findFilter *models.FindFilterType) (int, error) { - ret := _m.Called(ctx, movieFilter, findFilter) +// QueryCount provides a mock function with given fields: ctx, groupFilter, findFilter +func (_m *GroupReaderWriter) QueryCount(ctx context.Context, groupFilter *models.GroupFilterType, findFilter *models.FindFilterType) (int, error) { + ret := _m.Called(ctx, groupFilter, findFilter) var r0 int - if rf, ok := ret.Get(0).(func(context.Context, *models.MovieFilterType, *models.FindFilterType) int); ok { - r0 = rf(ctx, movieFilter, findFilter) + if rf, ok := ret.Get(0).(func(context.Context, *models.GroupFilterType, *models.FindFilterType) int); ok { + r0 = rf(ctx, groupFilter, findFilter) } else { r0 = ret.Get(0).(int) } var r1 error - if rf, ok := ret.Get(1).(func(context.Context, *models.MovieFilterType, *models.FindFilterType) error); ok { - r1 = rf(ctx, movieFilter, findFilter) + if rf, ok := ret.Get(1).(func(context.Context, *models.GroupFilterType, *models.FindFilterType) error); ok { + r1 = rf(ctx, groupFilter, findFilter) } else { r1 = ret.Error(1) } @@ -451,13 +451,13 @@ func (_m *MovieReaderWriter) QueryCount(ctx context.Context, movieFilter *models return r0, r1 } -// Update provides a mock function with given fields: ctx, updatedMovie -func (_m *MovieReaderWriter) Update(ctx context.Context, updatedMovie *models.Movie) error { - ret := _m.Called(ctx, updatedMovie) +// Update provides a mock function with given fields: ctx, updatedGroup +func (_m *GroupReaderWriter) Update(ctx context.Context, updatedGroup *models.Group) error { + ret := _m.Called(ctx, updatedGroup) var r0 error - if rf, ok := ret.Get(0).(func(context.Context, *models.Movie) error); ok { - r0 = rf(ctx, updatedMovie) + if rf, ok := ret.Get(0).(func(context.Context, *models.Group) error); ok { + r0 = rf(ctx, updatedGroup) } else { r0 = ret.Error(0) } @@ -465,13 +465,13 @@ func (_m *MovieReaderWriter) Update(ctx context.Context, updatedMovie *models.Mo return r0 } -// UpdateBackImage provides a mock function with given fields: ctx, movieID, backImage -func (_m *MovieReaderWriter) UpdateBackImage(ctx context.Context, movieID int, backImage []byte) error { - ret := _m.Called(ctx, movieID, backImage) +// UpdateBackImage provides a mock function with given fields: ctx, groupID, backImage +func (_m *GroupReaderWriter) UpdateBackImage(ctx context.Context, groupID int, backImage []byte) error { + ret := _m.Called(ctx, groupID, backImage) var r0 error if rf, ok := ret.Get(0).(func(context.Context, int, []byte) error); ok { - r0 = rf(ctx, movieID, backImage) + r0 = rf(ctx, groupID, backImage) } else { r0 = ret.Error(0) } @@ -479,13 +479,13 @@ func (_m *MovieReaderWriter) UpdateBackImage(ctx context.Context, movieID int, b return r0 } -// UpdateFrontImage provides a mock function with given fields: ctx, movieID, frontImage -func (_m *MovieReaderWriter) UpdateFrontImage(ctx context.Context, movieID int, frontImage []byte) error { - ret := _m.Called(ctx, movieID, frontImage) +// UpdateFrontImage provides a mock function with given fields: ctx, groupID, frontImage +func (_m *GroupReaderWriter) UpdateFrontImage(ctx context.Context, groupID int, frontImage []byte) error { + ret := _m.Called(ctx, groupID, frontImage) var r0 error if rf, ok := ret.Get(0).(func(context.Context, int, []byte) error); ok { - r0 = rf(ctx, movieID, frontImage) + r0 = rf(ctx, groupID, frontImage) } else { r0 = ret.Error(0) } @@ -493,22 +493,22 @@ func (_m *MovieReaderWriter) UpdateFrontImage(ctx context.Context, movieID int, return r0 } -// UpdatePartial provides a mock function with given fields: ctx, id, updatedMovie -func (_m *MovieReaderWriter) UpdatePartial(ctx context.Context, id int, updatedMovie models.MoviePartial) (*models.Movie, error) { - ret := _m.Called(ctx, id, updatedMovie) +// UpdatePartial provides a mock function with given fields: ctx, id, updatedGroup +func (_m *GroupReaderWriter) UpdatePartial(ctx context.Context, id int, updatedGroup models.GroupPartial) (*models.Group, error) { + ret := _m.Called(ctx, id, updatedGroup) - var r0 *models.Movie - if rf, ok := ret.Get(0).(func(context.Context, int, models.MoviePartial) *models.Movie); ok { - r0 = rf(ctx, id, updatedMovie) + var r0 *models.Group + if rf, ok := ret.Get(0).(func(context.Context, int, models.GroupPartial) *models.Group); ok { + r0 = rf(ctx, id, updatedGroup) } else { if ret.Get(0) != nil { - r0 = ret.Get(0).(*models.Movie) + r0 = ret.Get(0).(*models.Group) } } var r1 error - if rf, ok := ret.Get(1).(func(context.Context, int, models.MoviePartial) error); ok { - r1 = rf(ctx, id, updatedMovie) + if rf, ok := ret.Get(1).(func(context.Context, int, models.GroupPartial) error); ok { + r1 = rf(ctx, id, updatedGroup) } else { r1 = ret.Error(1) } diff --git a/pkg/models/mocks/SavedFilterReaderWriter.go b/pkg/models/mocks/SavedFilterReaderWriter.go index 65573854682..53f5cb0e50a 100644 --- a/pkg/models/mocks/SavedFilterReaderWriter.go +++ b/pkg/models/mocks/SavedFilterReaderWriter.go @@ -111,29 +111,6 @@ func (_m *SavedFilterReaderWriter) FindByMode(ctx context.Context, mode models.F return r0, r1 } -// FindDefault provides a mock function with given fields: ctx, mode -func (_m *SavedFilterReaderWriter) FindDefault(ctx context.Context, mode models.FilterMode) (*models.SavedFilter, error) { - ret := _m.Called(ctx, mode) - - var r0 *models.SavedFilter - if rf, ok := ret.Get(0).(func(context.Context, models.FilterMode) *models.SavedFilter); ok { - r0 = rf(ctx, mode) - } else { - if ret.Get(0) != nil { - r0 = ret.Get(0).(*models.SavedFilter) - } - } - - var r1 error - if rf, ok := ret.Get(1).(func(context.Context, models.FilterMode) error); ok { - r1 = rf(ctx, mode) - } else { - r1 = ret.Error(1) - } - - return r0, r1 -} - // FindMany provides a mock function with given fields: ctx, ids, ignoreNotFound func (_m *SavedFilterReaderWriter) FindMany(ctx context.Context, ids []int, ignoreNotFound bool) ([]*models.SavedFilter, error) { ret := _m.Called(ctx, ids, ignoreNotFound) @@ -157,20 +134,6 @@ func (_m *SavedFilterReaderWriter) FindMany(ctx context.Context, ids []int, igno return r0, r1 } -// SetDefault provides a mock function with given fields: ctx, obj -func (_m *SavedFilterReaderWriter) SetDefault(ctx context.Context, obj *models.SavedFilter) error { - ret := _m.Called(ctx, obj) - - var r0 error - if rf, ok := ret.Get(0).(func(context.Context, *models.SavedFilter) error); ok { - r0 = rf(ctx, obj) - } else { - r0 = ret.Error(0) - } - - return r0 -} - // Update provides a mock function with given fields: ctx, obj func (_m *SavedFilterReaderWriter) Update(ctx context.Context, obj *models.SavedFilter) error { ret := _m.Called(ctx, obj) diff --git a/pkg/models/mocks/SceneReaderWriter.go b/pkg/models/mocks/SceneReaderWriter.go index 080e40b0d29..3787d8182d3 100644 --- a/pkg/models/mocks/SceneReaderWriter.go +++ b/pkg/models/mocks/SceneReaderWriter.go @@ -190,20 +190,20 @@ func (_m *SceneReaderWriter) CountByFileID(ctx context.Context, fileID models.Fi return r0, r1 } -// CountByMovieID provides a mock function with given fields: ctx, movieID -func (_m *SceneReaderWriter) CountByMovieID(ctx context.Context, movieID int) (int, error) { - ret := _m.Called(ctx, movieID) +// CountByGroupID provides a mock function with given fields: ctx, groupID +func (_m *SceneReaderWriter) CountByGroupID(ctx context.Context, groupID int) (int, error) { + ret := _m.Called(ctx, groupID) var r0 int if rf, ok := ret.Get(0).(func(context.Context, int) int); ok { - r0 = rf(ctx, movieID) + r0 = rf(ctx, groupID) } else { r0 = ret.Get(0).(int) } var r1 error if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { - r1 = rf(ctx, movieID) + r1 = rf(ctx, groupID) } else { r1 = ret.Error(1) } @@ -589,13 +589,13 @@ func (_m *SceneReaderWriter) FindByGalleryID(ctx context.Context, performerID in return r0, r1 } -// FindByMovieID provides a mock function with given fields: ctx, movieID -func (_m *SceneReaderWriter) FindByMovieID(ctx context.Context, movieID int) ([]*models.Scene, error) { - ret := _m.Called(ctx, movieID) +// FindByGroupID provides a mock function with given fields: ctx, groupID +func (_m *SceneReaderWriter) FindByGroupID(ctx context.Context, groupID int) ([]*models.Scene, error) { + ret := _m.Called(ctx, groupID) var r0 []*models.Scene if rf, ok := ret.Get(0).(func(context.Context, int) []*models.Scene); ok { - r0 = rf(ctx, movieID) + r0 = rf(ctx, groupID) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*models.Scene) @@ -604,7 +604,7 @@ func (_m *SceneReaderWriter) FindByMovieID(ctx context.Context, movieID int) ([] var r1 error if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { - r1 = rf(ctx, movieID) + r1 = rf(ctx, groupID) } else { r1 = ret.Error(1) } @@ -840,6 +840,29 @@ func (_m *SceneReaderWriter) GetGalleryIDs(ctx context.Context, relatedID int) ( return r0, r1 } +// GetGroups provides a mock function with given fields: ctx, id +func (_m *SceneReaderWriter) GetGroups(ctx context.Context, id int) ([]models.GroupsScenes, error) { + ret := _m.Called(ctx, id) + + var r0 []models.GroupsScenes + if rf, ok := ret.Get(0).(func(context.Context, int) []models.GroupsScenes); ok { + r0 = rf(ctx, id) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]models.GroupsScenes) + } + } + + 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 +} + // GetManyFileIDs provides a mock function with given fields: ctx, ids func (_m *SceneReaderWriter) GetManyFileIDs(ctx context.Context, ids []int) ([][]models.FileID, error) { ret := _m.Called(ctx, ids) @@ -978,29 +1001,6 @@ func (_m *SceneReaderWriter) GetManyViewDates(ctx context.Context, ids []int) ([ return r0, r1 } -// GetMovies provides a mock function with given fields: ctx, id -func (_m *SceneReaderWriter) GetMovies(ctx context.Context, id int) ([]models.MoviesScenes, error) { - ret := _m.Called(ctx, id) - - var r0 []models.MoviesScenes - if rf, ok := ret.Get(0).(func(context.Context, int) []models.MoviesScenes); ok { - r0 = rf(ctx, id) - } else { - if ret.Get(0) != nil { - r0 = ret.Get(0).([]models.MoviesScenes) - } - } - - 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 -} - // GetOCount provides a mock function with given fields: ctx, id func (_m *SceneReaderWriter) GetOCount(ctx context.Context, id int) (int, error) { ret := _m.Called(ctx, id) diff --git a/pkg/models/mocks/TagReaderWriter.go b/pkg/models/mocks/TagReaderWriter.go index c3dfe7bd255..a285b97bf00 100644 --- a/pkg/models/mocks/TagReaderWriter.go +++ b/pkg/models/mocks/TagReaderWriter.go @@ -243,13 +243,13 @@ func (_m *TagReaderWriter) FindByGalleryID(ctx context.Context, galleryID int) ( return r0, r1 } -// FindByImageID provides a mock function with given fields: ctx, imageID -func (_m *TagReaderWriter) FindByImageID(ctx context.Context, imageID int) ([]*models.Tag, error) { - ret := _m.Called(ctx, imageID) +// FindByGroupID provides a mock function with given fields: ctx, groupID +func (_m *TagReaderWriter) FindByGroupID(ctx context.Context, groupID int) ([]*models.Tag, error) { + ret := _m.Called(ctx, groupID) var r0 []*models.Tag if rf, ok := ret.Get(0).(func(context.Context, int) []*models.Tag); ok { - r0 = rf(ctx, imageID) + r0 = rf(ctx, groupID) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*models.Tag) @@ -258,7 +258,7 @@ func (_m *TagReaderWriter) FindByImageID(ctx context.Context, imageID int) ([]*m var r1 error if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { - r1 = rf(ctx, imageID) + r1 = rf(ctx, groupID) } else { r1 = ret.Error(1) } @@ -266,13 +266,13 @@ func (_m *TagReaderWriter) FindByImageID(ctx context.Context, imageID int) ([]*m return r0, r1 } -// FindByMovieID provides a mock function with given fields: ctx, movieID -func (_m *TagReaderWriter) FindByMovieID(ctx context.Context, movieID int) ([]*models.Tag, error) { - ret := _m.Called(ctx, movieID) +// FindByImageID provides a mock function with given fields: ctx, imageID +func (_m *TagReaderWriter) FindByImageID(ctx context.Context, imageID int) ([]*models.Tag, error) { + ret := _m.Called(ctx, imageID) var r0 []*models.Tag if rf, ok := ret.Get(0).(func(context.Context, int) []*models.Tag); ok { - r0 = rf(ctx, movieID) + r0 = rf(ctx, imageID) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*models.Tag) @@ -281,7 +281,7 @@ func (_m *TagReaderWriter) FindByMovieID(ctx context.Context, movieID int) ([]*m var r1 error if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { - r1 = rf(ctx, movieID) + r1 = rf(ctx, imageID) } else { r1 = ret.Error(1) } diff --git a/pkg/models/mocks/database.go b/pkg/models/mocks/database.go index 83d2cbfabad..1892992108c 100644 --- a/pkg/models/mocks/database.go +++ b/pkg/models/mocks/database.go @@ -14,7 +14,7 @@ type Database struct { Gallery *GalleryReaderWriter GalleryChapter *GalleryChapterReaderWriter Image *ImageReaderWriter - Movie *MovieReaderWriter + Group *GroupReaderWriter Performer *PerformerReaderWriter Scene *SceneReaderWriter SceneMarker *SceneMarkerReaderWriter @@ -63,7 +63,7 @@ func NewDatabase() *Database { Gallery: &GalleryReaderWriter{}, GalleryChapter: &GalleryChapterReaderWriter{}, Image: &ImageReaderWriter{}, - Movie: &MovieReaderWriter{}, + Group: &GroupReaderWriter{}, Performer: &PerformerReaderWriter{}, Scene: &SceneReaderWriter{}, SceneMarker: &SceneMarkerReaderWriter{}, @@ -79,7 +79,7 @@ func (db *Database) AssertExpectations(t mock.TestingT) { db.Gallery.AssertExpectations(t) db.GalleryChapter.AssertExpectations(t) db.Image.AssertExpectations(t) - db.Movie.AssertExpectations(t) + db.Group.AssertExpectations(t) db.Performer.AssertExpectations(t) db.Scene.AssertExpectations(t) db.SceneMarker.AssertExpectations(t) @@ -96,7 +96,7 @@ func (db *Database) Repository() models.Repository { Gallery: db.Gallery, GalleryChapter: db.GalleryChapter, Image: db.Image, - Movie: db.Movie, + Group: db.Group, Performer: db.Performer, Scene: db.Scene, SceneMarker: db.SceneMarker, diff --git a/pkg/models/model_joins.go b/pkg/models/model_joins.go index da70293c3d3..189c2d7721f 100644 --- a/pkg/models/model_joins.go +++ b/pkg/models/model_joins.go @@ -5,54 +5,54 @@ import ( "strconv" ) -type MoviesScenes struct { - MovieID int `json:"movie_id"` +type GroupsScenes struct { + GroupID int `json:"movie_id"` // SceneID int `json:"scene_id"` SceneIndex *int `json:"scene_index"` } -func (s MoviesScenes) SceneMovieInput() SceneMovieInput { +func (s GroupsScenes) SceneMovieInput() SceneMovieInput { return SceneMovieInput{ - MovieID: strconv.Itoa(s.MovieID), + MovieID: strconv.Itoa(s.GroupID), SceneIndex: s.SceneIndex, } } -func (s MoviesScenes) Equal(o MoviesScenes) bool { - return o.MovieID == s.MovieID && ((o.SceneIndex == nil && s.SceneIndex == nil) || +func (s GroupsScenes) Equal(o GroupsScenes) bool { + return o.GroupID == s.GroupID && ((o.SceneIndex == nil && s.SceneIndex == nil) || (o.SceneIndex != nil && s.SceneIndex != nil && *o.SceneIndex == *s.SceneIndex)) } -type UpdateMovieIDs struct { - Movies []MoviesScenes `json:"movies"` +type UpdateGroupIDs struct { + Groups []GroupsScenes `json:"movies"` Mode RelationshipUpdateMode `json:"mode"` } -func (u *UpdateMovieIDs) SceneMovieInputs() []SceneMovieInput { +func (u *UpdateGroupIDs) SceneMovieInputs() []SceneMovieInput { if u == nil { return nil } - ret := make([]SceneMovieInput, len(u.Movies)) - for _, id := range u.Movies { + ret := make([]SceneMovieInput, len(u.Groups)) + for _, id := range u.Groups { ret = append(ret, id.SceneMovieInput()) } return ret } -func (u *UpdateMovieIDs) AddUnique(v MoviesScenes) { - for _, vv := range u.Movies { - if vv.MovieID == v.MovieID { +func (u *UpdateGroupIDs) AddUnique(v GroupsScenes) { + for _, vv := range u.Groups { + if vv.GroupID == v.GroupID { return } } - u.Movies = append(u.Movies, v) + u.Groups = append(u.Groups, v) } -func MoviesScenesFromInput(input []SceneMovieInput) ([]MoviesScenes, error) { - ret := make([]MoviesScenes, len(input)) +func GroupsScenesFromInput(input []SceneMovieInput) ([]GroupsScenes, error) { + ret := make([]GroupsScenes, len(input)) for i, v := range input { mID, err := strconv.Atoi(v.MovieID) @@ -60,8 +60,8 @@ func MoviesScenesFromInput(input []SceneMovieInput) ([]MoviesScenes, error) { return nil, fmt.Errorf("invalid movie ID: %s", v.MovieID) } - ret[i] = MoviesScenes{ - MovieID: mID, + ret[i] = GroupsScenes{ + GroupID: mID, SceneIndex: v.SceneIndex, } } diff --git a/pkg/models/model_movie.go b/pkg/models/model_movie.go index cd8bb848c80..af3ac56c68c 100644 --- a/pkg/models/model_movie.go +++ b/pkg/models/model_movie.go @@ -5,7 +5,7 @@ import ( "time" ) -type Movie struct { +type Group struct { ID int `json:"id"` Name string `json:"name"` Aliases string `json:"aliases"` @@ -23,27 +23,27 @@ type Movie struct { TagIDs RelatedIDs `json:"tag_ids"` } -func NewMovie() Movie { +func NewGroup() Group { currentTime := time.Now() - return Movie{ + return Group{ CreatedAt: currentTime, UpdatedAt: currentTime, } } -func (m *Movie) LoadURLs(ctx context.Context, l URLLoader) error { +func (m *Group) LoadURLs(ctx context.Context, l URLLoader) error { return m.URLs.load(func() ([]string, error) { return l.GetURLs(ctx, m.ID) }) } -func (m *Movie) LoadTagIDs(ctx context.Context, l TagIDLoader) error { +func (m *Group) LoadTagIDs(ctx context.Context, l TagIDLoader) error { return m.TagIDs.load(func() ([]int, error) { return l.GetTagIDs(ctx, m.ID) }) } -type MoviePartial struct { +type GroupPartial struct { Name OptionalString Aliases OptionalString Duration OptionalInt @@ -59,9 +59,9 @@ type MoviePartial struct { UpdatedAt OptionalTime } -func NewMoviePartial() MoviePartial { +func NewGroupPartial() GroupPartial { currentTime := time.Now() - return MoviePartial{ + return GroupPartial{ UpdatedAt: NewOptionalTime(currentTime), } } diff --git a/pkg/models/model_scene.go b/pkg/models/model_scene.go index 7b7770471a4..3f26a8cb6d8 100644 --- a/pkg/models/model_scene.go +++ b/pkg/models/model_scene.go @@ -41,7 +41,7 @@ type Scene struct { GalleryIDs RelatedIDs `json:"gallery_ids"` TagIDs RelatedIDs `json:"tag_ids"` PerformerIDs RelatedIDs `json:"performer_ids"` - Movies RelatedMovies `json:"movies"` + Groups RelatedGroups `json:"groups"` StashIDs RelatedStashIDs `json:"stash_ids"` } @@ -74,7 +74,7 @@ type ScenePartial struct { GalleryIDs *UpdateIDs TagIDs *UpdateIDs PerformerIDs *UpdateIDs - MovieIDs *UpdateMovieIDs + GroupIDs *UpdateGroupIDs StashIDs *UpdateStashIDs PrimaryFileID *FileID } @@ -139,9 +139,9 @@ func (s *Scene) LoadTagIDs(ctx context.Context, l TagIDLoader) error { }) } -func (s *Scene) LoadMovies(ctx context.Context, l SceneMovieLoader) error { - return s.Movies.load(func() ([]MoviesScenes, error) { - return l.GetMovies(ctx, s.ID) +func (s *Scene) LoadGroups(ctx context.Context, l SceneGroupLoader) error { + return s.Groups.load(func() ([]GroupsScenes, error) { + return l.GetGroups(ctx, s.ID) }) } @@ -168,7 +168,7 @@ func (s *Scene) LoadRelationships(ctx context.Context, l SceneReader) error { return err } - if err := s.LoadMovies(ctx, l); err != nil { + if err := s.LoadGroups(ctx, l); err != nil { return err } @@ -210,7 +210,7 @@ func (s ScenePartial) UpdateInput(id int) SceneUpdateInput { StudioID: s.StudioID.StringPtr(), GalleryIds: s.GalleryIDs.IDStrings(), PerformerIds: s.PerformerIDs.IDStrings(), - Movies: s.MovieIDs.SceneMovieInputs(), + Movies: s.GroupIDs.SceneMovieInputs(), TagIds: s.TagIDs.IDStrings(), StashIds: stashIDs, } diff --git a/pkg/models/model_scraped_item.go b/pkg/models/model_scraped_item.go index e95fc6df413..35f781109cb 100644 --- a/pkg/models/model_scraped_item.go +++ b/pkg/models/model_scraped_item.go @@ -415,6 +415,30 @@ type ScrapedMovie struct { func (ScrapedMovie) IsScrapedContent() {} +func (m ScrapedMovie) ScrapedGroup() ScrapedGroup { + ret := ScrapedGroup{ + StoredID: m.StoredID, + Name: m.Name, + Aliases: m.Aliases, + Duration: m.Duration, + Date: m.Date, + Rating: m.Rating, + Director: m.Director, + URLs: m.URLs, + Synopsis: m.Synopsis, + Studio: m.Studio, + Tags: m.Tags, + FrontImage: m.FrontImage, + BackImage: m.BackImage, + } + + if len(m.URLs) == 0 && m.URL != nil { + ret.URLs = []string{*m.URL} + } + + return ret +} + // ScrapedGroup is a group from a scraping operation type ScrapedGroup struct { StoredID *string `json:"stored_id"` @@ -435,3 +459,27 @@ type ScrapedGroup struct { } func (ScrapedGroup) IsScrapedContent() {} + +func (g ScrapedGroup) ScrapedMovie() ScrapedMovie { + ret := ScrapedMovie{ + StoredID: g.StoredID, + Name: g.Name, + Aliases: g.Aliases, + Duration: g.Duration, + Date: g.Date, + Rating: g.Rating, + Director: g.Director, + URLs: g.URLs, + Synopsis: g.Synopsis, + Studio: g.Studio, + Tags: g.Tags, + FrontImage: g.FrontImage, + BackImage: g.BackImage, + } + + if len(g.URLs) > 0 { + ret.URL = &g.URLs[0] + } + + return ret +} diff --git a/pkg/models/movie.go b/pkg/models/movie.go index 5fb98190dbd..db7badccc90 100644 --- a/pkg/models/movie.go +++ b/pkg/models/movie.go @@ -1,7 +1,7 @@ package models -type MovieFilterType struct { - OperatorFilter[MovieFilterType] +type GroupFilterType struct { + OperatorFilter[GroupFilterType] Name *StringCriterionInput `json:"name"` Director *StringCriterionInput `json:"director"` Synopsis *StringCriterionInput `json:"synopsis"` diff --git a/pkg/models/paths/paths_json.go b/pkg/models/paths/paths_json.go index 7f05027c40f..e6e302238f5 100644 --- a/pkg/models/paths/paths_json.go +++ b/pkg/models/paths/paths_json.go @@ -18,7 +18,7 @@ type JSONPaths struct { Galleries string Studios string Tags string - Movies string + Groups string Files string } @@ -31,7 +31,7 @@ func newJSONPaths(baseDir string) *JSONPaths { jp.Images = filepath.Join(baseDir, "images") jp.Galleries = filepath.Join(baseDir, "galleries") jp.Studios = filepath.Join(baseDir, "studios") - jp.Movies = filepath.Join(baseDir, "movies") + jp.Groups = filepath.Join(baseDir, "movies") jp.Tags = filepath.Join(baseDir, "tags") jp.Files = filepath.Join(baseDir, "files") return &jp @@ -49,7 +49,7 @@ func EmptyJSONDirs(baseDir string) { _ = fsutil.EmptyDir(jsonPaths.Galleries) _ = fsutil.EmptyDir(jsonPaths.Performers) _ = fsutil.EmptyDir(jsonPaths.Studios) - _ = fsutil.EmptyDir(jsonPaths.Movies) + _ = fsutil.EmptyDir(jsonPaths.Groups) _ = fsutil.EmptyDir(jsonPaths.Tags) _ = fsutil.EmptyDir(jsonPaths.Files) } @@ -74,8 +74,8 @@ func EnsureJSONDirs(baseDir string) { if err := fsutil.EnsureDir(jsonPaths.Studios); err != nil { logger.Warnf("couldn't create directories for Studios: %v", err) } - if err := fsutil.EnsureDir(jsonPaths.Movies); err != nil { - logger.Warnf("couldn't create directories for Movies: %v", err) + if err := fsutil.EnsureDir(jsonPaths.Groups); err != nil { + logger.Warnf("couldn't create directories for Groups: %v", err) } if err := fsutil.EnsureDir(jsonPaths.Tags); err != nil { logger.Warnf("couldn't create directories for Tags: %v", err) diff --git a/pkg/models/relationships.go b/pkg/models/relationships.go index 021fab4dbfb..81528c26e95 100644 --- a/pkg/models/relationships.go +++ b/pkg/models/relationships.go @@ -33,8 +33,8 @@ type FileIDLoader interface { GetManyFileIDs(ctx context.Context, ids []int) ([][]FileID, error) } -type SceneMovieLoader interface { - GetMovies(ctx context.Context, id int) ([]MoviesScenes, error) +type SceneGroupLoader interface { + GetGroups(ctx context.Context, id int) ([]GroupsScenes, error) } type StashIDLoader interface { @@ -115,50 +115,50 @@ func (r *RelatedIDs) load(fn func() ([]int, error)) error { return nil } -// RelatedMovies represents a list of related Movies. -type RelatedMovies struct { - list []MoviesScenes +// RelatedGroups represents a list of related Groups. +type RelatedGroups struct { + list []GroupsScenes } -// NewRelatedMovies returns a loaded RelatedMovies object with the provided movies. +// NewRelatedGroups returns a loaded RelateGroups object with the provided groups. // Loaded will return true when called on the returned object if the provided slice is not nil. -func NewRelatedMovies(list []MoviesScenes) RelatedMovies { - return RelatedMovies{ +func NewRelatedGroups(list []GroupsScenes) RelatedGroups { + return RelatedGroups{ list: list, } } // Loaded returns true if the relationship has been loaded. -func (r RelatedMovies) Loaded() bool { +func (r RelatedGroups) Loaded() bool { return r.list != nil } -func (r RelatedMovies) mustLoaded() { +func (r RelatedGroups) mustLoaded() { if !r.Loaded() { panic("list has not been loaded") } } -// List returns the related Movies. Panics if the relationship has not been loaded. -func (r RelatedMovies) List() []MoviesScenes { +// List returns the related Groups. Panics if the relationship has not been loaded. +func (r RelatedGroups) List() []GroupsScenes { r.mustLoaded() return r.list } // Add adds the provided ids to the list. Panics if the relationship has not been loaded. -func (r *RelatedMovies) Add(movies ...MoviesScenes) { +func (r *RelatedGroups) Add(groups ...GroupsScenes) { r.mustLoaded() - r.list = append(r.list, movies...) + r.list = append(r.list, groups...) } -// ForID returns the MoviesScenes object for the given movie ID. Returns nil if not found. -func (r *RelatedMovies) ForID(id int) *MoviesScenes { +// ForID returns the GroupsScenes object for the given group ID. Returns nil if not found. +func (r *RelatedGroups) ForID(id int) *GroupsScenes { r.mustLoaded() for _, v := range r.list { - if v.MovieID == id { + if v.GroupID == id { return &v } } @@ -166,7 +166,7 @@ func (r *RelatedMovies) ForID(id int) *MoviesScenes { return nil } -func (r *RelatedMovies) load(fn func() ([]MoviesScenes, error)) error { +func (r *RelatedGroups) load(fn func() ([]GroupsScenes, error)) error { if r.Loaded() { return nil } @@ -177,7 +177,7 @@ func (r *RelatedMovies) load(fn func() ([]MoviesScenes, error)) error { } if ids == nil { - ids = []MoviesScenes{} + ids = []GroupsScenes{} } r.list = ids diff --git a/pkg/models/repository.go b/pkg/models/repository.go index 3eb9a03d378..9bd1e8cad44 100644 --- a/pkg/models/repository.go +++ b/pkg/models/repository.go @@ -20,7 +20,7 @@ type Repository struct { Gallery GalleryReaderWriter GalleryChapter GalleryChapterReaderWriter Image ImageReaderWriter - Movie MovieReaderWriter + Group GroupReaderWriter Performer PerformerReaderWriter Scene SceneReaderWriter SceneMarker SceneMarkerReaderWriter diff --git a/pkg/models/repository_movie.go b/pkg/models/repository_movie.go index dec0e042127..0396049b66e 100644 --- a/pkg/models/repository_movie.go +++ b/pkg/models/repository_movie.go @@ -2,87 +2,87 @@ package models import "context" -// MovieGetter provides methods to get movies by ID. -type MovieGetter interface { +// GroupGetter provides methods to get groups by ID. +type GroupGetter interface { // TODO - rename this to Find and remove existing method - FindMany(ctx context.Context, ids []int) ([]*Movie, error) - Find(ctx context.Context, id int) (*Movie, error) + FindMany(ctx context.Context, ids []int) ([]*Group, error) + Find(ctx context.Context, id int) (*Group, error) } -// MovieFinder provides methods to find movies. -type MovieFinder interface { - MovieGetter - FindByPerformerID(ctx context.Context, performerID int) ([]*Movie, error) - FindByStudioID(ctx context.Context, studioID int) ([]*Movie, error) - FindByName(ctx context.Context, name string, nocase bool) (*Movie, error) - FindByNames(ctx context.Context, names []string, nocase bool) ([]*Movie, error) +// GroupFinder provides methods to find groups. +type GroupFinder interface { + GroupGetter + FindByPerformerID(ctx context.Context, performerID int) ([]*Group, error) + FindByStudioID(ctx context.Context, studioID int) ([]*Group, error) + FindByName(ctx context.Context, name string, nocase bool) (*Group, error) + FindByNames(ctx context.Context, names []string, nocase bool) ([]*Group, error) } -// MovieQueryer provides methods to query movies. -type MovieQueryer interface { - Query(ctx context.Context, movieFilter *MovieFilterType, findFilter *FindFilterType) ([]*Movie, int, error) - QueryCount(ctx context.Context, movieFilter *MovieFilterType, findFilter *FindFilterType) (int, error) +// GroupQueryer provides methods to query groups. +type GroupQueryer interface { + Query(ctx context.Context, groupFilter *GroupFilterType, findFilter *FindFilterType) ([]*Group, int, error) + QueryCount(ctx context.Context, groupFilter *GroupFilterType, findFilter *FindFilterType) (int, error) } -// MovieCounter provides methods to count movies. -type MovieCounter interface { +// GroupCounter provides methods to count groups. +type GroupCounter interface { Count(ctx context.Context) (int, error) CountByPerformerID(ctx context.Context, performerID int) (int, error) CountByStudioID(ctx context.Context, studioID int) (int, error) } -// MovieCreator provides methods to create movies. -type MovieCreator interface { - Create(ctx context.Context, newMovie *Movie) error +// GroupCreator provides methods to create groups. +type GroupCreator interface { + Create(ctx context.Context, newGroup *Group) error } -// MovieUpdater provides methods to update movies. -type MovieUpdater interface { - Update(ctx context.Context, updatedMovie *Movie) error - UpdatePartial(ctx context.Context, id int, updatedMovie MoviePartial) (*Movie, error) - UpdateFrontImage(ctx context.Context, movieID int, frontImage []byte) error - UpdateBackImage(ctx context.Context, movieID int, backImage []byte) error +// GroupUpdater provides methods to update groups. +type GroupUpdater interface { + Update(ctx context.Context, updatedGroup *Group) error + UpdatePartial(ctx context.Context, id int, updatedGroup GroupPartial) (*Group, error) + UpdateFrontImage(ctx context.Context, groupID int, frontImage []byte) error + UpdateBackImage(ctx context.Context, groupID int, backImage []byte) error } -// MovieDestroyer provides methods to destroy movies. -type MovieDestroyer interface { +// GroupDestroyer provides methods to destroy groups. +type GroupDestroyer interface { Destroy(ctx context.Context, id int) error } -type MovieCreatorUpdater interface { - MovieCreator - MovieUpdater +type GroupCreatorUpdater interface { + GroupCreator + GroupUpdater } -type MovieFinderCreator interface { - MovieFinder - MovieCreator +type GroupFinderCreator interface { + GroupFinder + GroupCreator } -// MovieReader provides all methods to read movies. -type MovieReader interface { - MovieFinder - MovieQueryer - MovieCounter +// GroupReader provides all methods to read groups. +type GroupReader interface { + GroupFinder + GroupQueryer + GroupCounter URLLoader TagIDLoader - All(ctx context.Context) ([]*Movie, error) - GetFrontImage(ctx context.Context, movieID int) ([]byte, error) - HasFrontImage(ctx context.Context, movieID int) (bool, error) - GetBackImage(ctx context.Context, movieID int) ([]byte, error) - HasBackImage(ctx context.Context, movieID int) (bool, error) + All(ctx context.Context) ([]*Group, error) + GetFrontImage(ctx context.Context, groupID int) ([]byte, error) + HasFrontImage(ctx context.Context, groupID int) (bool, error) + GetBackImage(ctx context.Context, groupID int) ([]byte, error) + HasBackImage(ctx context.Context, groupID int) (bool, error) } -// MovieWriter provides all methods to modify movies. -type MovieWriter interface { - MovieCreator - MovieUpdater - MovieDestroyer +// GroupWriter provides all methods to modify groups. +type GroupWriter interface { + GroupCreator + GroupUpdater + GroupDestroyer } -// MovieReaderWriter provides all movie methods. -type MovieReaderWriter interface { - MovieReader - MovieWriter +// GroupReaderWriter provides all group methods. +type GroupReaderWriter interface { + GroupReader + GroupWriter } diff --git a/pkg/models/repository_scene.go b/pkg/models/repository_scene.go index bc01ca691d2..bbd69606635 100644 --- a/pkg/models/repository_scene.go +++ b/pkg/models/repository_scene.go @@ -23,7 +23,7 @@ type SceneFinder interface { FindByPrimaryFileID(ctx context.Context, fileID FileID) ([]*Scene, error) FindByPerformerID(ctx context.Context, performerID int) ([]*Scene, error) FindByGalleryID(ctx context.Context, performerID int) ([]*Scene, error) - FindByMovieID(ctx context.Context, movieID int) ([]*Scene, error) + FindByGroupID(ctx context.Context, groupID int) ([]*Scene, error) FindDuplicates(ctx context.Context, distance int, durationDiff float64) ([][]*Scene, error) } @@ -37,7 +37,7 @@ type SceneQueryer interface { type SceneCounter interface { Count(ctx context.Context) (int, error) CountByPerformerID(ctx context.Context, performerID int) (int, error) - CountByMovieID(ctx context.Context, movieID int) (int, error) + CountByGroupID(ctx context.Context, groupID int) (int, error) CountByFileID(ctx context.Context, fileID FileID) (int, error) CountByStudioID(ctx context.Context, studioID int) (int, error) CountByTagID(ctx context.Context, tagID int) (int, error) @@ -99,7 +99,7 @@ type SceneReader interface { GalleryIDLoader PerformerIDLoader TagIDLoader - SceneMovieLoader + SceneGroupLoader StashIDLoader VideoFileLoader diff --git a/pkg/models/repository_tag.go b/pkg/models/repository_tag.go index 00f35abc43f..2b073cae02e 100644 --- a/pkg/models/repository_tag.go +++ b/pkg/models/repository_tag.go @@ -20,7 +20,7 @@ type TagFinder interface { FindByImageID(ctx context.Context, imageID int) ([]*Tag, error) FindByGalleryID(ctx context.Context, galleryID int) ([]*Tag, error) FindByPerformerID(ctx context.Context, performerID int) ([]*Tag, error) - FindByMovieID(ctx context.Context, movieID int) ([]*Tag, error) + FindByGroupID(ctx context.Context, groupID int) ([]*Tag, error) FindBySceneMarkerID(ctx context.Context, sceneMarkerID int) ([]*Tag, error) FindByStudioID(ctx context.Context, studioID int) ([]*Tag, error) FindByName(ctx context.Context, name string, nocase bool) (*Tag, error) diff --git a/pkg/models/scene.go b/pkg/models/scene.go index 5c5df87dbaf..814c4a41d62 100644 --- a/pkg/models/scene.go +++ b/pkg/models/scene.go @@ -106,9 +106,9 @@ type SceneFilterType struct { // Filter by related tags that meet this criteria TagsFilter *TagFilterType `json:"tags_filter"` // Filter by related groups that meet this criteria - GroupsFilter *MovieFilterType `json:"groups_filter"` + GroupsFilter *GroupFilterType `json:"groups_filter"` // Filter by related movies that meet this criteria - MoviesFilter *MovieFilterType `json:"movies_filter"` + MoviesFilter *GroupFilterType `json:"movies_filter"` // Filter by related markers that meet this criteria MarkersFilter *SceneMarkerFilterType `json:"markers_filter"` // Filter by created at diff --git a/pkg/movie/export.go b/pkg/movie/export.go index 55e157168e7..06f4923201b 100644 --- a/pkg/movie/export.go +++ b/pkg/movie/export.go @@ -17,8 +17,8 @@ type ImageGetter interface { } // ToJSON converts a Movie into its JSON equivalent. -func ToJSON(ctx context.Context, reader ImageGetter, studioReader models.StudioGetter, movie *models.Movie) (*jsonschema.Movie, error) { - newMovieJSON := jsonschema.Movie{ +func ToJSON(ctx context.Context, reader ImageGetter, studioReader models.StudioGetter, movie *models.Group) (*jsonschema.Group, error) { + newMovieJSON := jsonschema.Group{ Name: movie.Name, Aliases: movie.Aliases, Director: movie.Director, diff --git a/pkg/movie/export_test.go b/pkg/movie/export_test.go index dd6c9f27409..ee83a360a87 100644 --- a/pkg/movie/export_test.go +++ b/pkg/movie/export_test.go @@ -62,8 +62,8 @@ var ( updateTime = time.Date(2002, 01, 01, 0, 0, 0, 0, time.UTC) ) -func createFullMovie(id int, studioID int) models.Movie { - return models.Movie{ +func createFullMovie(id int, studioID int) models.Group { + return models.Group{ ID: id, Name: movieName, Aliases: movieAliases, @@ -79,8 +79,8 @@ func createFullMovie(id int, studioID int) models.Movie { } } -func createEmptyMovie(id int) models.Movie { - return models.Movie{ +func createEmptyMovie(id int) models.Group { + return models.Group{ ID: id, URLs: models.NewRelatedStrings([]string{}), CreatedAt: createTime, @@ -88,8 +88,8 @@ func createEmptyMovie(id int) models.Movie { } } -func createFullJSONMovie(studio, frontImage, backImage string) *jsonschema.Movie { - return &jsonschema.Movie{ +func createFullJSONMovie(studio, frontImage, backImage string) *jsonschema.Group { + return &jsonschema.Group{ Name: movieName, Aliases: movieAliases, Date: date, @@ -110,8 +110,8 @@ func createFullJSONMovie(studio, frontImage, backImage string) *jsonschema.Movie } } -func createEmptyJSONMovie() *jsonschema.Movie { - return &jsonschema.Movie{ +func createEmptyJSONMovie() *jsonschema.Group { + return &jsonschema.Group{ URLs: []string{}, CreatedAt: json.JSONTime{ Time: createTime, @@ -123,8 +123,8 @@ func createEmptyJSONMovie() *jsonschema.Movie { } type testScenario struct { - movie models.Movie - expected *jsonschema.Movie + movie models.Group + expected *jsonschema.Group err bool } @@ -174,18 +174,18 @@ func TestToJSON(t *testing.T) { imageErr := errors.New("error getting image") - db.Movie.On("GetFrontImage", testCtx, movieID).Return(frontImageBytes, nil).Once() - db.Movie.On("GetFrontImage", testCtx, missingStudioMovieID).Return(frontImageBytes, nil).Once() - db.Movie.On("GetFrontImage", testCtx, emptyID).Return(nil, nil).Once().Maybe() - db.Movie.On("GetFrontImage", testCtx, errFrontImageID).Return(nil, imageErr).Once() - db.Movie.On("GetFrontImage", testCtx, errBackImageID).Return(frontImageBytes, nil).Once() + db.Group.On("GetFrontImage", testCtx, movieID).Return(frontImageBytes, nil).Once() + db.Group.On("GetFrontImage", testCtx, missingStudioMovieID).Return(frontImageBytes, nil).Once() + db.Group.On("GetFrontImage", testCtx, emptyID).Return(nil, nil).Once().Maybe() + db.Group.On("GetFrontImage", testCtx, errFrontImageID).Return(nil, imageErr).Once() + db.Group.On("GetFrontImage", testCtx, errBackImageID).Return(frontImageBytes, nil).Once() - db.Movie.On("GetBackImage", testCtx, movieID).Return(backImageBytes, nil).Once() - db.Movie.On("GetBackImage", testCtx, missingStudioMovieID).Return(backImageBytes, nil).Once() - db.Movie.On("GetBackImage", testCtx, emptyID).Return(nil, nil).Once() - db.Movie.On("GetBackImage", testCtx, errBackImageID).Return(nil, imageErr).Once() - db.Movie.On("GetBackImage", testCtx, errFrontImageID).Return(backImageBytes, nil).Maybe() - db.Movie.On("GetBackImage", testCtx, errStudioMovieID).Return(backImageBytes, nil).Maybe() + db.Group.On("GetBackImage", testCtx, movieID).Return(backImageBytes, nil).Once() + db.Group.On("GetBackImage", testCtx, missingStudioMovieID).Return(backImageBytes, nil).Once() + db.Group.On("GetBackImage", testCtx, emptyID).Return(nil, nil).Once() + db.Group.On("GetBackImage", testCtx, errBackImageID).Return(nil, imageErr).Once() + db.Group.On("GetBackImage", testCtx, errFrontImageID).Return(backImageBytes, nil).Maybe() + db.Group.On("GetBackImage", testCtx, errStudioMovieID).Return(backImageBytes, nil).Maybe() studioErr := errors.New("error getting studio") @@ -195,7 +195,7 @@ func TestToJSON(t *testing.T) { for i, s := range scenarios { movie := s.movie - json, err := ToJSON(testCtx, db.Movie, db.Studio, &movie) + json, err := ToJSON(testCtx, db.Group, db.Studio, &movie) switch { case !s.err && err != nil: diff --git a/pkg/movie/import.go b/pkg/movie/import.go index 27c25316de0..fea410d95eb 100644 --- a/pkg/movie/import.go +++ b/pkg/movie/import.go @@ -12,24 +12,24 @@ import ( ) type ImporterReaderWriter interface { - models.MovieCreatorUpdater - FindByName(ctx context.Context, name string, nocase bool) (*models.Movie, error) + models.GroupCreatorUpdater + FindByName(ctx context.Context, name string, nocase bool) (*models.Group, error) } type Importer struct { ReaderWriter ImporterReaderWriter StudioWriter models.StudioFinderCreator TagWriter models.TagFinderCreator - Input jsonschema.Movie + Input jsonschema.Group MissingRefBehaviour models.ImportMissingRefEnum - movie models.Movie + group models.Group frontImageData []byte backImageData []byte } func (i *Importer) PreImport(ctx context.Context) error { - i.movie = i.movieJSONToMovie(i.Input) + i.group = i.groupJSONToGroup(i.Input) if err := i.populateStudio(ctx); err != nil { return err @@ -65,7 +65,7 @@ func (i *Importer) populateTags(ctx context.Context) error { } for _, p := range tags { - i.movie.TagIDs.Add(p.ID) + i.group.TagIDs.Add(p.ID) } } @@ -124,38 +124,38 @@ func createTags(ctx context.Context, tagWriter models.TagFinderCreator, names [] return ret, nil } -func (i *Importer) movieJSONToMovie(movieJSON jsonschema.Movie) models.Movie { - newMovie := models.Movie{ - Name: movieJSON.Name, - Aliases: movieJSON.Aliases, - Director: movieJSON.Director, - Synopsis: movieJSON.Synopsis, - CreatedAt: movieJSON.CreatedAt.GetTime(), - UpdatedAt: movieJSON.UpdatedAt.GetTime(), +func (i *Importer) groupJSONToGroup(groupJSON jsonschema.Group) models.Group { + newGroup := models.Group{ + Name: groupJSON.Name, + Aliases: groupJSON.Aliases, + Director: groupJSON.Director, + Synopsis: groupJSON.Synopsis, + CreatedAt: groupJSON.CreatedAt.GetTime(), + UpdatedAt: groupJSON.UpdatedAt.GetTime(), TagIDs: models.NewRelatedIDs([]int{}), } - if len(movieJSON.URLs) > 0 { - newMovie.URLs = models.NewRelatedStrings(movieJSON.URLs) - } else if movieJSON.URL != "" { - newMovie.URLs = models.NewRelatedStrings([]string{movieJSON.URL}) + if len(groupJSON.URLs) > 0 { + newGroup.URLs = models.NewRelatedStrings(groupJSON.URLs) + } else if groupJSON.URL != "" { + newGroup.URLs = models.NewRelatedStrings([]string{groupJSON.URL}) } - if movieJSON.Date != "" { - d, err := models.ParseDate(movieJSON.Date) + if groupJSON.Date != "" { + d, err := models.ParseDate(groupJSON.Date) if err == nil { - newMovie.Date = &d + newGroup.Date = &d } } - if movieJSON.Rating != 0 { - newMovie.Rating = &movieJSON.Rating + if groupJSON.Rating != 0 { + newGroup.Rating = &groupJSON.Rating } - if movieJSON.Duration != 0 { - newMovie.Duration = &movieJSON.Duration + if groupJSON.Duration != 0 { + newGroup.Duration = &groupJSON.Duration } - return newMovie + return newGroup } func (i *Importer) populateStudio(ctx context.Context) error { @@ -167,7 +167,7 @@ func (i *Importer) populateStudio(ctx context.Context) error { if studio == nil { if i.MissingRefBehaviour == models.ImportMissingRefEnumFail { - return fmt.Errorf("movie studio '%s' not found", i.Input.Studio) + return fmt.Errorf("group studio '%s' not found", i.Input.Studio) } if i.MissingRefBehaviour == models.ImportMissingRefEnumIgnore { @@ -179,10 +179,10 @@ func (i *Importer) populateStudio(ctx context.Context) error { if err != nil { return err } - i.movie.StudioID = &studioID + i.group.StudioID = &studioID } } else { - i.movie.StudioID = &studio.ID + i.group.StudioID = &studio.ID } } @@ -204,13 +204,13 @@ func (i *Importer) createStudio(ctx context.Context, name string) (int, error) { func (i *Importer) PostImport(ctx context.Context, id int) error { if len(i.frontImageData) > 0 { if err := i.ReaderWriter.UpdateFrontImage(ctx, id, i.frontImageData); err != nil { - return fmt.Errorf("error setting movie front image: %v", err) + return fmt.Errorf("error setting group front image: %v", err) } } if len(i.backImageData) > 0 { if err := i.ReaderWriter.UpdateBackImage(ctx, id, i.backImageData); err != nil { - return fmt.Errorf("error setting movie back image: %v", err) + return fmt.Errorf("error setting group back image: %v", err) } } @@ -237,21 +237,21 @@ func (i *Importer) FindExistingID(ctx context.Context) (*int, error) { } func (i *Importer) Create(ctx context.Context) (*int, error) { - err := i.ReaderWriter.Create(ctx, &i.movie) + err := i.ReaderWriter.Create(ctx, &i.group) if err != nil { - return nil, fmt.Errorf("error creating movie: %v", err) + return nil, fmt.Errorf("error creating group: %v", err) } - id := i.movie.ID + id := i.group.ID return &id, nil } func (i *Importer) Update(ctx context.Context, id int) error { - movie := i.movie - movie.ID = id - err := i.ReaderWriter.Update(ctx, &movie) + group := i.group + group.ID = id + err := i.ReaderWriter.Update(ctx, &group) if err != nil { - return fmt.Errorf("error updating existing movie: %v", err) + return fmt.Errorf("error updating existing group: %v", err) } return nil diff --git a/pkg/movie/import_test.go b/pkg/movie/import_test.go index 2cf35319c1a..c1d1e18f65e 100644 --- a/pkg/movie/import_test.go +++ b/pkg/movie/import_test.go @@ -39,7 +39,7 @@ var testCtx = context.Background() func TestImporterName(t *testing.T) { i := Importer{ - Input: jsonschema.Movie{ + Input: jsonschema.Group{ Name: movieName, }, } @@ -49,7 +49,7 @@ func TestImporterName(t *testing.T) { func TestImporterPreImport(t *testing.T) { i := Importer{ - Input: jsonschema.Movie{ + Input: jsonschema.Group{ Name: movieName, FrontImage: invalidImage, }, @@ -79,9 +79,9 @@ func TestImporterPreImportWithStudio(t *testing.T) { db := mocks.NewDatabase() i := Importer{ - ReaderWriter: db.Movie, + ReaderWriter: db.Group, StudioWriter: db.Studio, - Input: jsonschema.Movie{ + Input: jsonschema.Group{ Name: movieName, FrontImage: frontImage, Studio: existingStudioName, @@ -97,7 +97,7 @@ func TestImporterPreImportWithStudio(t *testing.T) { err := i.PreImport(testCtx) assert.Nil(t, err) - assert.Equal(t, existingStudioID, *i.movie.StudioID) + assert.Equal(t, existingStudioID, *i.group.StudioID) i.Input.Studio = existingStudioErr err = i.PreImport(testCtx) @@ -110,9 +110,9 @@ func TestImporterPreImportWithMissingStudio(t *testing.T) { db := mocks.NewDatabase() i := Importer{ - ReaderWriter: db.Movie, + ReaderWriter: db.Group, StudioWriter: db.Studio, - Input: jsonschema.Movie{ + Input: jsonschema.Group{ Name: movieName, FrontImage: frontImage, Studio: missingStudioName, @@ -136,7 +136,7 @@ func TestImporterPreImportWithMissingStudio(t *testing.T) { i.MissingRefBehaviour = models.ImportMissingRefEnumCreate err = i.PreImport(testCtx) assert.Nil(t, err) - assert.Equal(t, existingStudioID, *i.movie.StudioID) + assert.Equal(t, existingStudioID, *i.group.StudioID) db.AssertExpectations(t) } @@ -145,9 +145,9 @@ func TestImporterPreImportWithMissingStudioCreateErr(t *testing.T) { db := mocks.NewDatabase() i := Importer{ - ReaderWriter: db.Movie, + ReaderWriter: db.Group, StudioWriter: db.Studio, - Input: jsonschema.Movie{ + Input: jsonschema.Group{ Name: movieName, FrontImage: frontImage, Studio: missingStudioName, @@ -168,10 +168,10 @@ func TestImporterPreImportWithTag(t *testing.T) { db := mocks.NewDatabase() i := Importer{ - ReaderWriter: db.Movie, + ReaderWriter: db.Group, TagWriter: db.Tag, MissingRefBehaviour: models.ImportMissingRefEnumFail, - Input: jsonschema.Movie{ + Input: jsonschema.Group{ Tags: []string{ existingTagName, }, @@ -188,7 +188,7 @@ func TestImporterPreImportWithTag(t *testing.T) { err := i.PreImport(testCtx) assert.Nil(t, err) - assert.Equal(t, existingTagID, i.movie.TagIDs.List()[0]) + assert.Equal(t, existingTagID, i.group.TagIDs.List()[0]) i.Input.Tags = []string{existingTagErr} err = i.PreImport(testCtx) @@ -201,9 +201,9 @@ func TestImporterPreImportWithMissingTag(t *testing.T) { db := mocks.NewDatabase() i := Importer{ - ReaderWriter: db.Movie, + ReaderWriter: db.Group, TagWriter: db.Tag, - Input: jsonschema.Movie{ + Input: jsonschema.Group{ Tags: []string{ missingTagName, }, @@ -227,7 +227,7 @@ func TestImporterPreImportWithMissingTag(t *testing.T) { i.MissingRefBehaviour = models.ImportMissingRefEnumCreate err = i.PreImport(testCtx) assert.Nil(t, err) - assert.Equal(t, existingTagID, i.movie.TagIDs.List()[0]) + assert.Equal(t, existingTagID, i.group.TagIDs.List()[0]) db.AssertExpectations(t) } @@ -236,9 +236,9 @@ func TestImporterPreImportWithMissingTagCreateErr(t *testing.T) { db := mocks.NewDatabase() i := Importer{ - ReaderWriter: db.Movie, + ReaderWriter: db.Group, TagWriter: db.Tag, - Input: jsonschema.Movie{ + Input: jsonschema.Group{ Tags: []string{ missingTagName, }, @@ -259,7 +259,7 @@ func TestImporterPostImport(t *testing.T) { db := mocks.NewDatabase() i := Importer{ - ReaderWriter: db.Movie, + ReaderWriter: db.Group, StudioWriter: db.Studio, frontImageData: frontImageBytes, backImageData: backImageBytes, @@ -267,9 +267,9 @@ func TestImporterPostImport(t *testing.T) { updateMovieImageErr := errors.New("UpdateImages error") - db.Movie.On("UpdateFrontImage", testCtx, movieID, frontImageBytes).Return(nil).Once() - db.Movie.On("UpdateBackImage", testCtx, movieID, backImageBytes).Return(nil).Once() - db.Movie.On("UpdateFrontImage", testCtx, errImageID, frontImageBytes).Return(updateMovieImageErr).Once() + db.Group.On("UpdateFrontImage", testCtx, movieID, frontImageBytes).Return(nil).Once() + db.Group.On("UpdateBackImage", testCtx, movieID, backImageBytes).Return(nil).Once() + db.Group.On("UpdateFrontImage", testCtx, errImageID, frontImageBytes).Return(updateMovieImageErr).Once() err := i.PostImport(testCtx, movieID) assert.Nil(t, err) @@ -284,19 +284,19 @@ func TestImporterFindExistingID(t *testing.T) { db := mocks.NewDatabase() i := Importer{ - ReaderWriter: db.Movie, + ReaderWriter: db.Group, StudioWriter: db.Studio, - Input: jsonschema.Movie{ + Input: jsonschema.Group{ Name: movieName, }, } errFindByName := errors.New("FindByName error") - db.Movie.On("FindByName", testCtx, movieName, false).Return(nil, nil).Once() - db.Movie.On("FindByName", testCtx, existingMovieName, false).Return(&models.Movie{ + db.Group.On("FindByName", testCtx, movieName, false).Return(nil, nil).Once() + db.Group.On("FindByName", testCtx, existingMovieName, false).Return(&models.Group{ ID: existingMovieID, }, nil).Once() - db.Movie.On("FindByName", testCtx, movieNameErr, false).Return(nil, errFindByName).Once() + db.Group.On("FindByName", testCtx, movieNameErr, false).Return(nil, errFindByName).Once() id, err := i.FindExistingID(testCtx) assert.Nil(t, id) @@ -318,32 +318,32 @@ func TestImporterFindExistingID(t *testing.T) { func TestCreate(t *testing.T) { db := mocks.NewDatabase() - movie := models.Movie{ + movie := models.Group{ Name: movieName, } - movieErr := models.Movie{ + movieErr := models.Group{ Name: movieNameErr, } i := Importer{ - ReaderWriter: db.Movie, + ReaderWriter: db.Group, StudioWriter: db.Studio, - movie: movie, + group: movie, } errCreate := errors.New("Create error") - db.Movie.On("Create", testCtx, &movie).Run(func(args mock.Arguments) { - m := args.Get(1).(*models.Movie) + db.Group.On("Create", testCtx, &movie).Run(func(args mock.Arguments) { + m := args.Get(1).(*models.Group) m.ID = movieID }).Return(nil).Once() - db.Movie.On("Create", testCtx, &movieErr).Return(errCreate).Once() + db.Group.On("Create", testCtx, &movieErr).Return(errCreate).Once() id, err := i.Create(testCtx) assert.Equal(t, movieID, *id) assert.Nil(t, err) - i.movie = movieErr + i.group = movieErr id, err = i.Create(testCtx) assert.Nil(t, id) assert.NotNil(t, err) @@ -354,34 +354,34 @@ func TestCreate(t *testing.T) { func TestUpdate(t *testing.T) { db := mocks.NewDatabase() - movie := models.Movie{ + movie := models.Group{ Name: movieName, } - movieErr := models.Movie{ + movieErr := models.Group{ Name: movieNameErr, } i := Importer{ - ReaderWriter: db.Movie, + ReaderWriter: db.Group, StudioWriter: db.Studio, - movie: movie, + group: movie, } errUpdate := errors.New("Update error") // id needs to be set for the mock input movie.ID = movieID - db.Movie.On("Update", testCtx, &movie).Return(nil).Once() + db.Group.On("Update", testCtx, &movie).Return(nil).Once() err := i.Update(testCtx, movieID) assert.Nil(t, err) - i.movie = movieErr + i.group = movieErr // need to set id separately movieErr.ID = errImageID - db.Movie.On("Update", testCtx, &movieErr).Return(errUpdate).Once() + db.Group.On("Update", testCtx, &movieErr).Return(errUpdate).Once() err = i.Update(testCtx, errImageID) assert.NotNil(t, err) diff --git a/pkg/movie/query.go b/pkg/movie/query.go index 72764b8ddea..8b2e5baef57 100644 --- a/pkg/movie/query.go +++ b/pkg/movie/query.go @@ -7,8 +7,8 @@ import ( "github.com/stashapp/stash/pkg/models" ) -func CountByStudioID(ctx context.Context, r models.MovieQueryer, id int, depth *int) (int, error) { - filter := &models.MovieFilterType{ +func CountByStudioID(ctx context.Context, r models.GroupQueryer, id int, depth *int) (int, error) { + filter := &models.GroupFilterType{ Studios: &models.HierarchicalMultiCriterionInput{ Value: []string{strconv.Itoa(id)}, Modifier: models.CriterionModifierIncludes, @@ -19,8 +19,8 @@ func CountByStudioID(ctx context.Context, r models.MovieQueryer, id int, depth * return r.QueryCount(ctx, filter, nil) } -func CountByTagID(ctx context.Context, r models.MovieQueryer, id int, depth *int) (int, error) { - filter := &models.MovieFilterType{ +func CountByTagID(ctx context.Context, r models.GroupQueryer, id int, depth *int) (int, error) { + filter := &models.GroupFilterType{ Tags: &models.HierarchicalMultiCriterionInput{ Value: []string{strconv.Itoa(id)}, Modifier: models.CriterionModifierIncludes, diff --git a/pkg/scene/export.go b/pkg/scene/export.go index 6c2895c0844..5733c3be5d9 100644 --- a/pkg/scene/export.go +++ b/pkg/scene/export.go @@ -167,39 +167,39 @@ func GetDependentTagIDs(ctx context.Context, tags TagFinder, markerReader models return ret, nil } -// GetSceneMoviesJSON returns a slice of SceneMovie JSON representation objects -// corresponding to the provided scene's scene movie relationships. -func GetSceneMoviesJSON(ctx context.Context, movieReader models.MovieGetter, scene *models.Scene) ([]jsonschema.SceneMovie, error) { - sceneMovies := scene.Movies.List() - - var results []jsonschema.SceneMovie - for _, sceneMovie := range sceneMovies { - movie, err := movieReader.Find(ctx, sceneMovie.MovieID) +// GetSceneGroupsJSON returns a slice of SceneGroup JSON representation objects +// corresponding to the provided scene's scene group relationships. +func GetSceneGroupsJSON(ctx context.Context, groupReader models.GroupGetter, scene *models.Scene) ([]jsonschema.SceneGroup, error) { + sceneGroups := scene.Groups.List() + + var results []jsonschema.SceneGroup + for _, sceneGroup := range sceneGroups { + group, err := groupReader.Find(ctx, sceneGroup.GroupID) if err != nil { - return nil, fmt.Errorf("error getting movie: %v", err) + return nil, fmt.Errorf("error getting group: %v", err) } - if movie != nil { - sceneMovieJSON := jsonschema.SceneMovie{ - MovieName: movie.Name, + if group != nil { + sceneGroupJSON := jsonschema.SceneGroup{ + GroupName: group.Name, } - if sceneMovie.SceneIndex != nil { - sceneMovieJSON.SceneIndex = *sceneMovie.SceneIndex + if sceneGroup.SceneIndex != nil { + sceneGroupJSON.SceneIndex = *sceneGroup.SceneIndex } - results = append(results, sceneMovieJSON) + results = append(results, sceneGroupJSON) } } return results, nil } -// GetDependentMovieIDs returns a slice of movie IDs that this scene references. -func GetDependentMovieIDs(ctx context.Context, scene *models.Scene) ([]int, error) { +// GetDependentGroupIDs returns a slice of group IDs that this scene references. +func GetDependentGroupIDs(ctx context.Context, scene *models.Scene) ([]int, error) { var ret []int - m := scene.Movies.List() + m := scene.Groups.List() for _, mm := range m { - ret = append(ret, mm.MovieID) + ret = append(ret, mm.GroupID) } return ret, nil diff --git a/pkg/scene/export_test.go b/pkg/scene/export_test.go index 89fe3dfc62a..cde421bd80e 100644 --- a/pkg/scene/export_test.go +++ b/pkg/scene/export_test.go @@ -26,8 +26,8 @@ const ( noTagsID = 11 errTagsID = 12 - noMoviesID = 13 - errFindMovieID = 15 + noGroupsID = 13 + errFindGroupID = 15 noMarkersID = 16 errMarkersID = 17 @@ -49,15 +49,15 @@ var ( studioName = "studioName" // galleryChecksum = "galleryChecksum" - validMovie1 = 1 - validMovie2 = 2 - invalidMovie = 3 + validGroup1 = 1 + validGroup2 = 2 + invalidGroup = 3 - movie1Name = "movie1Name" - movie2Name = "movie2Name" + group1Name = "group1Name" + group2Name = "group2Name" - movie1Scene = 1 - movie2Scene = 2 + group1Scene = 1 + group2Scene = 2 ) var names = []string{ @@ -330,82 +330,82 @@ func TestGetTagNames(t *testing.T) { db.AssertExpectations(t) } -type sceneMoviesTestScenario struct { +type sceneGroupsTestScenario struct { input models.Scene - expected []jsonschema.SceneMovie + expected []jsonschema.SceneGroup err bool } -var validMovies = models.NewRelatedMovies([]models.MoviesScenes{ +var validGroups = models.NewRelatedGroups([]models.GroupsScenes{ { - MovieID: validMovie1, - SceneIndex: &movie1Scene, + GroupID: validGroup1, + SceneIndex: &group1Scene, }, { - MovieID: validMovie2, - SceneIndex: &movie2Scene, + GroupID: validGroup2, + SceneIndex: &group2Scene, }, }) -var invalidMovies = models.NewRelatedMovies([]models.MoviesScenes{ +var invalidGroups = models.NewRelatedGroups([]models.GroupsScenes{ { - MovieID: invalidMovie, - SceneIndex: &movie1Scene, + GroupID: invalidGroup, + SceneIndex: &group1Scene, }, }) -var getSceneMoviesJSONScenarios = []sceneMoviesTestScenario{ +var getSceneGroupsJSONScenarios = []sceneGroupsTestScenario{ { models.Scene{ ID: sceneID, - Movies: validMovies, + Groups: validGroups, }, - []jsonschema.SceneMovie{ + []jsonschema.SceneGroup{ { - MovieName: movie1Name, - SceneIndex: movie1Scene, + GroupName: group1Name, + SceneIndex: group1Scene, }, { - MovieName: movie2Name, - SceneIndex: movie2Scene, + GroupName: group2Name, + SceneIndex: group2Scene, }, }, false, }, { models.Scene{ - ID: noMoviesID, - Movies: models.NewRelatedMovies([]models.MoviesScenes{}), + ID: noGroupsID, + Groups: models.NewRelatedGroups([]models.GroupsScenes{}), }, nil, false, }, { models.Scene{ - ID: errFindMovieID, - Movies: invalidMovies, + ID: errFindGroupID, + Groups: invalidGroups, }, nil, true, }, } -func TestGetSceneMoviesJSON(t *testing.T) { +func TestGetSceneGroupsJSON(t *testing.T) { db := mocks.NewDatabase() - movieErr := errors.New("error getting movie") + groupErr := errors.New("error getting group") - db.Movie.On("Find", testCtx, validMovie1).Return(&models.Movie{ - Name: movie1Name, + db.Group.On("Find", testCtx, validGroup1).Return(&models.Group{ + Name: group1Name, }, nil).Once() - db.Movie.On("Find", testCtx, validMovie2).Return(&models.Movie{ - Name: movie2Name, + db.Group.On("Find", testCtx, validGroup2).Return(&models.Group{ + Name: group2Name, }, nil).Once() - db.Movie.On("Find", testCtx, invalidMovie).Return(nil, movieErr).Once() + db.Group.On("Find", testCtx, invalidGroup).Return(nil, groupErr).Once() - for i, s := range getSceneMoviesJSONScenarios { + for i, s := range getSceneGroupsJSONScenarios { scene := s.input - json, err := GetSceneMoviesJSON(testCtx, db.Movie, &scene) + json, err := GetSceneGroupsJSON(testCtx, db.Group, &scene) switch { case !s.err && err != nil: diff --git a/pkg/scene/filename_parser.go b/pkg/scene/filename_parser.go index 0426696def5..b8dff89d7e5 100644 --- a/pkg/scene/filename_parser.go +++ b/pkg/scene/filename_parser.go @@ -204,7 +204,7 @@ type sceneHolder struct { mm string dd string performers []string - movies []string + groups []string studio string tags []string } @@ -340,7 +340,7 @@ func (h *sceneHolder) setField(field parserField, value interface{}) { case "studio": h.studio = value.(string) case "movie": - h.movies = append(h.movies, value.(string)) + h.groups = append(h.groups, value.(string)) case "tag": h.tags = append(h.tags, value.(string)) case "yyyy": @@ -413,7 +413,7 @@ type FilenameParser struct { repository FilenameParserRepository performerCache map[string]*models.Performer studioCache map[string]*models.Studio - movieCache map[string]*models.Movie + groupCache map[string]*models.Group tagCache map[string]*models.Tag } @@ -427,7 +427,7 @@ func NewFilenameParser(filter *models.FindFilterType, config models.SceneParserI p.performerCache = make(map[string]*models.Performer) p.studioCache = make(map[string]*models.Studio) - p.movieCache = make(map[string]*models.Movie) + p.groupCache = make(map[string]*models.Group) p.tagCache = make(map[string]*models.Tag) p.initWhiteSpaceRegex() @@ -455,7 +455,7 @@ type FilenameParserRepository struct { Scene models.SceneQueryer Performer PerformerNamesFinder Studio models.StudioQueryer - Movie MovieNameFinder + Group GroupNameFinder Tag models.TagQueryer } @@ -464,7 +464,7 @@ func NewFilenameParserRepository(repo models.Repository) FilenameParserRepositor Scene: repo.Scene, Performer: repo.Performer, Studio: repo.Studio, - Movie: repo.Movie, + Group: repo.Group, Tag: repo.Tag, } } @@ -578,23 +578,23 @@ func (p *FilenameParser) queryStudio(ctx context.Context, qb models.StudioQuerye return ret } -type MovieNameFinder interface { - FindByName(ctx context.Context, name string, nocase bool) (*models.Movie, error) +type GroupNameFinder interface { + FindByName(ctx context.Context, name string, nocase bool) (*models.Group, error) } -func (p *FilenameParser) queryMovie(ctx context.Context, qb MovieNameFinder, movieName string) *models.Movie { - // massage the movie name - movieName = delimiterRE.ReplaceAllString(movieName, " ") +func (p *FilenameParser) queryGroup(ctx context.Context, qb GroupNameFinder, groupName string) *models.Group { + // massage the group name + groupName = delimiterRE.ReplaceAllString(groupName, " ") // check cache first - if ret, found := p.movieCache[movieName]; found { + if ret, found := p.groupCache[groupName]; found { return ret } - ret, _ := qb.FindByName(ctx, movieName, true) + ret, _ := qb.FindByName(ctx, groupName, true) // add result to cache - p.movieCache[movieName] = ret + p.groupCache[groupName] = ret return ret } @@ -665,18 +665,18 @@ func (p *FilenameParser) setStudio(ctx context.Context, qb models.StudioQueryer, } } -func (p *FilenameParser) setMovies(ctx context.Context, qb MovieNameFinder, h sceneHolder, result *models.SceneParserResult) { - // query for each movie - moviesSet := make(map[int]bool) - for _, movieName := range h.movies { - if movieName != "" { - movie := p.queryMovie(ctx, qb, movieName) - if movie != nil { - if _, found := moviesSet[movie.ID]; !found { +func (p *FilenameParser) setGroups(ctx context.Context, qb GroupNameFinder, h sceneHolder, result *models.SceneParserResult) { + // query for each group + groupsSet := make(map[int]bool) + for _, groupName := range h.groups { + if groupName != "" { + group := p.queryGroup(ctx, qb, groupName) + if group != nil { + if _, found := groupsSet[group.ID]; !found { result.Movies = append(result.Movies, &models.SceneMovieID{ - MovieID: strconv.Itoa(movie.ID), + MovieID: strconv.Itoa(group.ID), }) - moviesSet[movie.ID] = true + groupsSet[group.ID] = true } } } @@ -714,7 +714,7 @@ func (p *FilenameParser) setParserResult(ctx context.Context, h sceneHolder, res } p.setStudio(ctx, r.Studio, h, result) - if len(h.movies) > 0 { - p.setMovies(ctx, r.Movie, h, result) + if len(h.groups) > 0 { + p.setGroups(ctx, r.Group, h, result) } } diff --git a/pkg/scene/import.go b/pkg/scene/import.go index fc2db4dea94..884d6d7e091 100644 --- a/pkg/scene/import.go +++ b/pkg/scene/import.go @@ -26,7 +26,7 @@ type Importer struct { StudioWriter models.StudioFinderCreator GalleryFinder models.GalleryFinder PerformerWriter models.PerformerFinderCreator - MovieWriter models.MovieFinderCreator + GroupWriter models.GroupFinderCreator TagWriter models.TagFinderCreator Input jsonschema.Scene MissingRefBehaviour models.ImportMissingRefEnum @@ -62,7 +62,7 @@ func (i *Importer) PreImport(ctx context.Context) error { return err } - if err := i.populateMovies(ctx); err != nil { + if err := i.populateGroups(ctx); err != nil { return err } @@ -89,7 +89,7 @@ func (i *Importer) sceneJSONToScene(sceneJSON jsonschema.Scene) models.Scene { PerformerIDs: models.NewRelatedIDs([]int{}), TagIDs: models.NewRelatedIDs([]int{}), GalleryIDs: models.NewRelatedIDs([]int{}), - Movies: models.NewRelatedMovies([]models.MoviesScenes{}), + Groups: models.NewRelatedGroups([]models.GroupsScenes{}), StashIDs: models.NewRelatedStashIDs(sceneJSON.StashIDs), } @@ -335,24 +335,24 @@ func (i *Importer) createPerformers(ctx context.Context, names []string) ([]*mod return ret, nil } -func (i *Importer) populateMovies(ctx context.Context) error { - if len(i.Input.Movies) > 0 { - for _, inputMovie := range i.Input.Movies { - movie, err := i.MovieWriter.FindByName(ctx, inputMovie.MovieName, false) +func (i *Importer) populateGroups(ctx context.Context) error { + if len(i.Input.Groups) > 0 { + for _, inputGroup := range i.Input.Groups { + group, err := i.GroupWriter.FindByName(ctx, inputGroup.GroupName, false) if err != nil { - return fmt.Errorf("error finding scene movie: %v", err) + return fmt.Errorf("error finding scene group: %v", err) } - var movieID int - if movie == nil { + var groupID int + if group == nil { if i.MissingRefBehaviour == models.ImportMissingRefEnumFail { - return fmt.Errorf("scene movie [%s] not found", inputMovie.MovieName) + return fmt.Errorf("scene group [%s] not found", inputGroup.GroupName) } if i.MissingRefBehaviour == models.ImportMissingRefEnumCreate { - movieID, err = i.createMovie(ctx, inputMovie.MovieName) + groupID, err = i.createGroup(ctx, inputGroup.GroupName) if err != nil { - return fmt.Errorf("error creating scene movie: %v", err) + return fmt.Errorf("error creating scene group: %v", err) } } @@ -361,35 +361,35 @@ func (i *Importer) populateMovies(ctx context.Context) error { continue } } else { - movieID = movie.ID + groupID = group.ID } - toAdd := models.MoviesScenes{ - MovieID: movieID, + toAdd := models.GroupsScenes{ + GroupID: groupID, } - if inputMovie.SceneIndex != 0 { - index := inputMovie.SceneIndex + if inputGroup.SceneIndex != 0 { + index := inputGroup.SceneIndex toAdd.SceneIndex = &index } - i.scene.Movies.Add(toAdd) + i.scene.Groups.Add(toAdd) } } return nil } -func (i *Importer) createMovie(ctx context.Context, name string) (int, error) { - newMovie := models.NewMovie() - newMovie.Name = name +func (i *Importer) createGroup(ctx context.Context, name string) (int, error) { + newGroup := models.NewGroup() + newGroup.Name = name - err := i.MovieWriter.Create(ctx, &newMovie) + err := i.GroupWriter.Create(ctx, &newGroup) if err != nil { return 0, err } - return newMovie.ID, nil + return newGroup.ID, nil } func (i *Importer) populateTags(ctx context.Context) error { diff --git a/pkg/scene/import_test.go b/pkg/scene/import_test.go index 26180785627..26844f68773 100644 --- a/pkg/scene/import_test.go +++ b/pkg/scene/import_test.go @@ -17,7 +17,7 @@ const invalidImage = "aW1hZ2VCeXRlcw&&" var ( existingStudioID = 101 existingPerformerID = 103 - existingMovieID = 104 + existingGroupID = 104 existingTagID = 105 existingStudioName = "existingStudioName" @@ -28,9 +28,9 @@ var ( existingPerformerErr = "existingPerformerErr" missingPerformerName = "missingPerformerName" - existingMovieName = "existingMovieName" - existingMovieErr = "existingMovieErr" - missingMovieName = "missingMovieName" + existingGroupName = "existingGroupName" + existingGroupErr = "existingGroupErr" + missingGroupName = "missingGroupName" existingTagName = "existingTagName" existingTagErr = "existingTagErr" @@ -221,58 +221,58 @@ func TestImporterPreImportWithMissingPerformerCreateErr(t *testing.T) { db.AssertExpectations(t) } -func TestImporterPreImportWithMovie(t *testing.T) { +func TestImporterPreImportWithGroup(t *testing.T) { db := mocks.NewDatabase() i := Importer{ - MovieWriter: db.Movie, + GroupWriter: db.Group, MissingRefBehaviour: models.ImportMissingRefEnumFail, Input: jsonschema.Scene{ - Movies: []jsonschema.SceneMovie{ + Groups: []jsonschema.SceneGroup{ { - MovieName: existingMovieName, + GroupName: existingGroupName, SceneIndex: 1, }, }, }, } - db.Movie.On("FindByName", testCtx, existingMovieName, false).Return(&models.Movie{ - ID: existingMovieID, - Name: existingMovieName, + db.Group.On("FindByName", testCtx, existingGroupName, false).Return(&models.Group{ + ID: existingGroupID, + Name: existingGroupName, }, nil).Once() - db.Movie.On("FindByName", testCtx, existingMovieErr, false).Return(nil, errors.New("FindByName error")).Once() + db.Group.On("FindByName", testCtx, existingGroupErr, false).Return(nil, errors.New("FindByName error")).Once() err := i.PreImport(testCtx) assert.Nil(t, err) - assert.Equal(t, existingMovieID, i.scene.Movies.List()[0].MovieID) + assert.Equal(t, existingGroupID, i.scene.Groups.List()[0].GroupID) - i.Input.Movies[0].MovieName = existingMovieErr + i.Input.Groups[0].GroupName = existingGroupErr err = i.PreImport(testCtx) assert.NotNil(t, err) db.AssertExpectations(t) } -func TestImporterPreImportWithMissingMovie(t *testing.T) { +func TestImporterPreImportWithMissingGroup(t *testing.T) { db := mocks.NewDatabase() i := Importer{ - MovieWriter: db.Movie, + GroupWriter: db.Group, Input: jsonschema.Scene{ - Movies: []jsonschema.SceneMovie{ + Groups: []jsonschema.SceneGroup{ { - MovieName: missingMovieName, + GroupName: missingGroupName, }, }, }, MissingRefBehaviour: models.ImportMissingRefEnumFail, } - db.Movie.On("FindByName", testCtx, missingMovieName, false).Return(nil, nil).Times(3) - db.Movie.On("Create", testCtx, mock.AnythingOfType("*models.Movie")).Run(func(args mock.Arguments) { - m := args.Get(1).(*models.Movie) - m.ID = existingMovieID + db.Group.On("FindByName", testCtx, missingGroupName, false).Return(nil, nil).Times(3) + db.Group.On("Create", testCtx, mock.AnythingOfType("*models.Group")).Run(func(args mock.Arguments) { + m := args.Get(1).(*models.Group) + m.ID = existingGroupID }).Return(nil) err := i.PreImport(testCtx) @@ -285,28 +285,28 @@ func TestImporterPreImportWithMissingMovie(t *testing.T) { i.MissingRefBehaviour = models.ImportMissingRefEnumCreate err = i.PreImport(testCtx) assert.Nil(t, err) - assert.Equal(t, existingMovieID, i.scene.Movies.List()[0].MovieID) + assert.Equal(t, existingGroupID, i.scene.Groups.List()[0].GroupID) db.AssertExpectations(t) } -func TestImporterPreImportWithMissingMovieCreateErr(t *testing.T) { +func TestImporterPreImportWithMissingGroupCreateErr(t *testing.T) { db := mocks.NewDatabase() i := Importer{ - MovieWriter: db.Movie, + GroupWriter: db.Group, Input: jsonschema.Scene{ - Movies: []jsonschema.SceneMovie{ + Groups: []jsonschema.SceneGroup{ { - MovieName: missingMovieName, + GroupName: missingGroupName, }, }, }, MissingRefBehaviour: models.ImportMissingRefEnumCreate, } - db.Movie.On("FindByName", testCtx, missingMovieName, false).Return(nil, nil).Once() - db.Movie.On("Create", testCtx, mock.AnythingOfType("*models.Movie")).Return(errors.New("Create error")) + db.Group.On("FindByName", testCtx, missingGroupName, false).Return(nil, nil).Once() + db.Group.On("Create", testCtx, mock.AnythingOfType("*models.Group")).Return(errors.New("Create error")) err := i.PreImport(testCtx) assert.NotNil(t, err) diff --git a/pkg/scraper/cache.go b/pkg/scraper/cache.go index 167b7676bdd..1799892f91a 100644 --- a/pkg/scraper/cache.go +++ b/pkg/scraper/cache.go @@ -84,7 +84,7 @@ type Repository struct { GalleryFinder GalleryFinder TagFinder TagFinder PerformerFinder PerformerFinder - MovieFinder match.MovieNamesFinder + GroupFinder match.GroupNamesFinder StudioFinder StudioFinder } @@ -95,7 +95,7 @@ func NewRepository(repo models.Repository) Repository { GalleryFinder: repo.Gallery, TagFinder: repo.Tag, PerformerFinder: repo.Performer, - MovieFinder: repo.Movie, + GroupFinder: repo.Group, StudioFinder: repo.Studio, } } diff --git a/pkg/scraper/config.go b/pkg/scraper/config.go index 19545a08d35..9c51b4bba95 100644 --- a/pkg/scraper/config.go +++ b/pkg/scraper/config.go @@ -45,8 +45,9 @@ type config struct { // Configuration for querying a gallery by a URL GalleryByURL []*scrapeByURLConfig `yaml:"galleryByURL"` - // Configuration for querying a movie by a URL + // Configuration for querying a movie by a URL - deprecated, use GroupByURL MovieByURL []*scrapeByURLConfig `yaml:"movieByURL"` + GroupByURL []*scrapeByURLConfig `yaml:"groupByURL"` // Scraper debugging options DebugOptions *scraperDebugOptions `yaml:"debug"` @@ -99,7 +100,11 @@ func (c config) validate() error { } } - for _, s := range c.MovieByURL { + if len(c.MovieByURL) > 0 && len(c.GroupByURL) > 0 { + return errors.New("movieByURL disallowed if groupByURL is present") + } + + for _, s := range append(c.MovieByURL, c.GroupByURL...) { if err := s.validate(); err != nil { return err } @@ -289,17 +294,17 @@ func (c config) spec() Scraper { ret.Gallery = &gallery } - movie := ScraperSpec{} - if len(c.MovieByURL) > 0 { - movie.SupportedScrapes = append(movie.SupportedScrapes, ScrapeTypeURL) - for _, v := range c.MovieByURL { - movie.Urls = append(movie.Urls, v.URL...) + group := ScraperSpec{} + if len(c.MovieByURL) > 0 || len(c.GroupByURL) > 0 { + group.SupportedScrapes = append(group.SupportedScrapes, ScrapeTypeURL) + for _, v := range append(c.MovieByURL, c.GroupByURL...) { + group.Urls = append(group.Urls, v.URL...) } } - if len(movie.SupportedScrapes) > 0 { - ret.Movie = &movie - ret.Group = &movie + if len(group.SupportedScrapes) > 0 { + ret.Movie = &group + ret.Group = &group } return ret @@ -314,7 +319,7 @@ func (c config) supports(ty ScrapeContentType) bool { case ScrapeContentTypeGallery: return c.GalleryByFragment != nil || len(c.GalleryByURL) > 0 case ScrapeContentTypeMovie, ScrapeContentTypeGroup: - return len(c.MovieByURL) > 0 + return len(c.MovieByURL) > 0 || len(c.GroupByURL) > 0 } panic("Unhandled ScrapeContentType") diff --git a/pkg/scraper/group.go b/pkg/scraper/group.go index 94cc05b96ff..fff9beb2fe2 100644 --- a/pkg/scraper/group.go +++ b/pkg/scraper/group.go @@ -82,7 +82,7 @@ func loadUrlCandidates(c config, ty ScrapeContentType) []*scrapeByURLConfig { case ScrapeContentTypeScene: return c.SceneByURL case ScrapeContentTypeMovie, ScrapeContentTypeGroup: - return c.MovieByURL + return append(c.MovieByURL, c.GroupByURL...) case ScrapeContentTypeGallery: return c.GalleryByURL } diff --git a/pkg/scraper/image.go b/pkg/scraper/image.go index 78652f11d7e..193ddc517b6 100644 --- a/pkg/scraper/image.go +++ b/pkg/scraper/image.go @@ -88,6 +88,40 @@ func setMovieBackImage(ctx context.Context, client *http.Client, m *models.Scrap return nil } +func setGroupFrontImage(ctx context.Context, client *http.Client, m *models.ScrapedGroup, globalConfig GlobalConfig) error { + // don't try to get the image if it doesn't appear to be a URL + if m.FrontImage == nil || !strings.HasPrefix(*m.FrontImage, "http") { + // nothing to do + return nil + } + + img, err := getImage(ctx, *m.FrontImage, client, globalConfig) + if err != nil { + return err + } + + m.FrontImage = img + + return nil +} + +func setGroupBackImage(ctx context.Context, client *http.Client, m *models.ScrapedGroup, globalConfig GlobalConfig) error { + // don't try to get the image if it doesn't appear to be a URL + if m.BackImage == nil || !strings.HasPrefix(*m.BackImage, "http") { + // nothing to do + return nil + } + + img, err := getImage(ctx, *m.BackImage, client, globalConfig) + if err != nil { + return err + } + + m.BackImage = img + + return nil +} + func getImage(ctx context.Context, url string, client *http.Client, globalConfig GlobalConfig) (*string, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { diff --git a/pkg/scraper/json.go b/pkg/scraper/json.go index 0da20a8275e..929b5152e24 100644 --- a/pkg/scraper/json.go +++ b/pkg/scraper/json.go @@ -103,7 +103,7 @@ func (s *jsonScraper) scrapeByURL(ctx context.Context, url string, ty ScrapeCont } return ret, nil case ScrapeContentTypeMovie, ScrapeContentTypeGroup: - ret, err := scraper.scrapeMovie(ctx, q) + ret, err := scraper.scrapeGroup(ctx, q) if err != nil || ret == nil { return nil, err } diff --git a/pkg/scraper/mapped.go b/pkg/scraper/mapped.go index 7b0d6dc7e79..a6b70565fd7 100644 --- a/pkg/scraper/mapped.go +++ b/pkg/scraper/mapped.go @@ -1079,7 +1079,7 @@ func (s mappedScraper) scrapeGallery(ctx context.Context, q mappedQuery) (*Scrap return &ret, nil } -func (s mappedScraper) scrapeMovie(ctx context.Context, q mappedQuery) (*models.ScrapedMovie, error) { +func (s mappedScraper) scrapeGroup(ctx context.Context, q mappedQuery) (*models.ScrapedMovie, error) { var ret models.ScrapedMovie movieScraperConfig := s.Movie diff --git a/pkg/scraper/postprocessing.go b/pkg/scraper/postprocessing.go index e153c561664..09a4657c3f2 100644 --- a/pkg/scraper/postprocessing.go +++ b/pkg/scraper/postprocessing.go @@ -39,6 +39,12 @@ func (c Cache) postScrape(ctx context.Context, content ScrapedContent) (ScrapedC } case models.ScrapedMovie: return c.postScrapeMovie(ctx, v) + case *models.ScrapedGroup: + if v != nil { + return c.postScrapeGroup(ctx, *v) + } + case models.ScrapedGroup: + return c.postScrapeGroup(ctx, v) } // If nothing matches, pass the content through @@ -128,6 +134,38 @@ func (c Cache) postScrapeMovie(ctx context.Context, m models.ScrapedMovie) (Scra return m, nil } +func (c Cache) postScrapeGroup(ctx context.Context, m models.ScrapedGroup) (ScrapedContent, error) { + r := c.repository + if err := r.WithReadTxn(ctx, func(ctx context.Context) error { + tqb := r.TagFinder + tags, err := postProcessTags(ctx, tqb, m.Tags) + if err != nil { + return err + } + m.Tags = tags + + if m.Studio != nil { + if err := match.ScrapedStudio(ctx, r.StudioFinder, m.Studio, nil); err != nil { + return err + } + } + + return nil + }); err != nil { + return nil, err + } + + // post-process - set the image if applicable + if err := setGroupFrontImage(ctx, c.client, &m, c.globalConfig); err != nil { + logger.Warnf("could not set front image using URL %s: %v", *m.FrontImage, err) + } + if err := setGroupBackImage(ctx, c.client, &m, c.globalConfig); err != nil { + logger.Warnf("could not set back image using URL %s: %v", *m.BackImage, err) + } + + return m, nil +} + func (c Cache) postScrapeScenePerformer(ctx context.Context, p models.ScrapedPerformer) error { tqb := c.repository.TagFinder @@ -154,7 +192,7 @@ func (c Cache) postScrapeScene(ctx context.Context, scene ScrapedScene) (Scraped r := c.repository if err := r.WithReadTxn(ctx, func(ctx context.Context) error { pqb := r.PerformerFinder - mqb := r.MovieFinder + gqb := r.GroupFinder tqb := r.TagFinder sqb := r.StudioFinder @@ -173,10 +211,39 @@ func (c Cache) postScrapeScene(ctx context.Context, scene ScrapedScene) (Scraped } for _, p := range scene.Movies { - err := match.ScrapedMovie(ctx, mqb, p) + matchedID, err := match.ScrapedGroup(ctx, gqb, p.StoredID, p.Name) + if err != nil { + return err + } + + if matchedID != nil { + p.StoredID = matchedID + } + } + + for _, p := range scene.Groups { + matchedID, err := match.ScrapedGroup(ctx, gqb, p.StoredID, p.Name) if err != nil { return err } + + if matchedID != nil { + p.StoredID = matchedID + } + } + + // HACK - if movies was returned but not groups, add the groups from the movies + // if groups was returned but not movies, add the movies from the groups for backward compatibility + if len(scene.Movies) > 0 && len(scene.Groups) == 0 { + for _, m := range scene.Movies { + g := m.ScrapedGroup() + scene.Groups = append(scene.Groups, &g) + } + } else if len(scene.Groups) > 0 && len(scene.Movies) == 0 { + for _, g := range scene.Groups { + m := g.ScrapedMovie() + scene.Movies = append(scene.Movies, &m) + } } tags, err := postProcessTags(ctx, tqb, scene.Tags) diff --git a/pkg/scraper/xpath.go b/pkg/scraper/xpath.go index 9eab9c67f8a..299e9b5db69 100644 --- a/pkg/scraper/xpath.go +++ b/pkg/scraper/xpath.go @@ -84,7 +84,7 @@ func (s *xpathScraper) scrapeByURL(ctx context.Context, url string, ty ScrapeCon } return ret, nil case ScrapeContentTypeMovie, ScrapeContentTypeGroup: - ret, err := scraper.scrapeMovie(ctx, q) + ret, err := scraper.scrapeGroup(ctx, q) if err != nil || ret == nil { return nil, err } diff --git a/pkg/sqlite/anonymise.go b/pkg/sqlite/anonymise.go index 465e6cad551..1dc12df1c96 100644 --- a/pkg/sqlite/anonymise.go +++ b/pkg/sqlite/anonymise.go @@ -57,7 +57,7 @@ func (db *Anonymiser) Anonymise(ctx context.Context) error { func() error { return db.anonymisePerformers(ctx) }, func() error { return db.anonymiseStudios(ctx) }, func() error { return db.anonymiseTags(ctx) }, - func() error { return db.anonymiseMovies(ctx) }, + func() error { return db.anonymiseGroups(ctx) }, func() error { return db.Optimise(ctx) }, }) }(); err != nil { @@ -825,9 +825,9 @@ func (db *Anonymiser) anonymiseTags(ctx context.Context) error { return nil } -func (db *Anonymiser) anonymiseMovies(ctx context.Context) error { - logger.Infof("Anonymising movies") - table := movieTableMgr.table +func (db *Anonymiser) anonymiseGroups(ctx context.Context) error { + logger.Infof("Anonymising groups") + table := groupTableMgr.table lastID := 0 total := 0 const logEvery = 10000 @@ -883,7 +883,7 @@ func (db *Anonymiser) anonymiseMovies(ctx context.Context) error { total++ if total%logEvery == 0 { - logger.Infof("Anonymised %d movies", total) + logger.Infof("Anonymised %d groups", total) } return nil @@ -893,7 +893,7 @@ func (db *Anonymiser) anonymiseMovies(ctx context.Context) error { } } - if err := db.anonymiseURLs(ctx, goqu.T(movieURLsTable), "movie_id"); err != nil { + if err := db.anonymiseURLs(ctx, goqu.T(groupURLsTable), "movie_id"); err != nil { return err } diff --git a/pkg/sqlite/blob_test.go b/pkg/sqlite/blob_test.go index 4c6e0ccc277..10c2b93fe4b 100644 --- a/pkg/sqlite/blob_test.go +++ b/pkg/sqlite/blob_test.go @@ -12,7 +12,7 @@ import ( ) type updateImageFunc func(ctx context.Context, id int, image []byte) error -type getImageFunc func(ctx context.Context, movieID int) ([]byte, error) +type getImageFunc func(ctx context.Context, id int) ([]byte, error) func testUpdateImage(t *testing.T, ctx context.Context, id int, updateFn updateImageFunc, getFn getImageFunc) error { image := []byte("image") diff --git a/pkg/sqlite/database.go b/pkg/sqlite/database.go index 84220b39811..675ee4972f6 100644 --- a/pkg/sqlite/database.go +++ b/pkg/sqlite/database.go @@ -74,7 +74,7 @@ type storeRepository struct { SavedFilter *SavedFilterStore Studio *StudioStore Tag *TagStore - Movie *MovieStore + Group *GroupStore } type Database struct { @@ -110,7 +110,7 @@ func NewDatabase() *Database { Performer: performerStore, Studio: studioStore, Tag: tagStore, - Movie: NewMovieStore(blobStore), + Group: NewGroupStore(blobStore), SavedFilter: NewSavedFilterStore(), } diff --git a/pkg/sqlite/movies.go b/pkg/sqlite/movies.go index e5c08c31fbc..ca9b0b8f244 100644 --- a/pkg/sqlite/movies.go +++ b/pkg/sqlite/movies.go @@ -17,19 +17,19 @@ import ( ) const ( - movieTable = "movies" - movieIDColumn = "movie_id" + groupTable = "movies" + groupIDColumn = "movie_id" - movieFrontImageBlobColumn = "front_image_blob" - movieBackImageBlobColumn = "back_image_blob" + groupFrontImageBlobColumn = "front_image_blob" + groupBackImageBlobColumn = "back_image_blob" - moviesTagsTable = "movies_tags" + groupsTagsTable = "movies_tags" - movieURLsTable = "movie_urls" - movieURLColumn = "url" + groupURLsTable = "movie_urls" + groupURLColumn = "url" ) -type movieRow struct { +type groupRow struct { ID int `db:"id" goqu:"skipinsert"` Name zero.String `db:"name"` Aliases zero.String `db:"aliases"` @@ -48,7 +48,7 @@ type movieRow struct { BackImageBlob zero.String `db:"back_image_blob"` } -func (r *movieRow) fromMovie(o models.Movie) { +func (r *groupRow) fromGroup(o models.Group) { r.ID = o.ID r.Name = zero.StringFrom(o.Name) r.Aliases = zero.StringFrom(o.Aliases) @@ -62,8 +62,8 @@ func (r *movieRow) fromMovie(o models.Movie) { r.UpdatedAt = Timestamp{Timestamp: o.UpdatedAt} } -func (r *movieRow) resolve() *models.Movie { - ret := &models.Movie{ +func (r *groupRow) resolve() *models.Group { + ret := &models.Group{ ID: r.ID, Name: r.Name.String, Aliases: r.Aliases.String, @@ -80,11 +80,11 @@ func (r *movieRow) resolve() *models.Movie { return ret } -type movieRowRecord struct { +type groupRowRecord struct { updateRecord } -func (r *movieRowRecord) fromPartial(o models.MoviePartial) { +func (r *groupRowRecord) fromPartial(o models.GroupPartial) { r.setNullString("name", o.Name) r.setNullString("aliases", o.Aliases) r.setNullInt("duration", o.Duration) @@ -97,26 +97,26 @@ func (r *movieRowRecord) fromPartial(o models.MoviePartial) { r.setTimestamp("updated_at", o.UpdatedAt) } -type movieRepositoryType struct { +type groupRepositoryType struct { repository scenes repository tags joinRepository } var ( - movieRepository = movieRepositoryType{ + groupRepository = groupRepositoryType{ repository: repository{ - tableName: movieTable, + tableName: groupTable, idColumn: idColumn, }, scenes: repository{ - tableName: moviesScenesTable, - idColumn: movieIDColumn, + tableName: groupsScenesTable, + idColumn: groupIDColumn, }, tags: joinRepository{ repository: repository{ - tableName: moviesTagsTable, - idColumn: movieIDColumn, + tableName: groupsTagsTable, + idColumn: groupIDColumn, }, fkColumn: tagIDColumn, foreignTable: tagTable, @@ -125,40 +125,40 @@ var ( } ) -type MovieStore struct { +type GroupStore struct { blobJoinQueryBuilder tagRelationshipStore tableMgr *table } -func NewMovieStore(blobStore *BlobStore) *MovieStore { - return &MovieStore{ +func NewGroupStore(blobStore *BlobStore) *GroupStore { + return &GroupStore{ blobJoinQueryBuilder: blobJoinQueryBuilder{ blobStore: blobStore, - joinTable: movieTable, + joinTable: groupTable, }, tagRelationshipStore: tagRelationshipStore{ idRelationshipStore: idRelationshipStore{ - joinTable: moviesTagsTableMgr, + joinTable: groupsTagsTableMgr, }, }, - tableMgr: movieTableMgr, + tableMgr: groupTableMgr, } } -func (qb *MovieStore) table() exp.IdentifierExpression { +func (qb *GroupStore) table() exp.IdentifierExpression { return qb.tableMgr.table } -func (qb *MovieStore) selectDataset() *goqu.SelectDataset { +func (qb *GroupStore) selectDataset() *goqu.SelectDataset { return dialect.From(qb.table()).Select(qb.table().All()) } -func (qb *MovieStore) Create(ctx context.Context, newObject *models.Movie) error { - var r movieRow - r.fromMovie(*newObject) +func (qb *GroupStore) Create(ctx context.Context, newObject *models.Group) error { + var r groupRow + r.fromGroup(*newObject) id, err := qb.tableMgr.insertID(ctx, r) if err != nil { @@ -167,7 +167,7 @@ func (qb *MovieStore) Create(ctx context.Context, newObject *models.Movie) error if newObject.URLs.Loaded() { const startPos = 0 - if err := moviesURLsTableMgr.insertJoins(ctx, id, startPos, newObject.URLs.List()); err != nil { + if err := groupsURLsTableMgr.insertJoins(ctx, id, startPos, newObject.URLs.List()); err != nil { return err } } @@ -186,8 +186,8 @@ func (qb *MovieStore) Create(ctx context.Context, newObject *models.Movie) error return nil } -func (qb *MovieStore) UpdatePartial(ctx context.Context, id int, partial models.MoviePartial) (*models.Movie, error) { - r := movieRowRecord{ +func (qb *GroupStore) UpdatePartial(ctx context.Context, id int, partial models.GroupPartial) (*models.Group, error) { + r := groupRowRecord{ updateRecord{ Record: make(exp.Record), }, @@ -202,7 +202,7 @@ func (qb *MovieStore) UpdatePartial(ctx context.Context, id int, partial models. } if partial.URLs != nil { - if err := moviesURLsTableMgr.modifyJoins(ctx, id, partial.URLs.Values, partial.URLs.Mode); err != nil { + if err := groupsURLsTableMgr.modifyJoins(ctx, id, partial.URLs.Values, partial.URLs.Mode); err != nil { return nil, err } } @@ -214,16 +214,16 @@ func (qb *MovieStore) UpdatePartial(ctx context.Context, id int, partial models. return qb.find(ctx, id) } -func (qb *MovieStore) Update(ctx context.Context, updatedObject *models.Movie) error { - var r movieRow - r.fromMovie(*updatedObject) +func (qb *GroupStore) Update(ctx context.Context, updatedObject *models.Group) error { + var r groupRow + r.fromGroup(*updatedObject) if err := qb.tableMgr.updateByID(ctx, updatedObject.ID, r); err != nil { return err } if updatedObject.URLs.Loaded() { - if err := moviesURLsTableMgr.replaceJoins(ctx, updatedObject.ID, updatedObject.URLs.List()); err != nil { + if err := groupsURLsTableMgr.replaceJoins(ctx, updatedObject.ID, updatedObject.URLs.List()); err != nil { return err } } @@ -235,17 +235,17 @@ func (qb *MovieStore) Update(ctx context.Context, updatedObject *models.Movie) e return nil } -func (qb *MovieStore) Destroy(ctx context.Context, id int) error { +func (qb *GroupStore) Destroy(ctx context.Context, id int) error { // must handle image checksums manually if err := qb.destroyImages(ctx, id); err != nil { return err } - return movieRepository.destroyExisting(ctx, []int{id}) + return groupRepository.destroyExisting(ctx, []int{id}) } // returns nil, nil if not found -func (qb *MovieStore) Find(ctx context.Context, id int) (*models.Movie, error) { +func (qb *GroupStore) Find(ctx context.Context, id int) (*models.Group, error) { ret, err := qb.find(ctx, id) if errors.Is(err, sql.ErrNoRows) { return nil, nil @@ -253,8 +253,8 @@ func (qb *MovieStore) Find(ctx context.Context, id int) (*models.Movie, error) { return ret, err } -func (qb *MovieStore) FindMany(ctx context.Context, ids []int) ([]*models.Movie, error) { - ret := make([]*models.Movie, len(ids)) +func (qb *GroupStore) FindMany(ctx context.Context, ids []int) ([]*models.Group, error) { + ret := make([]*models.Group, len(ids)) table := qb.table() if err := batchExec(ids, defaultBatchSize, func(batch []int) error { @@ -276,7 +276,7 @@ func (qb *MovieStore) FindMany(ctx context.Context, ids []int) ([]*models.Movie, for i := range ret { if ret[i] == nil { - return nil, fmt.Errorf("movie with id %d not found", ids[i]) + return nil, fmt.Errorf("group with id %d not found", ids[i]) } } @@ -284,7 +284,7 @@ func (qb *MovieStore) FindMany(ctx context.Context, ids []int) ([]*models.Movie, } // returns nil, sql.ErrNoRows if not found -func (qb *MovieStore) find(ctx context.Context, id int) (*models.Movie, error) { +func (qb *GroupStore) find(ctx context.Context, id int) (*models.Group, error) { q := qb.selectDataset().Where(qb.tableMgr.byID(id)) ret, err := qb.get(ctx, q) @@ -296,7 +296,7 @@ func (qb *MovieStore) find(ctx context.Context, id int) (*models.Movie, error) { } // returns nil, sql.ErrNoRows if not found -func (qb *MovieStore) get(ctx context.Context, q *goqu.SelectDataset) (*models.Movie, error) { +func (qb *GroupStore) get(ctx context.Context, q *goqu.SelectDataset) (*models.Group, error) { ret, err := qb.getMany(ctx, q) if err != nil { return nil, err @@ -309,11 +309,11 @@ func (qb *MovieStore) get(ctx context.Context, q *goqu.SelectDataset) (*models.M return ret[0], nil } -func (qb *MovieStore) getMany(ctx context.Context, q *goqu.SelectDataset) ([]*models.Movie, error) { +func (qb *GroupStore) getMany(ctx context.Context, q *goqu.SelectDataset) ([]*models.Group, error) { const single = false - var ret []*models.Movie + var ret []*models.Group if err := queryFunc(ctx, q, single, func(r *sqlx.Rows) error { - var f movieRow + var f groupRow if err := r.StructScan(&f); err != nil { return err } @@ -329,7 +329,7 @@ func (qb *MovieStore) getMany(ctx context.Context, q *goqu.SelectDataset) ([]*mo return ret, nil } -func (qb *MovieStore) FindByName(ctx context.Context, name string, nocase bool) (*models.Movie, error) { +func (qb *GroupStore) FindByName(ctx context.Context, name string, nocase bool) (*models.Group, error) { // query := "SELECT * FROM movies WHERE name = ?" // if nocase { // query += " COLLATE NOCASE" @@ -349,7 +349,7 @@ func (qb *MovieStore) FindByName(ctx context.Context, name string, nocase bool) return ret, nil } -func (qb *MovieStore) FindByNames(ctx context.Context, names []string, nocase bool) ([]*models.Movie, error) { +func (qb *GroupStore) FindByNames(ctx context.Context, names []string, nocase bool) ([]*models.Group, error) { // query := "SELECT * FROM movies WHERE name" // if nocase { // query += " COLLATE NOCASE" @@ -374,12 +374,12 @@ func (qb *MovieStore) FindByNames(ctx context.Context, names []string, nocase bo return ret, nil } -func (qb *MovieStore) Count(ctx context.Context) (int, error) { +func (qb *GroupStore) Count(ctx context.Context) (int, error) { q := dialect.Select(goqu.COUNT("*")).From(qb.table()) return count(ctx, q) } -func (qb *MovieStore) All(ctx context.Context) ([]*models.Movie, error) { +func (qb *GroupStore) All(ctx context.Context) ([]*models.Group, error) { table := qb.table() return qb.getMany(ctx, qb.selectDataset().Order( @@ -388,24 +388,24 @@ func (qb *MovieStore) All(ctx context.Context) ([]*models.Movie, error) { )) } -func (qb *MovieStore) makeQuery(ctx context.Context, movieFilter *models.MovieFilterType, findFilter *models.FindFilterType) (*queryBuilder, error) { +func (qb *GroupStore) makeQuery(ctx context.Context, groupFilter *models.GroupFilterType, findFilter *models.FindFilterType) (*queryBuilder, error) { if findFilter == nil { findFilter = &models.FindFilterType{} } - if movieFilter == nil { - movieFilter = &models.MovieFilterType{} + if groupFilter == nil { + groupFilter = &models.GroupFilterType{} } - query := movieRepository.newQuery() - distinctIDs(&query, movieTable) + query := groupRepository.newQuery() + distinctIDs(&query, groupTable) if q := findFilter.Q; q != nil && *q != "" { searchColumns := []string{"movies.name", "movies.aliases"} query.parseQueryString(searchColumns, *q) } - filter := filterBuilderFromHandler(ctx, &movieFilterHandler{ - movieFilter: movieFilter, + filter := filterBuilderFromHandler(ctx, &groupFilterHandler{ + groupFilter: groupFilter, }) if err := query.addFilter(filter); err != nil { @@ -413,7 +413,7 @@ func (qb *MovieStore) makeQuery(ctx context.Context, movieFilter *models.MovieFi } var err error - query.sortAndPagination, err = qb.getMovieSort(findFilter) + query.sortAndPagination, err = qb.getGroupSort(findFilter) if err != nil { return nil, err } @@ -423,8 +423,8 @@ func (qb *MovieStore) makeQuery(ctx context.Context, movieFilter *models.MovieFi return &query, nil } -func (qb *MovieStore) Query(ctx context.Context, movieFilter *models.MovieFilterType, findFilter *models.FindFilterType) ([]*models.Movie, int, error) { - query, err := qb.makeQuery(ctx, movieFilter, findFilter) +func (qb *GroupStore) Query(ctx context.Context, groupFilter *models.GroupFilterType, findFilter *models.FindFilterType) ([]*models.Group, int, error) { + query, err := qb.makeQuery(ctx, groupFilter, findFilter) if err != nil { return nil, 0, err } @@ -434,16 +434,16 @@ func (qb *MovieStore) Query(ctx context.Context, movieFilter *models.MovieFilter return nil, 0, err } - movies, err := qb.FindMany(ctx, idsResult) + groups, err := qb.FindMany(ctx, idsResult) if err != nil { return nil, 0, err } - return movies, countResult, nil + return groups, countResult, nil } -func (qb *MovieStore) QueryCount(ctx context.Context, movieFilter *models.MovieFilterType, findFilter *models.FindFilterType) (int, error) { - query, err := qb.makeQuery(ctx, movieFilter, findFilter) +func (qb *GroupStore) QueryCount(ctx context.Context, groupFilter *models.GroupFilterType, findFilter *models.FindFilterType) (int, error) { + query, err := qb.makeQuery(ctx, groupFilter, findFilter) if err != nil { return 0, err } @@ -451,7 +451,7 @@ func (qb *MovieStore) QueryCount(ctx context.Context, movieFilter *models.MovieF return query.executeCount(ctx) } -var movieSortOptions = sortOptions{ +var groupSortOptions = sortOptions{ "created_at", "date", "duration", @@ -464,7 +464,7 @@ var movieSortOptions = sortOptions{ "updated_at", } -func (qb *MovieStore) getMovieSort(findFilter *models.FindFilterType) (string, error) { +func (qb *GroupStore) getGroupSort(findFilter *models.FindFilterType) (string, error) { var sort string var direction string if findFilter == nil { @@ -476,16 +476,16 @@ func (qb *MovieStore) getMovieSort(findFilter *models.FindFilterType) (string, e } // CVE-2024-32231 - ensure sort is in the list of allowed sorts - if err := movieSortOptions.validateSort(sort); err != nil { + if err := groupSortOptions.validateSort(sort); err != nil { return "", err } sortQuery := "" switch sort { case "tag_count": - sortQuery += getCountSort(movieTable, moviesTagsTable, movieIDColumn, direction) + sortQuery += getCountSort(groupTable, groupsTagsTable, groupIDColumn, direction) case "scenes_count": // generic getSort won't work for this - sortQuery += getCountSort(movieTable, moviesScenesTable, movieIDColumn, direction) + sortQuery += getCountSort(groupTable, groupsScenesTable, groupIDColumn, direction) default: sortQuery += getSort(sort, direction, "movies") } @@ -495,11 +495,11 @@ func (qb *MovieStore) getMovieSort(findFilter *models.FindFilterType) (string, e return sortQuery, nil } -func (qb *MovieStore) queryMovies(ctx context.Context, query string, args []interface{}) ([]*models.Movie, error) { +func (qb *GroupStore) queryGroups(ctx context.Context, query string, args []interface{}) ([]*models.Group, error) { const single = false - var ret []*models.Movie - if err := movieRepository.queryFunc(ctx, query, args, single, func(r *sqlx.Rows) error { - var f movieRow + var ret []*models.Group + if err := groupRepository.queryFunc(ctx, query, args, single, func(r *sqlx.Rows) error { + var f groupRow if err := r.StructScan(&f); err != nil { return err } @@ -515,42 +515,42 @@ func (qb *MovieStore) queryMovies(ctx context.Context, query string, args []inte return ret, nil } -func (qb *MovieStore) UpdateFrontImage(ctx context.Context, movieID int, frontImage []byte) error { - return qb.UpdateImage(ctx, movieID, movieFrontImageBlobColumn, frontImage) +func (qb *GroupStore) UpdateFrontImage(ctx context.Context, groupID int, frontImage []byte) error { + return qb.UpdateImage(ctx, groupID, groupFrontImageBlobColumn, frontImage) } -func (qb *MovieStore) UpdateBackImage(ctx context.Context, movieID int, backImage []byte) error { - return qb.UpdateImage(ctx, movieID, movieBackImageBlobColumn, backImage) +func (qb *GroupStore) UpdateBackImage(ctx context.Context, groupID int, backImage []byte) error { + return qb.UpdateImage(ctx, groupID, groupBackImageBlobColumn, backImage) } -func (qb *MovieStore) destroyImages(ctx context.Context, movieID int) error { - if err := qb.DestroyImage(ctx, movieID, movieFrontImageBlobColumn); err != nil { +func (qb *GroupStore) destroyImages(ctx context.Context, groupID int) error { + if err := qb.DestroyImage(ctx, groupID, groupFrontImageBlobColumn); err != nil { return err } - if err := qb.DestroyImage(ctx, movieID, movieBackImageBlobColumn); err != nil { + if err := qb.DestroyImage(ctx, groupID, groupBackImageBlobColumn); err != nil { return err } return nil } -func (qb *MovieStore) GetFrontImage(ctx context.Context, movieID int) ([]byte, error) { - return qb.GetImage(ctx, movieID, movieFrontImageBlobColumn) +func (qb *GroupStore) GetFrontImage(ctx context.Context, groupID int) ([]byte, error) { + return qb.GetImage(ctx, groupID, groupFrontImageBlobColumn) } -func (qb *MovieStore) HasFrontImage(ctx context.Context, movieID int) (bool, error) { - return qb.HasImage(ctx, movieID, movieFrontImageBlobColumn) +func (qb *GroupStore) HasFrontImage(ctx context.Context, groupID int) (bool, error) { + return qb.HasImage(ctx, groupID, groupFrontImageBlobColumn) } -func (qb *MovieStore) GetBackImage(ctx context.Context, movieID int) ([]byte, error) { - return qb.GetImage(ctx, movieID, movieBackImageBlobColumn) +func (qb *GroupStore) GetBackImage(ctx context.Context, groupID int) ([]byte, error) { + return qb.GetImage(ctx, groupID, groupBackImageBlobColumn) } -func (qb *MovieStore) HasBackImage(ctx context.Context, movieID int) (bool, error) { - return qb.HasImage(ctx, movieID, movieBackImageBlobColumn) +func (qb *GroupStore) HasBackImage(ctx context.Context, groupID int) (bool, error) { + return qb.HasImage(ctx, groupID, groupBackImageBlobColumn) } -func (qb *MovieStore) FindByPerformerID(ctx context.Context, performerID int) ([]*models.Movie, error) { +func (qb *GroupStore) FindByPerformerID(ctx context.Context, performerID int) ([]*models.Group, error) { query := `SELECT DISTINCT movies.* FROM movies INNER JOIN movies_scenes ON movies.id = movies_scenes.movie_id @@ -558,37 +558,37 @@ INNER JOIN performers_scenes ON performers_scenes.scene_id = movies_scenes.scene WHERE performers_scenes.performer_id = ? ` args := []interface{}{performerID} - return qb.queryMovies(ctx, query, args) + return qb.queryGroups(ctx, query, args) } -func (qb *MovieStore) CountByPerformerID(ctx context.Context, performerID int) (int, error) { +func (qb *GroupStore) CountByPerformerID(ctx context.Context, performerID int) (int, error) { query := `SELECT COUNT(DISTINCT movies_scenes.movie_id) AS count FROM movies_scenes INNER JOIN performers_scenes ON performers_scenes.scene_id = movies_scenes.scene_id WHERE performers_scenes.performer_id = ? ` args := []interface{}{performerID} - return movieRepository.runCountQuery(ctx, query, args) + return groupRepository.runCountQuery(ctx, query, args) } -func (qb *MovieStore) FindByStudioID(ctx context.Context, studioID int) ([]*models.Movie, error) { +func (qb *GroupStore) FindByStudioID(ctx context.Context, studioID int) ([]*models.Group, error) { query := `SELECT movies.* FROM movies WHERE movies.studio_id = ? ` args := []interface{}{studioID} - return qb.queryMovies(ctx, query, args) + return qb.queryGroups(ctx, query, args) } -func (qb *MovieStore) CountByStudioID(ctx context.Context, studioID int) (int, error) { +func (qb *GroupStore) CountByStudioID(ctx context.Context, studioID int) (int, error) { query := `SELECT COUNT(1) AS count FROM movies WHERE movies.studio_id = ? ` args := []interface{}{studioID} - return movieRepository.runCountQuery(ctx, query, args) + return groupRepository.runCountQuery(ctx, query, args) } -func (qb *MovieStore) GetURLs(ctx context.Context, movieID int) ([]string, error) { - return moviesURLsTableMgr.get(ctx, movieID) +func (qb *GroupStore) GetURLs(ctx context.Context, groupID int) ([]string, error) { + return groupsURLsTableMgr.get(ctx, groupID) } diff --git a/pkg/sqlite/movies_filter.go b/pkg/sqlite/movies_filter.go index 0a2b3d67448..54c4cd03b29 100644 --- a/pkg/sqlite/movies_filter.go +++ b/pkg/sqlite/movies_filter.go @@ -7,22 +7,22 @@ import ( "github.com/stashapp/stash/pkg/models" ) -type movieFilterHandler struct { - movieFilter *models.MovieFilterType +type groupFilterHandler struct { + groupFilter *models.GroupFilterType } -func (qb *movieFilterHandler) validate() error { - movieFilter := qb.movieFilter - if movieFilter == nil { +func (qb *groupFilterHandler) validate() error { + groupFilter := qb.groupFilter + if groupFilter == nil { return nil } - if err := validateFilterCombination(movieFilter.OperatorFilter); err != nil { + if err := validateFilterCombination(groupFilter.OperatorFilter); err != nil { return err } - if subFilter := movieFilter.SubFilter(); subFilter != nil { - sqb := &movieFilterHandler{movieFilter: subFilter} + if subFilter := groupFilter.SubFilter(); subFilter != nil { + sqb := &groupFilterHandler{groupFilter: subFilter} if err := sqb.validate(); err != nil { return err } @@ -31,9 +31,9 @@ func (qb *movieFilterHandler) validate() error { return nil } -func (qb *movieFilterHandler) handle(ctx context.Context, f *filterBuilder) { - movieFilter := qb.movieFilter - if movieFilter == nil { +func (qb *groupFilterHandler) handle(ctx context.Context, f *filterBuilder) { + groupFilter := qb.groupFilter + if groupFilter == nil { return } @@ -42,51 +42,51 @@ func (qb *movieFilterHandler) handle(ctx context.Context, f *filterBuilder) { return } - sf := movieFilter.SubFilter() + sf := groupFilter.SubFilter() if sf != nil { - sub := &movieFilterHandler{sf} - handleSubFilter(ctx, sub, f, movieFilter.OperatorFilter) + sub := &groupFilterHandler{sf} + handleSubFilter(ctx, sub, f, groupFilter.OperatorFilter) } f.handleCriterion(ctx, qb.criterionHandler()) } -func (qb *movieFilterHandler) criterionHandler() criterionHandler { - movieFilter := qb.movieFilter +func (qb *groupFilterHandler) criterionHandler() criterionHandler { + groupFilter := qb.groupFilter return compoundHandler{ - stringCriterionHandler(movieFilter.Name, "movies.name"), - stringCriterionHandler(movieFilter.Director, "movies.director"), - stringCriterionHandler(movieFilter.Synopsis, "movies.synopsis"), - intCriterionHandler(movieFilter.Rating100, "movies.rating", nil), - floatIntCriterionHandler(movieFilter.Duration, "movies.duration", nil), - qb.missingCriterionHandler(movieFilter.IsMissing), - qb.urlsCriterionHandler(movieFilter.URL), - studioCriterionHandler(movieTable, movieFilter.Studios), - qb.performersCriterionHandler(movieFilter.Performers), - qb.tagsCriterionHandler(movieFilter.Tags), - qb.tagCountCriterionHandler(movieFilter.TagCount), - &dateCriterionHandler{movieFilter.Date, "movies.date", nil}, - ×tampCriterionHandler{movieFilter.CreatedAt, "movies.created_at", nil}, - ×tampCriterionHandler{movieFilter.UpdatedAt, "movies.updated_at", nil}, + stringCriterionHandler(groupFilter.Name, "movies.name"), + stringCriterionHandler(groupFilter.Director, "movies.director"), + stringCriterionHandler(groupFilter.Synopsis, "movies.synopsis"), + intCriterionHandler(groupFilter.Rating100, "movies.rating", nil), + floatIntCriterionHandler(groupFilter.Duration, "movies.duration", nil), + qb.missingCriterionHandler(groupFilter.IsMissing), + qb.urlsCriterionHandler(groupFilter.URL), + studioCriterionHandler(groupTable, groupFilter.Studios), + qb.performersCriterionHandler(groupFilter.Performers), + qb.tagsCriterionHandler(groupFilter.Tags), + qb.tagCountCriterionHandler(groupFilter.TagCount), + &dateCriterionHandler{groupFilter.Date, "movies.date", nil}, + ×tampCriterionHandler{groupFilter.CreatedAt, "movies.created_at", nil}, + ×tampCriterionHandler{groupFilter.UpdatedAt, "movies.updated_at", nil}, &relatedFilterHandler{ relatedIDCol: "movies_scenes.scene_id", relatedRepo: sceneRepository.repository, - relatedHandler: &sceneFilterHandler{movieFilter.ScenesFilter}, + relatedHandler: &sceneFilterHandler{groupFilter.ScenesFilter}, joinFn: func(f *filterBuilder) { - movieRepository.scenes.innerJoin(f, "", "movies.id") + groupRepository.scenes.innerJoin(f, "", "movies.id") }, }, &relatedFilterHandler{ relatedIDCol: "movies.studio_id", relatedRepo: studioRepository.repository, - relatedHandler: &studioFilterHandler{movieFilter.StudiosFilter}, + relatedHandler: &studioFilterHandler{groupFilter.StudiosFilter}, }, } } -func (qb *movieFilterHandler) missingCriterionHandler(isMissing *string) criterionHandlerFunc { +func (qb *groupFilterHandler) missingCriterionHandler(isMissing *string) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if isMissing != nil && *isMissing != "" { switch *isMissing { @@ -104,21 +104,21 @@ func (qb *movieFilterHandler) missingCriterionHandler(isMissing *string) criteri } } -func (qb *movieFilterHandler) urlsCriterionHandler(url *models.StringCriterionInput) criterionHandlerFunc { +func (qb *groupFilterHandler) urlsCriterionHandler(url *models.StringCriterionInput) criterionHandlerFunc { h := stringListCriterionHandlerBuilder{ - primaryTable: movieTable, - primaryFK: movieIDColumn, - joinTable: movieURLsTable, - stringColumn: movieURLColumn, + primaryTable: groupTable, + primaryFK: groupIDColumn, + joinTable: groupURLsTable, + stringColumn: groupURLColumn, addJoinTable: func(f *filterBuilder) { - moviesURLsTableMgr.join(f, "", "movies.id") + groupsURLsTableMgr.join(f, "", "movies.id") }, } return h.handler(url) } -func (qb *movieFilterHandler) performersCriterionHandler(performers *models.MultiCriterionInput) criterionHandlerFunc { +func (qb *groupFilterHandler) performersCriterionHandler(performers *models.MultiCriterionInput) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if performers != nil { if performers.Modifier == models.CriterionModifierIsNull || performers.Modifier == models.CriterionModifierNotNull { @@ -165,26 +165,26 @@ func (qb *movieFilterHandler) performersCriterionHandler(performers *models.Mult } } -func (qb *movieFilterHandler) tagsCriterionHandler(tags *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { +func (qb *groupFilterHandler) tagsCriterionHandler(tags *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { h := joinedHierarchicalMultiCriterionHandlerBuilder{ - primaryTable: movieTable, + primaryTable: groupTable, foreignTable: tagTable, foreignFK: "tag_id", relationsTable: "tags_relations", joinAs: "movie_tag", - joinTable: moviesTagsTable, - primaryFK: movieIDColumn, + joinTable: groupsTagsTable, + primaryFK: groupIDColumn, } return h.handler(tags) } -func (qb *movieFilterHandler) tagCountCriterionHandler(count *models.IntCriterionInput) criterionHandlerFunc { +func (qb *groupFilterHandler) tagCountCriterionHandler(count *models.IntCriterionInput) criterionHandlerFunc { h := countCriterionHandlerBuilder{ - primaryTable: movieTable, - joinTable: moviesTagsTable, - primaryFK: movieIDColumn, + primaryTable: groupTable, + joinTable: groupsTagsTable, + primaryFK: groupIDColumn, } return h.handler(count) diff --git a/pkg/sqlite/movies_test.go b/pkg/sqlite/movies_test.go index 3cfe05fe803..45171337970 100644 --- a/pkg/sqlite/movies_test.go +++ b/pkg/sqlite/movies_test.go @@ -16,14 +16,14 @@ import ( "github.com/stashapp/stash/pkg/models" ) -func loadMovieRelationships(ctx context.Context, expected models.Movie, actual *models.Movie) error { +func loadGroupRelationships(ctx context.Context, expected models.Group, actual *models.Group) error { if expected.URLs.Loaded() { - if err := actual.LoadURLs(ctx, db.Movie); err != nil { + if err := actual.LoadURLs(ctx, db.Group); err != nil { return err } } if expected.TagIDs.Loaded() { - if err := actual.LoadTagIDs(ctx, db.Movie); err != nil { + if err := actual.LoadTagIDs(ctx, db.Group); err != nil { return err } } @@ -31,7 +31,7 @@ func loadMovieRelationships(ctx context.Context, expected models.Movie, actual * return nil } -func Test_MovieStore_Create(t *testing.T) { +func Test_GroupStore_Create(t *testing.T) { var ( name = "name" url = "url" @@ -47,21 +47,21 @@ func Test_MovieStore_Create(t *testing.T) { tests := []struct { name string - newObject models.Movie + newObject models.Group wantErr bool }{ { "full", - models.Movie{ + models.Group{ Name: name, Duration: &duration, Date: &date, Rating: &rating, - StudioID: &studioIDs[studioIdxWithMovie], + StudioID: &studioIDs[studioIdxWithGroup], Director: director, Synopsis: synopsis, URLs: models.NewRelatedStrings([]string{url}), - TagIDs: models.NewRelatedIDs([]int{tagIDs[tagIdx1WithDupName], tagIDs[tagIdx1WithMovie]}), + TagIDs: models.NewRelatedIDs([]int{tagIDs[tagIdx1WithDupName], tagIDs[tagIdx1WithGroup]}), Aliases: aliases, CreatedAt: createdAt, UpdatedAt: updatedAt, @@ -70,7 +70,7 @@ func Test_MovieStore_Create(t *testing.T) { }, { "invalid tag id", - models.Movie{ + models.Group{ Name: name, TagIDs: models.NewRelatedIDs([]int{invalidID}), }, @@ -78,7 +78,7 @@ func Test_MovieStore_Create(t *testing.T) { }, } - qb := db.Movie + qb := db.Group for _, tt := range tests { runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { @@ -86,7 +86,7 @@ func Test_MovieStore_Create(t *testing.T) { p := tt.newObject if err := qb.Create(ctx, &p); (err != nil) != tt.wantErr { - t.Errorf("MovieStore.Create() error = %v, wantErr = %v", err, tt.wantErr) + t.Errorf("GroupStore.Create() error = %v, wantErr = %v", err, tt.wantErr) } if tt.wantErr { @@ -100,17 +100,17 @@ func Test_MovieStore_Create(t *testing.T) { copy.ID = p.ID // load relationships - if err := loadMovieRelationships(ctx, copy, &p); err != nil { - t.Errorf("loadMovieRelationships() error = %v", err) + if err := loadGroupRelationships(ctx, copy, &p); err != nil { + t.Errorf("loadGroupRelationships() error = %v", err) return } assert.Equal(copy, p) - // ensure can find the movie + // ensure can find the group found, err := qb.Find(ctx, p.ID) if err != nil { - t.Errorf("MovieStore.Find() error = %v", err) + t.Errorf("GroupStore.Find() error = %v", err) } if !assert.NotNil(found) { @@ -118,8 +118,8 @@ func Test_MovieStore_Create(t *testing.T) { } // load relationships - if err := loadMovieRelationships(ctx, copy, found); err != nil { - t.Errorf("loadMovieRelationships() error = %v", err) + if err := loadGroupRelationships(ctx, copy, found); err != nil { + t.Errorf("loadGroupRelationships() error = %v", err) return } assert.Equal(copy, *found) @@ -129,7 +129,7 @@ func Test_MovieStore_Create(t *testing.T) { } } -func Test_movieQueryBuilder_Update(t *testing.T) { +func Test_groupQueryBuilder_Update(t *testing.T) { var ( name = "name" url = "url" @@ -145,22 +145,22 @@ func Test_movieQueryBuilder_Update(t *testing.T) { tests := []struct { name string - updatedObject *models.Movie + updatedObject *models.Group wantErr bool }{ { "full", - &models.Movie{ - ID: movieIDs[movieIdxWithTag], + &models.Group{ + ID: groupIDs[groupIdxWithTag], Name: name, Duration: &duration, Date: &date, Rating: &rating, - StudioID: &studioIDs[studioIdxWithMovie], + StudioID: &studioIDs[studioIdxWithGroup], Director: director, Synopsis: synopsis, URLs: models.NewRelatedStrings([]string{url}), - TagIDs: models.NewRelatedIDs([]int{tagIDs[tagIdx1WithDupName], tagIDs[tagIdx1WithMovie]}), + TagIDs: models.NewRelatedIDs([]int{tagIDs[tagIdx1WithDupName], tagIDs[tagIdx1WithGroup]}), Aliases: aliases, CreatedAt: createdAt, UpdatedAt: updatedAt, @@ -169,8 +169,8 @@ func Test_movieQueryBuilder_Update(t *testing.T) { }, { "clear tag ids", - &models.Movie{ - ID: movieIDs[movieIdxWithTag], + &models.Group{ + ID: groupIDs[groupIdxWithTag], Name: name, TagIDs: models.NewRelatedIDs([]int{}), }, @@ -178,8 +178,8 @@ func Test_movieQueryBuilder_Update(t *testing.T) { }, { "invalid studio id", - &models.Movie{ - ID: movieIDs[movieIdxWithScene], + &models.Group{ + ID: groupIDs[groupIdxWithScene], Name: name, StudioID: &invalidID, }, @@ -187,8 +187,8 @@ func Test_movieQueryBuilder_Update(t *testing.T) { }, { "invalid tag id", - &models.Movie{ - ID: movieIDs[movieIdxWithScene], + &models.Group{ + ID: groupIDs[groupIdxWithScene], Name: name, TagIDs: models.NewRelatedIDs([]int{invalidID}), }, @@ -196,7 +196,7 @@ func Test_movieQueryBuilder_Update(t *testing.T) { }, } - qb := db.Movie + qb := db.Group for _, tt := range tests { runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { assert := assert.New(t) @@ -204,7 +204,7 @@ func Test_movieQueryBuilder_Update(t *testing.T) { copy := *tt.updatedObject if err := qb.Update(ctx, tt.updatedObject); (err != nil) != tt.wantErr { - t.Errorf("movieQueryBuilder.Update() error = %v, wantErr %v", err, tt.wantErr) + t.Errorf("groupQueryBuilder.Update() error = %v, wantErr %v", err, tt.wantErr) } if tt.wantErr { @@ -213,12 +213,12 @@ func Test_movieQueryBuilder_Update(t *testing.T) { s, err := qb.Find(ctx, tt.updatedObject.ID) if err != nil { - t.Errorf("movieQueryBuilder.Find() error = %v", err) + t.Errorf("groupQueryBuilder.Find() error = %v", err) } // load relationships - if err := loadMovieRelationships(ctx, copy, s); err != nil { - t.Errorf("loadMovieRelationships() error = %v", err) + if err := loadGroupRelationships(ctx, copy, s); err != nil { + t.Errorf("loadGroupRelationships() error = %v", err) return } @@ -227,9 +227,9 @@ func Test_movieQueryBuilder_Update(t *testing.T) { } } -func clearMoviePartial() models.MoviePartial { +func clearGroupPartial() models.GroupPartial { // leave mandatory fields - return models.MoviePartial{ + return models.GroupPartial{ Aliases: models.OptionalString{Set: true, Null: true}, Synopsis: models.OptionalString{Set: true, Null: true}, Director: models.OptionalString{Set: true, Null: true}, @@ -242,7 +242,7 @@ func clearMoviePartial() models.MoviePartial { } } -func Test_movieQueryBuilder_UpdatePartial(t *testing.T) { +func Test_groupQueryBuilder_UpdatePartial(t *testing.T) { var ( name = "name" url = "url" @@ -259,14 +259,14 @@ func Test_movieQueryBuilder_UpdatePartial(t *testing.T) { tests := []struct { name string id int - partial models.MoviePartial - want models.Movie + partial models.GroupPartial + want models.Group wantErr bool }{ { "full", - movieIDs[movieIdxWithScene], - models.MoviePartial{ + groupIDs[groupIdxWithScene], + models.GroupPartial{ Name: models.NewOptionalString(name), Director: models.NewOptionalString(director), Synopsis: models.NewOptionalString(synopsis), @@ -278,16 +278,16 @@ func Test_movieQueryBuilder_UpdatePartial(t *testing.T) { Date: models.NewOptionalDate(date), Duration: models.NewOptionalInt(duration), Rating: models.NewOptionalInt(rating), - StudioID: models.NewOptionalInt(studioIDs[studioIdxWithMovie]), + StudioID: models.NewOptionalInt(studioIDs[studioIdxWithGroup]), CreatedAt: models.NewOptionalTime(createdAt), UpdatedAt: models.NewOptionalTime(updatedAt), TagIDs: &models.UpdateIDs{ - IDs: []int{tagIDs[tagIdx1WithMovie], tagIDs[tagIdx1WithDupName]}, + IDs: []int{tagIDs[tagIdx1WithGroup], tagIDs[tagIdx1WithDupName]}, Mode: models.RelationshipUpdateModeSet, }, }, - models.Movie{ - ID: movieIDs[movieIdxWithScene], + models.Group{ + ID: groupIDs[groupIdxWithScene], Name: name, Director: director, Synopsis: synopsis, @@ -296,20 +296,20 @@ func Test_movieQueryBuilder_UpdatePartial(t *testing.T) { Date: &date, Duration: &duration, Rating: &rating, - StudioID: &studioIDs[studioIdxWithMovie], + StudioID: &studioIDs[studioIdxWithGroup], CreatedAt: createdAt, UpdatedAt: updatedAt, - TagIDs: models.NewRelatedIDs([]int{tagIDs[tagIdx1WithDupName], tagIDs[tagIdx1WithMovie]}), + TagIDs: models.NewRelatedIDs([]int{tagIDs[tagIdx1WithDupName], tagIDs[tagIdx1WithGroup]}), }, false, }, { "clear all", - movieIDs[movieIdxWithScene], - clearMoviePartial(), - models.Movie{ - ID: movieIDs[movieIdxWithScene], - Name: movieNames[movieIdxWithScene], + groupIDs[groupIdxWithScene], + clearGroupPartial(), + models.Group{ + ID: groupIDs[groupIdxWithScene], + Name: groupNames[groupIdxWithScene], TagIDs: models.NewRelatedIDs([]int{}), }, false, @@ -317,20 +317,20 @@ func Test_movieQueryBuilder_UpdatePartial(t *testing.T) { { "invalid id", invalidID, - models.MoviePartial{}, - models.Movie{}, + models.GroupPartial{}, + models.Group{}, true, }, } for _, tt := range tests { - qb := db.Movie + qb := db.Group 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("movieQueryBuilder.UpdatePartial() error = %v, wantErr %v", err, tt.wantErr) + t.Errorf("groupQueryBuilder.UpdatePartial() error = %v, wantErr %v", err, tt.wantErr) return } @@ -339,8 +339,8 @@ func Test_movieQueryBuilder_UpdatePartial(t *testing.T) { } // load relationships - if err := loadMovieRelationships(ctx, tt.want, got); err != nil { - t.Errorf("loadMovieRelationships() error = %v", err) + if err := loadGroupRelationships(ctx, tt.want, got); err != nil { + t.Errorf("loadGroupRelationships() error = %v", err) return } @@ -348,12 +348,12 @@ func Test_movieQueryBuilder_UpdatePartial(t *testing.T) { s, err := qb.Find(ctx, tt.id) if err != nil { - t.Errorf("movieQueryBuilder.Find() error = %v", err) + t.Errorf("groupQueryBuilder.Find() error = %v", err) } // load relationships - if err := loadMovieRelationships(ctx, tt.want, s); err != nil { - t.Errorf("loadMovieRelationships() error = %v", err) + if err := loadGroupRelationships(ctx, tt.want, s); err != nil { + t.Errorf("loadGroupRelationships() error = %v", err) return } @@ -362,65 +362,65 @@ func Test_movieQueryBuilder_UpdatePartial(t *testing.T) { } } -func TestMovieFindByName(t *testing.T) { +func TestGroupFindByName(t *testing.T) { withTxn(func(ctx context.Context) error { - mqb := db.Movie + mqb := db.Group - name := movieNames[movieIdxWithScene] // find a movie by name + name := groupNames[groupIdxWithScene] // find a group by name - movie, err := mqb.FindByName(ctx, name, false) + group, err := mqb.FindByName(ctx, name, false) if err != nil { - t.Errorf("Error finding movies: %s", err.Error()) + t.Errorf("Error finding groups: %s", err.Error()) } - assert.Equal(t, movieNames[movieIdxWithScene], movie.Name) + assert.Equal(t, groupNames[groupIdxWithScene], group.Name) - name = movieNames[movieIdxWithDupName] // find a movie by name nocase + name = groupNames[groupIdxWithDupName] // find a group by name nocase - movie, err = mqb.FindByName(ctx, name, true) + group, err = mqb.FindByName(ctx, name, true) if err != nil { - t.Errorf("Error finding movies: %s", err.Error()) + t.Errorf("Error finding groups: %s", err.Error()) } - // movieIdxWithDupName and movieIdxWithScene should have similar names ( only diff should be Name vs NaMe) - //movie.Name should match with movieIdxWithScene since its ID is before moveIdxWithDupName - assert.Equal(t, movieNames[movieIdxWithScene], movie.Name) - //movie.Name should match with movieIdxWithDupName if the check is not case sensitive - assert.Equal(t, strings.ToLower(movieNames[movieIdxWithDupName]), strings.ToLower(movie.Name)) + // groupIdxWithDupName and groupIdxWithScene should have similar names ( only diff should be Name vs NaMe) + //group.Name should match with groupIdxWithScene since its ID is before moveIdxWithDupName + assert.Equal(t, groupNames[groupIdxWithScene], group.Name) + //group.Name should match with groupIdxWithDupName if the check is not case sensitive + assert.Equal(t, strings.ToLower(groupNames[groupIdxWithDupName]), strings.ToLower(group.Name)) return nil }) } -func TestMovieFindByNames(t *testing.T) { +func TestGroupFindByNames(t *testing.T) { withTxn(func(ctx context.Context) error { var names []string - mqb := db.Movie + mqb := db.Group - names = append(names, movieNames[movieIdxWithScene]) // find movies by names + names = append(names, groupNames[groupIdxWithScene]) // find groups by names - movies, err := mqb.FindByNames(ctx, names, false) + groups, err := mqb.FindByNames(ctx, names, false) if err != nil { - t.Errorf("Error finding movies: %s", err.Error()) + t.Errorf("Error finding groups: %s", err.Error()) } - assert.Len(t, movies, 1) - assert.Equal(t, movieNames[movieIdxWithScene], movies[0].Name) + assert.Len(t, groups, 1) + assert.Equal(t, groupNames[groupIdxWithScene], groups[0].Name) - movies, err = mqb.FindByNames(ctx, names, true) // find movies by names nocase + groups, err = mqb.FindByNames(ctx, names, true) // find groups by names nocase if err != nil { - t.Errorf("Error finding movies: %s", err.Error()) + t.Errorf("Error finding groups: %s", err.Error()) } - assert.Len(t, movies, 2) // movieIdxWithScene and movieIdxWithDupName - assert.Equal(t, strings.ToLower(movieNames[movieIdxWithScene]), strings.ToLower(movies[0].Name)) - assert.Equal(t, strings.ToLower(movieNames[movieIdxWithScene]), strings.ToLower(movies[1].Name)) + assert.Len(t, groups, 2) // groupIdxWithScene and groupIdxWithDupName + assert.Equal(t, strings.ToLower(groupNames[groupIdxWithScene]), strings.ToLower(groups[0].Name)) + assert.Equal(t, strings.ToLower(groupNames[groupIdxWithScene]), strings.ToLower(groups[1].Name)) return nil }) } -func moviesToIDs(i []*models.Movie) []int { +func groupsToIDs(i []*models.Group) []int { ret := make([]int, len(i)) for i, v := range i { ret[i] = v.ID @@ -429,7 +429,7 @@ func moviesToIDs(i []*models.Movie) []int { return ret } -func TestMovieQuery(t *testing.T) { +func TestGroupQuery(t *testing.T) { var ( frontImage = "front_image" backImage = "back_image" @@ -438,7 +438,7 @@ func TestMovieQuery(t *testing.T) { tests := []struct { name string findFilter *models.FindFilterType - filter *models.MovieFilterType + filter *models.GroupFilterType includeIdxs []int excludeIdxs []int wantErr bool @@ -446,7 +446,7 @@ func TestMovieQuery(t *testing.T) { { "is missing front image", nil, - &models.MovieFilterType{ + &models.GroupFilterType{ IsMissing: &frontImage, }, // just ensure that it doesn't error @@ -457,7 +457,7 @@ func TestMovieQuery(t *testing.T) { { "is missing back image", nil, - &models.MovieFilterType{ + &models.GroupFilterType{ IsMissing: &backImage, }, // just ensure that it doesn't error @@ -471,13 +471,13 @@ func TestMovieQuery(t *testing.T) { runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { assert := assert.New(t) - results, _, err := db.Movie.Query(ctx, tt.filter, tt.findFilter) + results, _, err := db.Group.Query(ctx, tt.filter, tt.findFilter) if (err != nil) != tt.wantErr { - t.Errorf("MovieQueryBuilder.Query() error = %v, wantErr %v", err, tt.wantErr) + t.Errorf("GroupQueryBuilder.Query() error = %v, wantErr %v", err, tt.wantErr) return } - ids := moviesToIDs(results) + ids := groupsToIDs(results) include := indexesToIDs(performerIDs, tt.includeIdxs) exclude := indexesToIDs(performerIDs, tt.excludeIdxs) @@ -491,66 +491,66 @@ func TestMovieQuery(t *testing.T) { } } -func TestMovieQueryStudio(t *testing.T) { +func TestGroupQueryStudio(t *testing.T) { withTxn(func(ctx context.Context) error { - mqb := db.Movie + mqb := db.Group studioCriterion := models.HierarchicalMultiCriterionInput{ Value: []string{ - strconv.Itoa(studioIDs[studioIdxWithMovie]), + strconv.Itoa(studioIDs[studioIdxWithGroup]), }, Modifier: models.CriterionModifierIncludes, } - movieFilter := models.MovieFilterType{ + groupFilter := models.GroupFilterType{ Studios: &studioCriterion, } - movies, _, err := mqb.Query(ctx, &movieFilter, nil) + groups, _, err := mqb.Query(ctx, &groupFilter, nil) if err != nil { - t.Errorf("Error querying movie: %s", err.Error()) + t.Errorf("Error querying group: %s", err.Error()) } - assert.Len(t, movies, 1) + assert.Len(t, groups, 1) // ensure id is correct - assert.Equal(t, movieIDs[movieIdxWithStudio], movies[0].ID) + assert.Equal(t, groupIDs[groupIdxWithStudio], groups[0].ID) studioCriterion = models.HierarchicalMultiCriterionInput{ Value: []string{ - strconv.Itoa(studioIDs[studioIdxWithMovie]), + strconv.Itoa(studioIDs[studioIdxWithGroup]), }, Modifier: models.CriterionModifierExcludes, } - q := getMovieStringValue(movieIdxWithStudio, titleField) + q := getGroupStringValue(groupIdxWithStudio, titleField) findFilter := models.FindFilterType{ Q: &q, } - movies, _, err = mqb.Query(ctx, &movieFilter, &findFilter) + groups, _, err = mqb.Query(ctx, &groupFilter, &findFilter) if err != nil { - t.Errorf("Error querying movie: %s", err.Error()) + t.Errorf("Error querying group: %s", err.Error()) } - assert.Len(t, movies, 0) + assert.Len(t, groups, 0) return nil }) } -func TestMovieQueryURL(t *testing.T) { +func TestGroupQueryURL(t *testing.T) { const sceneIdx = 1 - movieURL := getMovieStringValue(sceneIdx, urlField) + groupURL := getGroupStringValue(sceneIdx, urlField) urlCriterion := models.StringCriterionInput{ - Value: movieURL, + Value: groupURL, Modifier: models.CriterionModifierEquals, } - filter := models.MovieFilterType{ + filter := models.GroupFilterType{ URL: &urlCriterion, } - verifyFn := func(n *models.Movie) { + verifyFn := func(n *models.Group) { t.Helper() urls := n.URLs.List() @@ -562,93 +562,93 @@ func TestMovieQueryURL(t *testing.T) { verifyString(t, url, urlCriterion) } - verifyMovieQuery(t, filter, verifyFn) + verifyGroupQuery(t, filter, verifyFn) urlCriterion.Modifier = models.CriterionModifierNotEquals - verifyMovieQuery(t, filter, verifyFn) + verifyGroupQuery(t, filter, verifyFn) urlCriterion.Modifier = models.CriterionModifierMatchesRegex - urlCriterion.Value = "movie_.*1_URL" - verifyMovieQuery(t, filter, verifyFn) + urlCriterion.Value = "group_.*1_URL" + verifyGroupQuery(t, filter, verifyFn) urlCriterion.Modifier = models.CriterionModifierNotMatchesRegex - verifyMovieQuery(t, filter, verifyFn) + verifyGroupQuery(t, filter, verifyFn) urlCriterion.Modifier = models.CriterionModifierIsNull urlCriterion.Value = "" - verifyMovieQuery(t, filter, verifyFn) + verifyGroupQuery(t, filter, verifyFn) urlCriterion.Modifier = models.CriterionModifierNotNull - verifyMovieQuery(t, filter, verifyFn) + verifyGroupQuery(t, filter, verifyFn) } -func TestMovieQueryURLExcludes(t *testing.T) { +func TestGroupQueryURLExcludes(t *testing.T) { withRollbackTxn(func(ctx context.Context) error { - mqb := db.Movie + mqb := db.Group - // create movie with two URLs - movie := models.Movie{ - Name: "TestMovieQueryURLExcludes", + // create group with two URLs + group := models.Group{ + Name: "TestGroupQueryURLExcludes", URLs: models.NewRelatedStrings([]string{ "aaa", "bbb", }), } - err := mqb.Create(ctx, &movie) + err := mqb.Create(ctx, &group) if err != nil { - return fmt.Errorf("Error creating movie: %w", err) + return fmt.Errorf("Error creating group: %w", err) } - // query for movies that exclude the URL "aaa" + // query for groups that exclude the URL "aaa" urlCriterion := models.StringCriterionInput{ Value: "aaa", Modifier: models.CriterionModifierExcludes, } nameCriterion := models.StringCriterionInput{ - Value: movie.Name, + Value: group.Name, Modifier: models.CriterionModifierEquals, } - filter := models.MovieFilterType{ + filter := models.GroupFilterType{ URL: &urlCriterion, Name: &nameCriterion, } - movies := queryMovies(ctx, t, &filter, nil) - assert.Len(t, movies, 0, "Expected no movies to be found") + groups := queryGroups(ctx, t, &filter, nil) + assert.Len(t, groups, 0, "Expected no groups to be found") - // query for movies that exclude the URL "ccc" + // query for groups that exclude the URL "ccc" urlCriterion.Value = "ccc" - movies = queryMovies(ctx, t, &filter, nil) + groups = queryGroups(ctx, t, &filter, nil) - if assert.Len(t, movies, 1, "Expected one movie to be found") { - assert.Equal(t, movie.Name, movies[0].Name) + if assert.Len(t, groups, 1, "Expected one group to be found") { + assert.Equal(t, group.Name, groups[0].Name) } return nil }) } -func verifyMovieQuery(t *testing.T, filter models.MovieFilterType, verifyFn func(s *models.Movie)) { +func verifyGroupQuery(t *testing.T, filter models.GroupFilterType, verifyFn func(s *models.Group)) { withTxn(func(ctx context.Context) error { t.Helper() - sqb := db.Movie + sqb := db.Group - movies := queryMovies(ctx, t, &filter, nil) + groups := queryGroups(ctx, t, &filter, nil) - for _, movie := range movies { - if err := movie.LoadURLs(ctx, sqb); err != nil { - t.Errorf("Error loading movie relationships: %v", err) + for _, group := range groups { + if err := group.LoadURLs(ctx, sqb); err != nil { + t.Errorf("Error loading group relationships: %v", err) } } // assume it should find at least one - assert.Greater(t, len(movies), 0) + assert.Greater(t, len(groups), 0) - for _, m := range movies { + for _, m := range groups { verifyFn(m) } @@ -656,102 +656,102 @@ func verifyMovieQuery(t *testing.T, filter models.MovieFilterType, verifyFn func }) } -func queryMovies(ctx context.Context, t *testing.T, movieFilter *models.MovieFilterType, findFilter *models.FindFilterType) []*models.Movie { - sqb := db.Movie - movies, _, err := sqb.Query(ctx, movieFilter, findFilter) +func queryGroups(ctx context.Context, t *testing.T, groupFilter *models.GroupFilterType, findFilter *models.FindFilterType) []*models.Group { + sqb := db.Group + groups, _, err := sqb.Query(ctx, groupFilter, findFilter) if err != nil { - t.Errorf("Error querying movie: %s", err.Error()) + t.Errorf("Error querying group: %s", err.Error()) } - return movies + return groups } -func TestMovieQueryTags(t *testing.T) { +func TestGroupQueryTags(t *testing.T) { withTxn(func(ctx context.Context) error { tagCriterion := models.HierarchicalMultiCriterionInput{ Value: []string{ - strconv.Itoa(tagIDs[tagIdxWithMovie]), - strconv.Itoa(tagIDs[tagIdx1WithMovie]), + strconv.Itoa(tagIDs[tagIdxWithGroup]), + strconv.Itoa(tagIDs[tagIdx1WithGroup]), }, Modifier: models.CriterionModifierIncludes, } - movieFilter := models.MovieFilterType{ + groupFilter := models.GroupFilterType{ Tags: &tagCriterion, } // ensure ids are correct - movies := queryMovies(ctx, t, &movieFilter, nil) - assert.Len(t, movies, 3) - for _, movie := range movies { - assert.True(t, movie.ID == movieIDs[movieIdxWithTag] || movie.ID == movieIDs[movieIdxWithTwoTags] || movie.ID == movieIDs[movieIdxWithThreeTags]) + groups := queryGroups(ctx, t, &groupFilter, nil) + assert.Len(t, groups, 3) + for _, group := range groups { + assert.True(t, group.ID == groupIDs[groupIdxWithTag] || group.ID == groupIDs[groupIdxWithTwoTags] || group.ID == groupIDs[groupIdxWithThreeTags]) } tagCriterion = models.HierarchicalMultiCriterionInput{ Value: []string{ - strconv.Itoa(tagIDs[tagIdx1WithMovie]), - strconv.Itoa(tagIDs[tagIdx2WithMovie]), + strconv.Itoa(tagIDs[tagIdx1WithGroup]), + strconv.Itoa(tagIDs[tagIdx2WithGroup]), }, Modifier: models.CriterionModifierIncludesAll, } - movies = queryMovies(ctx, t, &movieFilter, nil) + groups = queryGroups(ctx, t, &groupFilter, nil) - if assert.Len(t, movies, 2) { - assert.Equal(t, sceneIDs[movieIdxWithTwoTags], movies[0].ID) - assert.Equal(t, sceneIDs[movieIdxWithThreeTags], movies[1].ID) + if assert.Len(t, groups, 2) { + assert.Equal(t, sceneIDs[groupIdxWithTwoTags], groups[0].ID) + assert.Equal(t, sceneIDs[groupIdxWithThreeTags], groups[1].ID) } tagCriterion = models.HierarchicalMultiCriterionInput{ Value: []string{ - strconv.Itoa(tagIDs[tagIdx1WithMovie]), + strconv.Itoa(tagIDs[tagIdx1WithGroup]), }, Modifier: models.CriterionModifierExcludes, } - q := getSceneStringValue(movieIdxWithTwoTags, titleField) + q := getSceneStringValue(groupIdxWithTwoTags, titleField) findFilter := models.FindFilterType{ Q: &q, } - movies = queryMovies(ctx, t, &movieFilter, &findFilter) - assert.Len(t, movies, 0) + groups = queryGroups(ctx, t, &groupFilter, &findFilter) + assert.Len(t, groups, 0) return nil }) } -func TestMovieQueryTagCount(t *testing.T) { +func TestGroupQueryTagCount(t *testing.T) { const tagCount = 1 tagCountCriterion := models.IntCriterionInput{ Value: tagCount, Modifier: models.CriterionModifierEquals, } - verifyMoviesTagCount(t, tagCountCriterion) + verifyGroupsTagCount(t, tagCountCriterion) tagCountCriterion.Modifier = models.CriterionModifierNotEquals - verifyMoviesTagCount(t, tagCountCriterion) + verifyGroupsTagCount(t, tagCountCriterion) tagCountCriterion.Modifier = models.CriterionModifierGreaterThan - verifyMoviesTagCount(t, tagCountCriterion) + verifyGroupsTagCount(t, tagCountCriterion) tagCountCriterion.Modifier = models.CriterionModifierLessThan - verifyMoviesTagCount(t, tagCountCriterion) + verifyGroupsTagCount(t, tagCountCriterion) } -func verifyMoviesTagCount(t *testing.T, tagCountCriterion models.IntCriterionInput) { +func verifyGroupsTagCount(t *testing.T, tagCountCriterion models.IntCriterionInput) { withTxn(func(ctx context.Context) error { - sqb := db.Movie - movieFilter := models.MovieFilterType{ + sqb := db.Group + groupFilter := models.GroupFilterType{ TagCount: &tagCountCriterion, } - movies := queryMovies(ctx, t, &movieFilter, nil) - assert.Greater(t, len(movies), 0) + groups := queryGroups(ctx, t, &groupFilter, nil) + assert.Greater(t, len(groups), 0) - for _, movie := range movies { - ids, err := sqb.GetTagIDs(ctx, movie.ID) + for _, group := range groups { + ids, err := sqb.GetTagIDs(ctx, group.ID) if err != nil { return err } @@ -762,7 +762,7 @@ func verifyMoviesTagCount(t *testing.T, tagCountCriterion models.IntCriterionInp }) } -func TestMovieQuerySorting(t *testing.T) { +func TestGroupQuerySorting(t *testing.T) { sort := "scenes_count" direction := models.SortDirectionEnumDesc findFilter := models.FindFilterType{ @@ -771,60 +771,60 @@ func TestMovieQuerySorting(t *testing.T) { } withTxn(func(ctx context.Context) error { - movies := queryMovies(ctx, t, nil, &findFilter) + groups := queryGroups(ctx, t, nil, &findFilter) // scenes should be in same order as indexes - firstMovie := movies[0] + firstGroup := groups[0] - assert.Equal(t, movieIDs[movieIdxWithScene], firstMovie.ID) + assert.Equal(t, groupIDs[groupIdxWithScene], firstGroup.ID) // sort in descending order direction = models.SortDirectionEnumAsc - movies = queryMovies(ctx, t, nil, &findFilter) - lastMovie := movies[len(movies)-1] + groups = queryGroups(ctx, t, nil, &findFilter) + lastGroup := groups[len(groups)-1] - assert.Equal(t, movieIDs[movieIdxWithScene], lastMovie.ID) + assert.Equal(t, groupIDs[groupIdxWithScene], lastGroup.ID) return nil }) } -func TestMovieUpdateFrontImage(t *testing.T) { +func TestGroupUpdateFrontImage(t *testing.T) { if err := withRollbackTxn(func(ctx context.Context) error { - qb := db.Movie + qb := db.Group - // create movie to test against - const name = "TestMovieUpdateMovieImages" - movie := models.Movie{ + // create group to test against + const name = "TestGroupUpdateGroupImages" + group := models.Group{ Name: name, } - err := qb.Create(ctx, &movie) + err := qb.Create(ctx, &group) if err != nil { - return fmt.Errorf("Error creating movie: %s", err.Error()) + return fmt.Errorf("Error creating group: %s", err.Error()) } - return testUpdateImage(t, ctx, movie.ID, qb.UpdateFrontImage, qb.GetFrontImage) + return testUpdateImage(t, ctx, group.ID, qb.UpdateFrontImage, qb.GetFrontImage) }); err != nil { t.Error(err.Error()) } } -func TestMovieUpdateBackImage(t *testing.T) { +func TestGroupUpdateBackImage(t *testing.T) { if err := withRollbackTxn(func(ctx context.Context) error { - qb := db.Movie + qb := db.Group - // create movie to test against - const name = "TestMovieUpdateMovieImages" - movie := models.Movie{ + // create group to test against + const name = "TestGroupUpdateGroupImages" + group := models.Group{ Name: name, } - err := qb.Create(ctx, &movie) + err := qb.Create(ctx, &group) if err != nil { - return fmt.Errorf("Error creating movie: %s", err.Error()) + return fmt.Errorf("Error creating group: %s", err.Error()) } - return testUpdateImage(t, ctx, movie.ID, qb.UpdateBackImage, qb.GetBackImage) + return testUpdateImage(t, ctx, group.ID, qb.UpdateBackImage, qb.GetBackImage) }); err != nil { t.Error(err.Error()) } diff --git a/pkg/sqlite/scene.go b/pkg/sqlite/scene.go index fa0cb5e6390..5b4caff11bf 100644 --- a/pkg/sqlite/scene.go +++ b/pkg/sqlite/scene.go @@ -28,7 +28,7 @@ const ( performersScenesTable = "performers_scenes" scenesTagsTable = "scenes_tags" scenesGalleriesTable = "scenes_galleries" - moviesScenesTable = "movies_scenes" + groupsScenesTable = "movies_scenes" scenesURLsTable = "scene_urls" sceneURLColumn = "url" scenesViewDatesTable = "scenes_view_dates" @@ -173,7 +173,7 @@ type sceneRepositoryType struct { galleries joinRepository tags joinRepository performers joinRepository - movies repository + groups repository files filesRepository @@ -209,8 +209,8 @@ var ( }, fkColumn: performerIDColumn, }, - movies: repository{ - tableName: moviesScenesTable, + groups: repository{ + tableName: groupsScenesTable, idColumn: sceneIDColumn, }, files: filesRepository{ @@ -343,8 +343,8 @@ func (qb *SceneStore) Create(ctx context.Context, newObject *models.Scene, fileI } } - if newObject.Movies.Loaded() { - if err := scenesMoviesTableMgr.insertJoins(ctx, id, newObject.Movies.List()); err != nil { + if newObject.Groups.Loaded() { + if err := scenesGroupsTableMgr.insertJoins(ctx, id, newObject.Groups.List()); err != nil { return err } } @@ -399,8 +399,8 @@ func (qb *SceneStore) UpdatePartial(ctx context.Context, id int, partial models. return nil, err } } - if partial.MovieIDs != nil { - if err := scenesMoviesTableMgr.modifyJoins(ctx, id, partial.MovieIDs.Movies, partial.MovieIDs.Mode); err != nil { + if partial.GroupIDs != nil { + if err := scenesGroupsTableMgr.modifyJoins(ctx, id, partial.GroupIDs.Groups, partial.GroupIDs.Mode); err != nil { return nil, err } } @@ -451,8 +451,8 @@ func (qb *SceneStore) Update(ctx context.Context, updatedObject *models.Scene) e } } - if updatedObject.Movies.Loaded() { - if err := scenesMoviesTableMgr.replaceJoins(ctx, updatedObject.ID, updatedObject.Movies.List()); err != nil { + if updatedObject.Groups.Loaded() { + if err := scenesGroupsTableMgr.replaceJoins(ctx, updatedObject.ID, updatedObject.Groups.List()); err != nil { return err } } @@ -778,23 +778,23 @@ func (qb *SceneStore) OCountByPerformerID(ctx context.Context, performerID int) return ret, nil } -func (qb *SceneStore) FindByMovieID(ctx context.Context, movieID int) ([]*models.Scene, error) { - sq := dialect.From(scenesMoviesJoinTable).Select(scenesMoviesJoinTable.Col(sceneIDColumn)).Where( - scenesMoviesJoinTable.Col(movieIDColumn).Eq(movieID), +func (qb *SceneStore) FindByGroupID(ctx context.Context, groupID int) ([]*models.Scene, error) { + sq := dialect.From(scenesGroupsJoinTable).Select(scenesGroupsJoinTable.Col(sceneIDColumn)).Where( + scenesGroupsJoinTable.Col(groupIDColumn).Eq(groupID), ) ret, err := qb.findBySubquery(ctx, sq) if err != nil { - return nil, fmt.Errorf("getting scenes for movie %d: %w", movieID, err) + return nil, fmt.Errorf("getting scenes for group %d: %w", groupID, err) } return ret, nil } -func (qb *SceneStore) CountByMovieID(ctx context.Context, movieID int) (int, error) { - joinTable := scenesMoviesJoinTable +func (qb *SceneStore) CountByGroupID(ctx context.Context, groupID int) (int, error) { + joinTable := scenesGroupsJoinTable - q := dialect.Select(goqu.COUNT("*")).From(joinTable).Where(joinTable.Col(movieIDColumn).Eq(movieID)) + q := dialect.Select(goqu.COUNT("*")).From(joinTable).Where(joinTable.Col(groupIDColumn).Eq(groupID)) return count(ctx, q) } @@ -1142,8 +1142,8 @@ func (qb *SceneStore) setSceneSort(query *queryBuilder, findFilter *models.FindF direction := findFilter.GetDirection() switch sort { case "movie_scene_number", "group_scene_number": - query.join(moviesScenesTable, "", "scenes.id = movies_scenes.scene_id") - query.sortAndPagination += getSort("scene_index", direction, moviesScenesTable) + query.join(groupsScenesTable, "", "scenes.id = movies_scenes.scene_id") + query.sortAndPagination += getSort("scene_index", direction, groupsScenesTable) case "tag_count": query.sortAndPagination += getCountSort(sceneTable, scenesTagsTable, sceneIDColumn, direction) case "performer_count": @@ -1270,11 +1270,11 @@ func (qb *SceneStore) AssignFiles(ctx context.Context, sceneID int, fileIDs []mo return scenesFilesTableMgr.insertJoins(ctx, sceneID, firstPrimary, fileIDs) } -func (qb *SceneStore) GetMovies(ctx context.Context, id int) (ret []models.MoviesScenes, err error) { - ret = []models.MoviesScenes{} +func (qb *SceneStore) GetGroups(ctx context.Context, id int) (ret []models.GroupsScenes, err error) { + ret = []models.GroupsScenes{} - if err := sceneRepository.movies.getAll(ctx, id, func(rows *sqlx.Rows) error { - var ms moviesScenesRow + if err := sceneRepository.groups.getAll(ctx, id, func(rows *sqlx.Rows) error { + var ms groupsScenesRow if err := rows.StructScan(&ms); err != nil { return err } diff --git a/pkg/sqlite/scene_filter.go b/pkg/sqlite/scene_filter.go index 7c6e3f634fa..53eacaf3446 100644 --- a/pkg/sqlite/scene_filter.go +++ b/pkg/sqlite/scene_filter.go @@ -195,10 +195,10 @@ func (qb *sceneFilterHandler) criterionHandler() criterionHandler { &relatedFilterHandler{ relatedIDCol: "movies_scenes.movie_id", - relatedRepo: movieRepository.repository, - relatedHandler: &movieFilterHandler{sceneFilter.MoviesFilter}, + relatedRepo: groupRepository.repository, + relatedHandler: &groupFilterHandler{sceneFilter.MoviesFilter}, joinFn: func(f *filterBuilder) { - sceneRepository.movies.innerJoin(f, "", "scenes.id") + sceneRepository.groups.innerJoin(f, "", "scenes.id") }, }, @@ -320,7 +320,7 @@ func (qb *sceneFilterHandler) isMissingCriterionHandler(isMissing *string) crite case "studio": f.addWhere("scenes.studio_id IS NULL") case "movie": - sceneRepository.movies.join(f, "movies_join", "scenes.id") + sceneRepository.groups.join(f, "movies_join", "scenes.id") f.addWhere("movies_join.scene_id IS NULL") case "performers": sceneRepository.performers.join(f, "performers_join", "scenes.id") @@ -485,10 +485,10 @@ func (qb *sceneFilterHandler) performerAgeCriterionHandler(performerAge *models. func (qb *sceneFilterHandler) groupsCriterionHandler(movies *models.MultiCriterionInput) criterionHandlerFunc { addJoinsFunc := func(f *filterBuilder) { - sceneRepository.movies.join(f, "", "scenes.id") + sceneRepository.groups.join(f, "", "scenes.id") f.addLeftJoin("movies", "", "movies_scenes.movie_id = movies.id") } - h := qb.getMultiCriterionHandlerBuilder(movieTable, moviesScenesTable, "movie_id", addJoinsFunc) + h := qb.getMultiCriterionHandlerBuilder(groupTable, groupsScenesTable, "movie_id", addJoinsFunc) return h.handler(movies) } diff --git a/pkg/sqlite/scene_test.go b/pkg/sqlite/scene_test.go index f5528b1248e..9116158fc9f 100644 --- a/pkg/sqlite/scene_test.go +++ b/pkg/sqlite/scene_test.go @@ -41,8 +41,8 @@ func loadSceneRelationships(ctx context.Context, expected models.Scene, actual * return err } } - if expected.Movies.Loaded() { - if err := actual.LoadMovies(ctx, db.Scene); err != nil { + if expected.Groups.Loaded() { + if err := actual.LoadGroups(ctx, db.Scene); err != nil { return err } } @@ -120,13 +120,13 @@ func Test_sceneQueryBuilder_Create(t *testing.T) { GalleryIDs: models.NewRelatedIDs([]int{galleryIDs[galleryIdxWithScene]}), TagIDs: models.NewRelatedIDs([]int{tagIDs[tagIdx1WithDupName], tagIDs[tagIdx1WithScene]}), PerformerIDs: models.NewRelatedIDs([]int{performerIDs[performerIdx1WithScene], performerIDs[performerIdx1WithDupName]}), - Movies: models.NewRelatedMovies([]models.MoviesScenes{ + Groups: models.NewRelatedGroups([]models.GroupsScenes{ { - MovieID: movieIDs[movieIdxWithScene], + GroupID: groupIDs[groupIdxWithScene], SceneIndex: &sceneIndex, }, { - MovieID: movieIDs[movieIdxWithStudio], + GroupID: groupIDs[groupIdxWithStudio], SceneIndex: &sceneIndex2, }, }), @@ -165,13 +165,13 @@ func Test_sceneQueryBuilder_Create(t *testing.T) { GalleryIDs: models.NewRelatedIDs([]int{galleryIDs[galleryIdxWithScene]}), TagIDs: models.NewRelatedIDs([]int{tagIDs[tagIdx1WithDupName], tagIDs[tagIdx1WithScene]}), PerformerIDs: models.NewRelatedIDs([]int{performerIDs[performerIdx1WithScene], performerIDs[performerIdx1WithDupName]}), - Movies: models.NewRelatedMovies([]models.MoviesScenes{ + Groups: models.NewRelatedGroups([]models.GroupsScenes{ { - MovieID: movieIDs[movieIdxWithScene], + GroupID: groupIDs[groupIdxWithScene], SceneIndex: &sceneIndex, }, { - MovieID: movieIDs[movieIdxWithStudio], + GroupID: groupIDs[groupIdxWithStudio], SceneIndex: &sceneIndex2, }, }), @@ -219,11 +219,11 @@ func Test_sceneQueryBuilder_Create(t *testing.T) { true, }, { - "invalid movie id", + "invalid group id", models.Scene{ - Movies: models.NewRelatedMovies([]models.MoviesScenes{ + Groups: models.NewRelatedGroups([]models.GroupsScenes{ { - MovieID: invalidID, + GroupID: invalidID, SceneIndex: &sceneIndex, }, }), @@ -349,13 +349,13 @@ func Test_sceneQueryBuilder_Update(t *testing.T) { GalleryIDs: models.NewRelatedIDs([]int{galleryIDs[galleryIdxWithScene]}), TagIDs: models.NewRelatedIDs([]int{tagIDs[tagIdx1WithDupName], tagIDs[tagIdx1WithScene]}), PerformerIDs: models.NewRelatedIDs([]int{performerIDs[performerIdx1WithScene], performerIDs[performerIdx1WithDupName]}), - Movies: models.NewRelatedMovies([]models.MoviesScenes{ + Groups: models.NewRelatedGroups([]models.GroupsScenes{ { - MovieID: movieIDs[movieIdxWithScene], + GroupID: groupIDs[groupIdxWithScene], SceneIndex: &sceneIndex, }, { - MovieID: movieIDs[movieIdxWithStudio], + GroupID: groupIDs[groupIdxWithStudio], SceneIndex: &sceneIndex2, }, }), @@ -381,7 +381,7 @@ func Test_sceneQueryBuilder_Update(t *testing.T) { GalleryIDs: models.NewRelatedIDs([]int{}), TagIDs: models.NewRelatedIDs([]int{}), PerformerIDs: models.NewRelatedIDs([]int{}), - Movies: models.NewRelatedMovies([]models.MoviesScenes{}), + Groups: models.NewRelatedGroups([]models.GroupsScenes{}), StashIDs: models.NewRelatedStashIDs([]models.StashID{}), }, false, @@ -411,10 +411,10 @@ func Test_sceneQueryBuilder_Update(t *testing.T) { false, }, { - "clear movies", + "clear groups", &models.Scene{ - ID: sceneIDs[sceneIdxWithMovie], - Movies: models.NewRelatedMovies([]models.MoviesScenes{}), + ID: sceneIDs[sceneIdxWithGroup], + Groups: models.NewRelatedGroups([]models.GroupsScenes{}), }, false, }, @@ -451,12 +451,12 @@ func Test_sceneQueryBuilder_Update(t *testing.T) { true, }, { - "invalid movie id", + "invalid group id", &models.Scene{ ID: sceneIDs[sceneIdxWithSpacedName], - Movies: models.NewRelatedMovies([]models.MoviesScenes{ + Groups: models.NewRelatedGroups([]models.GroupsScenes{ { - MovieID: invalidID, + GroupID: invalidID, SceneIndex: &sceneIndex, }, }), @@ -573,14 +573,14 @@ func Test_sceneQueryBuilder_UpdatePartial(t *testing.T) { IDs: []int{performerIDs[performerIdx1WithScene], performerIDs[performerIdx1WithDupName]}, Mode: models.RelationshipUpdateModeSet, }, - MovieIDs: &models.UpdateMovieIDs{ - Movies: []models.MoviesScenes{ + GroupIDs: &models.UpdateGroupIDs{ + Groups: []models.GroupsScenes{ { - MovieID: movieIDs[movieIdxWithScene], + GroupID: groupIDs[groupIdxWithScene], SceneIndex: &sceneIndex, }, { - MovieID: movieIDs[movieIdxWithStudio], + GroupID: groupIDs[groupIdxWithStudio], SceneIndex: &sceneIndex2, }, }, @@ -621,13 +621,13 @@ func Test_sceneQueryBuilder_UpdatePartial(t *testing.T) { GalleryIDs: models.NewRelatedIDs([]int{galleryIDs[galleryIdxWithScene]}), TagIDs: models.NewRelatedIDs([]int{tagIDs[tagIdx1WithDupName], tagIDs[tagIdx1WithScene]}), PerformerIDs: models.NewRelatedIDs([]int{performerIDs[performerIdx1WithScene], performerIDs[performerIdx1WithDupName]}), - Movies: models.NewRelatedMovies([]models.MoviesScenes{ + Groups: models.NewRelatedGroups([]models.GroupsScenes{ { - MovieID: movieIDs[movieIdxWithScene], + GroupID: groupIDs[groupIdxWithScene], SceneIndex: &sceneIndex, }, { - MovieID: movieIDs[movieIdxWithStudio], + GroupID: groupIDs[groupIdxWithStudio], SceneIndex: &sceneIndex2, }, }), @@ -658,7 +658,7 @@ func Test_sceneQueryBuilder_UpdatePartial(t *testing.T) { GalleryIDs: models.NewRelatedIDs([]int{}), TagIDs: models.NewRelatedIDs([]int{}), PerformerIDs: models.NewRelatedIDs([]int{}), - Movies: models.NewRelatedMovies([]models.MoviesScenes{}), + Groups: models.NewRelatedGroups([]models.GroupsScenes{}), StashIDs: models.NewRelatedStashIDs([]models.StashID{}), PlayDuration: getScenePlayDuration(sceneIdxWithSpacedName), ResumeTime: getSceneResumeTime(sceneIdxWithSpacedName), @@ -727,13 +727,13 @@ func Test_sceneQueryBuilder_UpdatePartialRelationships(t *testing.T) { stashID1 = "stashid1" stashID2 = "stashid2" - movieScenes = []models.MoviesScenes{ + groupScenes = []models.GroupsScenes{ { - MovieID: movieIDs[movieIdxWithDupName], + GroupID: groupIDs[groupIdxWithDupName], SceneIndex: &sceneIndex, }, { - MovieID: movieIDs[movieIdxWithStudio], + GroupID: groupIDs[groupIdxWithStudio], SceneIndex: &sceneIndex2, }, } @@ -863,40 +863,40 @@ func Test_sceneQueryBuilder_UpdatePartialRelationships(t *testing.T) { false, }, { - "add movies", - sceneIDs[sceneIdxWithMovie], + "add groups", + sceneIDs[sceneIdxWithGroup], models.ScenePartial{ - MovieIDs: &models.UpdateMovieIDs{ - Movies: movieScenes, + GroupIDs: &models.UpdateGroupIDs{ + Groups: groupScenes, Mode: models.RelationshipUpdateModeAdd, }, }, models.Scene{ - Movies: models.NewRelatedMovies(append([]models.MoviesScenes{ + Groups: models.NewRelatedGroups(append([]models.GroupsScenes{ { - MovieID: indexesToIDs(movieIDs, sceneMovies[sceneIdxWithMovie])[0], + GroupID: indexesToIDs(groupIDs, sceneGroups[sceneIdxWithGroup])[0], }, - }, movieScenes...)), + }, groupScenes...)), }, false, }, { - "add movies to empty", + "add groups to empty", sceneIDs[sceneIdx1WithPerformer], models.ScenePartial{ - MovieIDs: &models.UpdateMovieIDs{ - Movies: movieScenes, + GroupIDs: &models.UpdateGroupIDs{ + Groups: groupScenes, Mode: models.RelationshipUpdateModeAdd, }, }, models.Scene{ - Movies: models.NewRelatedMovies([]models.MoviesScenes{ + Groups: models.NewRelatedGroups([]models.GroupsScenes{ { - MovieID: movieIDs[movieIdxWithDupName], + GroupID: groupIDs[groupIdxWithDupName], SceneIndex: &sceneIndex, }, { - MovieID: movieIDs[movieIdxWithStudio], + GroupID: groupIDs[groupIdxWithStudio], SceneIndex: &sceneIndex2, }, }), @@ -967,27 +967,27 @@ func Test_sceneQueryBuilder_UpdatePartialRelationships(t *testing.T) { false, }, { - "add duplicate movies", - sceneIDs[sceneIdxWithMovie], + "add duplicate groups", + sceneIDs[sceneIdxWithGroup], models.ScenePartial{ - MovieIDs: &models.UpdateMovieIDs{ - Movies: append([]models.MoviesScenes{ + GroupIDs: &models.UpdateGroupIDs{ + Groups: append([]models.GroupsScenes{ { - MovieID: movieIDs[movieIdxWithScene], + GroupID: groupIDs[groupIdxWithScene], SceneIndex: &sceneIndex, }, }, - movieScenes..., + groupScenes..., ), Mode: models.RelationshipUpdateModeAdd, }, }, models.Scene{ - Movies: models.NewRelatedMovies(append([]models.MoviesScenes{ + Groups: models.NewRelatedGroups(append([]models.GroupsScenes{ { - MovieID: indexesToIDs(movieIDs, sceneMovies[sceneIdxWithMovie])[0], + GroupID: indexesToIDs(groupIDs, sceneGroups[sceneIdxWithGroup])[0], }, - }, movieScenes...)), + }, groupScenes...)), }, false, }, @@ -1044,13 +1044,13 @@ func Test_sceneQueryBuilder_UpdatePartialRelationships(t *testing.T) { true, }, { - "add invalid movies", - sceneIDs[sceneIdxWithMovie], + "add invalid groups", + sceneIDs[sceneIdxWithGroup], models.ScenePartial{ - MovieIDs: &models.UpdateMovieIDs{ - Movies: []models.MoviesScenes{ + GroupIDs: &models.UpdateGroupIDs{ + Groups: []models.GroupsScenes{ { - MovieID: invalidID, + GroupID: invalidID, }, }, Mode: models.RelationshipUpdateModeAdd, @@ -1102,20 +1102,20 @@ func Test_sceneQueryBuilder_UpdatePartialRelationships(t *testing.T) { false, }, { - "remove movies", - sceneIDs[sceneIdxWithMovie], + "remove groups", + sceneIDs[sceneIdxWithGroup], models.ScenePartial{ - MovieIDs: &models.UpdateMovieIDs{ - Movies: []models.MoviesScenes{ + GroupIDs: &models.UpdateGroupIDs{ + Groups: []models.GroupsScenes{ { - MovieID: movieIDs[movieIdxWithScene], + GroupID: groupIDs[groupIdxWithScene], }, }, Mode: models.RelationshipUpdateModeRemove, }, }, models.Scene{ - Movies: models.NewRelatedMovies([]models.MoviesScenes{}), + Groups: models.NewRelatedGroups([]models.GroupsScenes{}), }, false, }, @@ -1176,22 +1176,22 @@ func Test_sceneQueryBuilder_UpdatePartialRelationships(t *testing.T) { false, }, { - "remove unrelated movies", - sceneIDs[sceneIdxWithMovie], + "remove unrelated groups", + sceneIDs[sceneIdxWithGroup], models.ScenePartial{ - MovieIDs: &models.UpdateMovieIDs{ - Movies: []models.MoviesScenes{ + GroupIDs: &models.UpdateGroupIDs{ + Groups: []models.GroupsScenes{ { - MovieID: movieIDs[movieIdxWithDupName], + GroupID: groupIDs[groupIdxWithDupName], }, }, Mode: models.RelationshipUpdateModeRemove, }, }, models.Scene{ - Movies: models.NewRelatedMovies([]models.MoviesScenes{ + Groups: models.NewRelatedGroups([]models.GroupsScenes{ { - MovieID: indexesToIDs(movieIDs, sceneMovies[sceneIdxWithMovie])[0], + GroupID: indexesToIDs(groupIDs, sceneGroups[sceneIdxWithGroup])[0], }, }), }, @@ -1257,9 +1257,9 @@ func Test_sceneQueryBuilder_UpdatePartialRelationships(t *testing.T) { assert.ElementsMatch(tt.want.GalleryIDs.List(), got.GalleryIDs.List()) assert.ElementsMatch(tt.want.GalleryIDs.List(), s.GalleryIDs.List()) } - if tt.partial.MovieIDs != nil { - assert.ElementsMatch(tt.want.Movies.List(), got.Movies.List()) - assert.ElementsMatch(tt.want.Movies.List(), s.Movies.List()) + if tt.partial.GroupIDs != nil { + assert.ElementsMatch(tt.want.Groups.List(), got.Groups.List()) + assert.ElementsMatch(tt.want.Groups.List(), s.Groups.List()) } if tt.partial.StashIDs != nil { assert.ElementsMatch(tt.want.StashIDs.List(), got.StashIDs.List()) @@ -1467,9 +1467,9 @@ func Test_sceneQueryBuilder_Find(t *testing.T) { false, }, { - "with movies", - sceneIDs[sceneIdxWithMovie], - makeSceneWithID(sceneIdxWithMovie), + "with groups", + sceneIDs[sceneIdxWithGroup], + makeSceneWithID(sceneIdxWithGroup), false, }, } @@ -1527,13 +1527,13 @@ func Test_sceneQueryBuilder_FindMany(t *testing.T) { sceneIDs[sceneIdxWithGallery], sceneIDs[sceneIdxWithTwoPerformers], sceneIDs[sceneIdxWithTwoTags], - sceneIDs[sceneIdxWithMovie], + sceneIDs[sceneIdxWithGroup], }, []*models.Scene{ makeSceneWithID(sceneIdxWithGallery), makeSceneWithID(sceneIdxWithTwoPerformers), makeSceneWithID(sceneIdxWithTwoTags), - makeSceneWithID(sceneIdxWithMovie), + makeSceneWithID(sceneIdxWithGroup), }, false, }, @@ -1608,9 +1608,9 @@ func Test_sceneQueryBuilder_FindByChecksum(t *testing.T) { false, }, { - "with movies", - getChecksum(sceneIdxWithMovie), - []*models.Scene{makeSceneWithID(sceneIdxWithMovie)}, + "with groups", + getChecksum(sceneIdxWithGroup), + []*models.Scene{makeSceneWithID(sceneIdxWithGroup)}, false, }, } @@ -1678,9 +1678,9 @@ func Test_sceneQueryBuilder_FindByOSHash(t *testing.T) { false, }, { - "with movies", - getOSHash(sceneIdxWithMovie), - []*models.Scene{makeSceneWithID(sceneIdxWithMovie)}, + "with groups", + getOSHash(sceneIdxWithGroup), + []*models.Scene{makeSceneWithID(sceneIdxWithGroup)}, false, }, } @@ -1749,9 +1749,9 @@ func Test_sceneQueryBuilder_FindByPath(t *testing.T) { false, }, { - "with movies", - getPath(sceneIdxWithMovie), - []*models.Scene{makeSceneWithID(sceneIdxWithMovie)}, + "with groups", + getPath(sceneIdxWithGroup), + []*models.Scene{makeSceneWithID(sceneIdxWithGroup)}, false, }, } @@ -2107,7 +2107,7 @@ func TestSceneQuery(t *testing.T) { }, }, []int{sceneIdxWithGallery}, - []int{sceneIdxWithMovie}, + []int{sceneIdxWithGroup}, false, }, { @@ -2120,7 +2120,7 @@ func TestSceneQuery(t *testing.T) { }, }, []int{sceneIdxWithGallery}, - []int{sceneIdxWithMovie}, + []int{sceneIdxWithGroup}, false, }, // { @@ -2133,7 +2133,7 @@ func TestSceneQuery(t *testing.T) { // }, // }, // []int{sceneIdxWithGallery}, - // []int{sceneIdxWithMovie}, + // []int{sceneIdxWithGroup}, // false, // }, { @@ -3108,7 +3108,7 @@ func TestSceneQueryIsMissingMovies(t *testing.T) { IsMissing: &isMissing, } - q := getSceneStringValue(sceneIdxWithMovie, titleField) + q := getSceneStringValue(sceneIdxWithGroup, titleField) findFilter := models.FindFilterType{ Q: &q, } @@ -3122,7 +3122,7 @@ func TestSceneQueryIsMissingMovies(t *testing.T) { // ensure non of the ids equal the one with movies for _, scene := range scenes { - assert.NotEqual(t, sceneIDs[sceneIdxWithMovie], scene.ID) + assert.NotEqual(t, sceneIDs[sceneIdxWithGroup], scene.ID) } return nil @@ -3878,7 +3878,7 @@ func TestSceneQueryMovies(t *testing.T) { sqb := db.Scene movieCriterion := models.MultiCriterionInput{ Value: []string{ - strconv.Itoa(movieIDs[movieIdxWithScene]), + strconv.Itoa(groupIDs[groupIdxWithScene]), }, Modifier: models.CriterionModifierIncludes, } @@ -3892,16 +3892,16 @@ func TestSceneQueryMovies(t *testing.T) { assert.Len(t, scenes, 1) // ensure id is correct - assert.Equal(t, sceneIDs[sceneIdxWithMovie], scenes[0].ID) + assert.Equal(t, sceneIDs[sceneIdxWithGroup], scenes[0].ID) movieCriterion = models.MultiCriterionInput{ Value: []string{ - strconv.Itoa(movieIDs[movieIdxWithScene]), + strconv.Itoa(groupIDs[groupIdxWithScene]), }, Modifier: models.CriterionModifierExcludes, } - q := getSceneStringValue(sceneIdxWithMovie, titleField) + q := getSceneStringValue(sceneIdxWithGroup, titleField) findFilter := models.FindFilterType{ Q: &q, } @@ -4212,22 +4212,22 @@ func TestSceneCountByTagID(t *testing.T) { }) } -func TestSceneCountByMovieID(t *testing.T) { +func TestSceneCountByGroupID(t *testing.T) { withTxn(func(ctx context.Context) error { sqb := db.Scene - sceneCount, err := sqb.CountByMovieID(ctx, movieIDs[movieIdxWithScene]) + sceneCount, err := sqb.CountByGroupID(ctx, groupIDs[groupIdxWithScene]) if err != nil { - t.Errorf("error calling CountByMovieID: %s", err.Error()) + t.Errorf("error calling CountByGroupID: %s", err.Error()) } assert.Equal(t, 1, sceneCount) - sceneCount, err = sqb.CountByMovieID(ctx, 0) + sceneCount, err = sqb.CountByGroupID(ctx, 0) if err != nil { - t.Errorf("error calling CountByMovieID: %s", err.Error()) + t.Errorf("error calling CountByGroupID: %s", err.Error()) } assert.Equal(t, 0, sceneCount) @@ -4264,16 +4264,16 @@ func TestFindByMovieID(t *testing.T) { withTxn(func(ctx context.Context) error { sqb := db.Scene - scenes, err := sqb.FindByMovieID(ctx, movieIDs[movieIdxWithScene]) + scenes, err := sqb.FindByGroupID(ctx, groupIDs[groupIdxWithScene]) if err != nil { t.Errorf("error calling FindByMovieID: %s", err.Error()) } assert.Len(t, scenes, 1) - assert.Equal(t, sceneIDs[sceneIdxWithMovie], scenes[0].ID) + assert.Equal(t, sceneIDs[sceneIdxWithGroup], scenes[0].ID) - scenes, err = sqb.FindByMovieID(ctx, 0) + scenes, err = sqb.FindByGroupID(ctx, 0) if err != nil { t.Errorf("error calling FindByMovieID: %s", err.Error()) diff --git a/pkg/sqlite/setup_test.go b/pkg/sqlite/setup_test.go index b8ce23cd6c0..aa6af73c4c9 100644 --- a/pkg/sqlite/setup_test.go +++ b/pkg/sqlite/setup_test.go @@ -54,7 +54,7 @@ const ( ) const ( - sceneIdxWithMovie = iota + sceneIdxWithGroup = iota sceneIdxWithGallery sceneIdxWithPerformer sceneIdx1WithPerformer @@ -148,17 +148,17 @@ const ( ) const ( - movieIdxWithScene = iota - movieIdxWithStudio - movieIdxWithTag - movieIdxWithTwoTags - movieIdxWithThreeTags - // movies with dup names start from the end - // create 7 more basic movies (can remove this if we add more indexes) - movieIdxWithDupName = movieIdxWithStudio + 7 - - moviesNameCase = movieIdxWithDupName - moviesNameNoCase = 1 + groupIdxWithScene = iota + groupIdxWithStudio + groupIdxWithTag + groupIdxWithTwoTags + groupIdxWithThreeTags + // groups with dup names start from the end + // create 7 more basic groups (can remove this if we add more indexes) + groupIdxWithDupName = groupIdxWithStudio + 7 + + groupsNameCase = groupIdxWithDupName + groupsNameNoCase = 1 ) const ( @@ -220,10 +220,10 @@ const ( tagIdxWithParentAndChild tagIdxWithGrandParent tagIdx2WithMarkers - tagIdxWithMovie - tagIdx1WithMovie - tagIdx2WithMovie - tagIdx3WithMovie + tagIdxWithGroup + tagIdx1WithGroup + tagIdx2WithGroup + tagIdx3WithGroup // new indexes above // tags with dup names start from the end tagIdx1WithDupName @@ -238,7 +238,7 @@ const ( const ( studioIdxWithScene = iota studioIdxWithTwoScenes - studioIdxWithMovie + studioIdxWithGroup studioIdxWithChildStudio studioIdxWithParentStudio studioIdxWithImage @@ -305,7 +305,7 @@ var ( sceneIDs []int imageIDs []int performerIDs []int - movieIDs []int + groupIDs []int galleryIDs []int tagIDs []int studioIDs []int @@ -316,7 +316,7 @@ var ( tagNames []string studioNames []string - movieNames []string + groupNames []string performerNames []string ) @@ -389,8 +389,8 @@ var ( sceneIdxWithGallery: {galleryIdxWithScene}, } - sceneMovies = linkMap{ - sceneIdxWithMovie: {movieIdxWithScene}, + sceneGroups = linkMap{ + sceneIdxWithGroup: {groupIdxWithScene}, } sceneStudios = map[int]int{ @@ -496,14 +496,14 @@ var ( ) var ( - movieStudioLinks = [][2]int{ - {movieIdxWithStudio, studioIdxWithMovie}, + groupStudioLinks = [][2]int{ + {groupIdxWithStudio, studioIdxWithGroup}, } - movieTags = linkMap{ - movieIdxWithTag: {tagIdxWithMovie}, - movieIdxWithTwoTags: {tagIdx1WithMovie, tagIdx2WithMovie}, - movieIdxWithThreeTags: {tagIdx1WithMovie, tagIdx2WithMovie, tagIdx3WithMovie}, + groupTags = linkMap{ + groupIdxWithTag: {tagIdxWithGroup}, + groupIdxWithTwoTags: {tagIdx1WithGroup, tagIdx2WithGroup}, + groupIdxWithThreeTags: {tagIdx1WithGroup, tagIdx2WithGroup, tagIdx3WithGroup}, } ) @@ -653,8 +653,8 @@ func populateDB() error { return fmt.Errorf("error creating tags: %s", err.Error()) } - if err := createMovies(ctx, db.Movie, moviesNameCase, moviesNameNoCase); err != nil { - return fmt.Errorf("error creating movies: %s", err.Error()) + if err := createGroups(ctx, db.Group, groupsNameCase, groupsNameNoCase); err != nil { + return fmt.Errorf("error creating groups: %s", err.Error()) } if err := createPerformers(ctx, performersNameCase, performersNameNoCase); err != nil { @@ -685,8 +685,8 @@ func populateDB() error { return fmt.Errorf("error creating saved filters: %s", err.Error()) } - if err := linkMovieStudios(ctx, db.Movie); err != nil { - return fmt.Errorf("error linking movie studios: %s", err.Error()) + if err := linkGroupStudios(ctx, db.Group); err != nil { + return fmt.Errorf("error linking group studios: %s", err.Error()) } if err := linkStudiosParent(ctx); err != nil { @@ -1069,12 +1069,12 @@ func makeScene(i int) *models.Scene { pids := indexesToIDs(performerIDs, scenePerformers[i]) tids := indexesToIDs(tagIDs, sceneTags[i]) - mids := indexesToIDs(movieIDs, sceneMovies[i]) + mids := indexesToIDs(groupIDs, sceneGroups[i]) - movies := make([]models.MoviesScenes, len(mids)) + groups := make([]models.GroupsScenes, len(mids)) for i, m := range mids { - movies[i] = models.MoviesScenes{ - MovieID: m, + groups[i] = models.GroupsScenes{ + GroupID: m, } } @@ -1092,7 +1092,7 @@ func makeScene(i int) *models.Scene { GalleryIDs: models.NewRelatedIDs(gids), PerformerIDs: models.NewRelatedIDs(pids), TagIDs: models.NewRelatedIDs(tids), - Movies: models.NewRelatedMovies(movies), + Groups: models.NewRelatedGroups(groups), StashIDs: models.NewRelatedStashIDs([]models.StashID{ sceneStashID(i), }), @@ -1320,18 +1320,18 @@ func createGalleries(ctx context.Context, n int) error { return nil } -func getMovieStringValue(index int, field string) string { - return getPrefixedStringValue("movie", index, field) +func getGroupStringValue(index int, field string) string { + return getPrefixedStringValue("group", index, field) } -func getMovieNullStringValue(index int, field string) string { - ret := getPrefixedNullStringValue("movie", index, field) +func getGroupNullStringValue(index int, field string) string { + ret := getPrefixedNullStringValue("group", index, field) return ret.String } -func getMovieEmptyString(index int, field string) string { - v := getPrefixedNullStringValue("movie", index, field) +func getGroupEmptyString(index int, field string) string { + v := getPrefixedNullStringValue("group", index, field) if !v.Valid { return "" } @@ -1339,8 +1339,8 @@ func getMovieEmptyString(index int, field string) string { return v.String } -// createMoviees creates n movies with plain Name and o movies with camel cased NaMe included -func createMovies(ctx context.Context, mqb models.MovieReaderWriter, n int, o int) error { +// createGroups creates n groups with plain Name and o groups with camel cased NaMe included +func createGroups(ctx context.Context, mqb models.GroupReaderWriter, n int, o int) error { const namePlain = "Name" const nameNoCase = "NaMe" @@ -1348,31 +1348,31 @@ func createMovies(ctx context.Context, mqb models.MovieReaderWriter, n int, o in index := i name := namePlain - tids := indexesToIDs(tagIDs, movieTags[i]) + tids := indexesToIDs(tagIDs, groupTags[i]) if i >= n { // i=n movies get dup names if case is not checked + name = nameNoCase // i>=n groups get dup names if case is not checked index = n + o - (i + 1) // for the name to be the same the number (index) must be the same also } // so count backwards to 0 as needed - // movies [ i ] and [ n + o - i - 1 ] should have similar names with only the Name!=NaMe part different + // groups [ i ] and [ n + o - i - 1 ] should have similar names with only the Name!=NaMe part different - name = getMovieStringValue(index, name) - movie := models.Movie{ + name = getGroupStringValue(index, name) + group := models.Group{ Name: name, URLs: models.NewRelatedStrings([]string{ - getMovieEmptyString(i, urlField), + getGroupEmptyString(i, urlField), }), TagIDs: models.NewRelatedIDs(tids), } - err := mqb.Create(ctx, &movie) + err := mqb.Create(ctx, &group) if err != nil { - return fmt.Errorf("Error creating movie [%d] %v+: %s", i, movie, err.Error()) + return fmt.Errorf("Error creating group [%d] %v+: %s", i, group, err.Error()) } - movieIDs = append(movieIDs, movie.ID) - movieNames = append(movieNames, movie.Name) + groupIDs = append(groupIDs, group.ID) + groupNames = append(groupNames, group.Name) } return nil @@ -1709,7 +1709,7 @@ func createStudios(ctx context.Context, n int, o int) error { TagIDs: models.NewRelatedIDs(tids), } // only add aliases for some scenes - if i == studioIdxWithMovie || i%5 == 0 { + if i == studioIdxWithGroup || i%5 == 0 { alias := getStudioStringValue(i, "Alias") studio.Aliases = models.NewRelatedStrings([]string{alias}) } @@ -1842,12 +1842,12 @@ func doLinks(links [][2]int, fn func(idx1, idx2 int) error) error { return nil } -func linkMovieStudios(ctx context.Context, mqb models.MovieWriter) error { - return doLinks(movieStudioLinks, func(movieIndex, studioIndex int) error { - movie := models.MoviePartial{ +func linkGroupStudios(ctx context.Context, mqb models.GroupWriter) error { + return doLinks(groupStudioLinks, func(groupIndex, studioIndex int) error { + group := models.GroupPartial{ StudioID: models.NewOptionalInt(studioIDs[studioIndex]), } - _, err := mqb.UpdatePartial(ctx, movieIDs[movieIndex], movie) + _, err := mqb.UpdatePartial(ctx, groupIDs[groupIndex], group) return err }) diff --git a/pkg/sqlite/studio_test.go b/pkg/sqlite/studio_test.go index 627129f0d1e..a61dadc245f 100644 --- a/pkg/sqlite/studio_test.go +++ b/pkg/sqlite/studio_test.go @@ -216,7 +216,7 @@ func TestStudioQueryForAutoTag(t *testing.T) { withTxn(func(ctx context.Context) error { tqb := db.Studio - name := studioNames[studioIdxWithMovie] // find a studio by name + name := studioNames[studioIdxWithGroup] // find a studio by name studios, err := tqb.QueryForAutoTag(ctx, []string{name}) @@ -225,16 +225,16 @@ func TestStudioQueryForAutoTag(t *testing.T) { } assert.Len(t, studios, 1) - assert.Equal(t, strings.ToLower(studioNames[studioIdxWithMovie]), strings.ToLower(studios[0].Name)) + assert.Equal(t, strings.ToLower(studioNames[studioIdxWithGroup]), strings.ToLower(studios[0].Name)) - name = getStudioStringValue(studioIdxWithMovie, "Alias") + name = getStudioStringValue(studioIdxWithGroup, "Alias") studios, err = tqb.QueryForAutoTag(ctx, []string{name}) if err != nil { t.Errorf("Error finding studios: %s", err.Error()) } if assert.Len(t, studios, 1) { - assert.Equal(t, studioIDs[studioIdxWithMovie], studios[0].ID) + assert.Equal(t, studioIDs[studioIdxWithGroup], studios[0].ID) } return nil }) @@ -911,7 +911,7 @@ func TestStudioQueryName(t *testing.T) { } func TestStudioQueryAlias(t *testing.T) { - const studioIdx = studioIdxWithMovie + const studioIdx = studioIdxWithGroup studioName := getStudioStringValue(studioIdx, "Alias") aliasCriterion := &models.StringCriterionInput{ diff --git a/pkg/sqlite/table.go b/pkg/sqlite/table.go index 6b6ed9417af..65716cff35e 100644 --- a/pkg/sqlite/table.go +++ b/pkg/sqlite/table.go @@ -588,30 +588,30 @@ func (t *orderedValueTable[T]) modifyJoins(ctx context.Context, id int, v []T, m return nil } -type scenesMoviesTable struct { +type scenesGroupsTable struct { table } -type moviesScenesRow struct { +type groupsScenesRow struct { SceneID null.Int `db:"scene_id"` - MovieID null.Int `db:"movie_id"` + GroupID null.Int `db:"movie_id"` SceneIndex null.Int `db:"scene_index"` } -func (r moviesScenesRow) resolve(sceneID int) models.MoviesScenes { - return models.MoviesScenes{ - MovieID: int(r.MovieID.Int64), +func (r groupsScenesRow) resolve(sceneID int) models.GroupsScenes { + return models.GroupsScenes{ + GroupID: int(r.GroupID.Int64), SceneIndex: nullIntPtr(r.SceneIndex), } } -func (t *scenesMoviesTable) get(ctx context.Context, id int) ([]models.MoviesScenes, error) { +func (t *scenesGroupsTable) get(ctx context.Context, id int) ([]models.GroupsScenes, error) { q := dialect.Select("movie_id", "scene_index").From(t.table.table).Where(t.idColumn.Eq(id)) const single = false - var ret []models.MoviesScenes + var ret []models.GroupsScenes if err := queryFunc(ctx, q, single, func(rows *sqlx.Rows) error { - var v moviesScenesRow + var v groupsScenesRow if err := rows.StructScan(&v); err != nil { return err } @@ -620,15 +620,15 @@ func (t *scenesMoviesTable) get(ctx context.Context, id int) ([]models.MoviesSce return nil }); err != nil { - return nil, fmt.Errorf("getting scene movies from %s: %w", t.table.table.GetTable(), err) + return nil, fmt.Errorf("getting scene groups from %s: %w", t.table.table.GetTable(), err) } return ret, nil } -func (t *scenesMoviesTable) insertJoin(ctx context.Context, id int, v models.MoviesScenes) (sql.Result, error) { +func (t *scenesGroupsTable) insertJoin(ctx context.Context, id int, v models.GroupsScenes) (sql.Result, error) { q := dialect.Insert(t.table.table).Cols(t.idColumn.GetCol(), "movie_id", "scene_index").Vals( - goqu.Vals{id, v.MovieID, intFromPtr(v.SceneIndex)}, + goqu.Vals{id, v.GroupID, intFromPtr(v.SceneIndex)}, ) ret, err := exec(ctx, q) if err != nil { @@ -638,7 +638,7 @@ func (t *scenesMoviesTable) insertJoin(ctx context.Context, id int, v models.Mov return ret, nil } -func (t *scenesMoviesTable) insertJoins(ctx context.Context, id int, v []models.MoviesScenes) error { +func (t *scenesGroupsTable) insertJoins(ctx context.Context, id int, v []models.GroupsScenes) error { for _, fk := range v { if _, err := t.insertJoin(ctx, id, fk); err != nil { return err @@ -648,7 +648,7 @@ func (t *scenesMoviesTable) insertJoins(ctx context.Context, id int, v []models. return nil } -func (t *scenesMoviesTable) replaceJoins(ctx context.Context, id int, v []models.MoviesScenes) error { +func (t *scenesGroupsTable) replaceJoins(ctx context.Context, id int, v []models.GroupsScenes) error { if err := t.destroy(ctx, []int{id}); err != nil { return err } @@ -656,7 +656,7 @@ func (t *scenesMoviesTable) replaceJoins(ctx context.Context, id int, v []models return t.insertJoins(ctx, id, v) } -func (t *scenesMoviesTable) addJoins(ctx context.Context, id int, v []models.MoviesScenes) error { +func (t *scenesGroupsTable) addJoins(ctx context.Context, id int, v []models.GroupsScenes) error { // get existing foreign keys fks, err := t.get(ctx, id) if err != nil { @@ -664,12 +664,12 @@ func (t *scenesMoviesTable) addJoins(ctx context.Context, id int, v []models.Mov } // only add values that are not already present - var filtered []models.MoviesScenes + var filtered []models.GroupsScenes for _, vv := range v { found := false for _, e := range fks { - if vv.MovieID == e.MovieID { + if vv.GroupID == e.GroupID { found = true break } @@ -682,11 +682,11 @@ func (t *scenesMoviesTable) addJoins(ctx context.Context, id int, v []models.Mov return t.insertJoins(ctx, id, filtered) } -func (t *scenesMoviesTable) destroyJoins(ctx context.Context, id int, v []models.MoviesScenes) error { +func (t *scenesGroupsTable) destroyJoins(ctx context.Context, id int, v []models.GroupsScenes) error { for _, vv := range v { q := dialect.Delete(t.table.table).Where( t.idColumn.Eq(id), - t.table.table.Col("movie_id").Eq(vv.MovieID), + t.table.table.Col("movie_id").Eq(vv.GroupID), ) if _, err := exec(ctx, q); err != nil { @@ -697,7 +697,7 @@ func (t *scenesMoviesTable) destroyJoins(ctx context.Context, id int, v []models return nil } -func (t *scenesMoviesTable) modifyJoins(ctx context.Context, id int, v []models.MoviesScenes, mode models.RelationshipUpdateMode) error { +func (t *scenesGroupsTable) modifyJoins(ctx context.Context, id int, v []models.GroupsScenes, mode models.RelationshipUpdateMode) error { switch mode { case models.RelationshipUpdateModeSet: return t.replaceJoins(ctx, id, v) diff --git a/pkg/sqlite/tables.go b/pkg/sqlite/tables.go index 2f500639e36..7f93c814852 100644 --- a/pkg/sqlite/tables.go +++ b/pkg/sqlite/tables.go @@ -25,7 +25,7 @@ var ( scenesTagsJoinTable = goqu.T(scenesTagsTable) scenesPerformersJoinTable = goqu.T(performersScenesTable) scenesStashIDsJoinTable = goqu.T("scene_stash_ids") - scenesMoviesJoinTable = goqu.T(moviesScenesTable) + scenesGroupsJoinTable = goqu.T(groupsScenesTable) scenesURLsJoinTable = goqu.T(scenesURLsTable) performersAliasesJoinTable = goqu.T(performersAliasesTable) @@ -37,8 +37,8 @@ var ( studiosTagsJoinTable = goqu.T(studiosTagsTable) studiosStashIDsJoinTable = goqu.T("studio_stash_ids") - moviesURLsJoinTable = goqu.T(movieURLsTable) - moviesTagsJoinTable = goqu.T(moviesTagsTable) + groupsURLsJoinTable = goqu.T(groupURLsTable) + groupsTagsJoinTable = goqu.T(groupsTagsTable) tagsAliasesJoinTable = goqu.T(tagAliasesTable) tagRelationsJoinTable = goqu.T(tagRelationsTable) @@ -184,10 +184,10 @@ var ( }, } - scenesMoviesTableMgr = &scenesMoviesTable{ + scenesGroupsTableMgr = &scenesGroupsTable{ table: table{ - table: scenesMoviesJoinTable, - idColumn: scenesMoviesJoinTable.Col(sceneIDColumn), + table: scenesGroupsJoinTable, + idColumn: scenesGroupsJoinTable.Col(sceneIDColumn), }, } @@ -337,25 +337,25 @@ var ( ) var ( - movieTableMgr = &table{ - table: goqu.T(movieTable), - idColumn: goqu.T(movieTable).Col(idColumn), + groupTableMgr = &table{ + table: goqu.T(groupTable), + idColumn: goqu.T(groupTable).Col(idColumn), } - moviesURLsTableMgr = &orderedValueTable[string]{ + groupsURLsTableMgr = &orderedValueTable[string]{ table: table{ - table: moviesURLsJoinTable, - idColumn: moviesURLsJoinTable.Col(movieIDColumn), + table: groupsURLsJoinTable, + idColumn: groupsURLsJoinTable.Col(groupIDColumn), }, - valueColumn: moviesURLsJoinTable.Col(movieURLColumn), + valueColumn: groupsURLsJoinTable.Col(groupURLColumn), } - moviesTagsTableMgr = &joinTable{ + groupsTagsTableMgr = &joinTable{ table: table{ - table: moviesTagsJoinTable, - idColumn: moviesTagsJoinTable.Col(movieIDColumn), + table: groupsTagsJoinTable, + idColumn: groupsTagsJoinTable.Col(groupIDColumn), }, - fkColumn: moviesTagsJoinTable.Col(tagIDColumn), + fkColumn: groupsTagsJoinTable.Col(tagIDColumn), foreignTable: tagTableMgr, orderBy: tagTableMgr.table.Col("name").Asc(), } diff --git a/pkg/sqlite/tag.go b/pkg/sqlite/tag.go index 69dc086ea3f..faab05c5e19 100644 --- a/pkg/sqlite/tag.go +++ b/pkg/sqlite/tag.go @@ -424,7 +424,7 @@ func (qb *TagStore) FindByGalleryID(ctx context.Context, galleryID int) ([]*mode return qb.queryTags(ctx, query, args) } -func (qb *TagStore) FindByMovieID(ctx context.Context, movieID int) ([]*models.Tag, error) { +func (qb *TagStore) FindByGroupID(ctx context.Context, movieID int) ([]*models.Tag, error) { query := ` SELECT tags.* FROM tags LEFT JOIN movies_tags as movies_join on movies_join.tag_id = tags.id @@ -637,6 +637,7 @@ func (qb *TagStore) Query(ctx context.Context, tagFilter *models.TagFilterType, var tagSortOptions = sortOptions{ "created_at", "galleries_count", + "groups_count", "id", "images_count", "movies_count", @@ -684,7 +685,7 @@ func (qb *TagStore) getTagSort(query *queryBuilder, findFilter *models.FindFilte case "studios_count": sortQuery += getCountSort(tagTable, studiosTagsTable, tagIDColumn, direction) case "movies_count", "groups_count": - sortQuery += getCountSort(tagTable, moviesTagsTable, tagIDColumn, direction) + sortQuery += getCountSort(tagTable, groupsTagsTable, tagIDColumn, direction) default: sortQuery += getSort(sort, direction, "tags") } diff --git a/pkg/sqlite/tag_filter.go b/pkg/sqlite/tag_filter.go index c2fd1723fec..8daee02a827 100644 --- a/pkg/sqlite/tag_filter.go +++ b/pkg/sqlite/tag_filter.go @@ -190,11 +190,11 @@ func (qb *tagFilterHandler) studioCountCriterionHandler(studioCount *models.IntC } } -func (qb *tagFilterHandler) groupCountCriterionHandler(movieCount *models.IntCriterionInput) criterionHandlerFunc { +func (qb *tagFilterHandler) groupCountCriterionHandler(groupCount *models.IntCriterionInput) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { - if movieCount != nil { + if groupCount != nil { f.addLeftJoin("movies_tags", "", "movies_tags.tag_id = tags.id") - clause, args := getIntCriterionWhereClause("count(distinct movies_tags.movie_id)", *movieCount) + clause, args := getIntCriterionWhereClause("count(distinct movies_tags.movie_id)", *groupCount) f.addHaving(clause, args...) } diff --git a/pkg/sqlite/tag_test.go b/pkg/sqlite/tag_test.go index 099f8b91221..f673567f82e 100644 --- a/pkg/sqlite/tag_test.go +++ b/pkg/sqlite/tag_test.go @@ -42,22 +42,22 @@ func TestMarkerFindBySceneMarkerID(t *testing.T) { }) } -func TestTagFindByMovieID(t *testing.T) { +func TestTagFindByGroupID(t *testing.T) { withTxn(func(ctx context.Context) error { tqb := db.Tag - movieID := movieIDs[movieIdxWithTag] + groupID := groupIDs[groupIdxWithTag] - tags, err := tqb.FindByMovieID(ctx, movieID) + tags, err := tqb.FindByGroupID(ctx, groupID) if err != nil { t.Errorf("Error finding tags: %s", err.Error()) } assert.Len(t, tags, 1) - assert.Equal(t, tagIDs[tagIdxWithMovie], tags[0].ID) + assert.Equal(t, tagIDs[tagIdxWithGroup], tags[0].ID) - tags, err = tqb.FindByMovieID(ctx, 0) + tags, err = tqb.FindByGroupID(ctx, 0) if err != nil { t.Errorf("Error finding tags: %s", err.Error()) @@ -236,7 +236,7 @@ func TestTagQuerySort(t *testing.T) { sortBy = "movies_count" tags = queryTags(ctx, t, sqb, nil, findFilter) - assert.Equal(tagIDs[tagIdx1WithMovie], tags[0].ID) + assert.Equal(tagIDs[tagIdx1WithGroup], tags[0].ID) return nil }) diff --git a/pkg/sqlite/transaction.go b/pkg/sqlite/transaction.go index eda5b6b8d2d..705c61e0789 100644 --- a/pkg/sqlite/transaction.go +++ b/pkg/sqlite/transaction.go @@ -132,7 +132,7 @@ func (db *Database) Repository() models.Repository { Gallery: db.Gallery, GalleryChapter: db.GalleryChapter, Image: db.Image, - Movie: db.Movie, + Group: db.Group, Performer: db.Performer, Scene: db.Scene, SceneMarker: db.SceneMarker, diff --git a/ui/v2.5/src/docs/en/Manual/ScraperDevelopment.md b/ui/v2.5/src/docs/en/Manual/ScraperDevelopment.md index c74b821cf5d..caa3d41dc80 100644 --- a/ui/v2.5/src/docs/en/Manual/ScraperDevelopment.md +++ b/ui/v2.5/src/docs/en/Manual/ScraperDevelopment.md @@ -20,7 +20,7 @@ sceneByFragment: sceneByURL: -movieByURL: +groupByURL: galleryByFragment: @@ -42,7 +42,7 @@ The scraping types and their required fields are outlined in the following table | Scraper in query dropdown button in Scene Edit page | Valid `sceneByName` and `sceneByQueryFragment` configurations. | | Scraper in `Scrape...` dropdown button in Scene Edit page | Valid `sceneByFragment` configuration. | | Scrape scene from URL | Valid `sceneByURL` configuration with matching URL. | -| Scrape movie from URL | Valid `movieByURL` configuration with matching URL. | +| Scrape group from URL | Valid `groupByURL` configuration with matching URL. **Note:** `movieByURL` is also supported but is deprecated. | | Scraper in `Scrape...` dropdown button in Gallery Edit page | Valid `galleryByFragment` configuration. | | Scrape gallery from URL | Valid `galleryByURL` configuration with matching URL. | @@ -78,7 +78,7 @@ The script is sent input and expects output based on the scraping type, as detai | `sceneByName` | `{"name": ""}` | Array of JSON-encoded scene fragments | | `sceneByQueryFragment`, `sceneByFragment` | JSON-encoded scene fragment | JSON-encoded scene fragment | | `sceneByURL` | `{"url": ""}` | JSON-encoded scene fragment | -| `movieByURL` | `{"url": ""}` | JSON-encoded movie fragment | +| `groupByURL` | `{"url": ""}` | JSON-encoded group fragment | | `galleryByFragment` | JSON-encoded gallery fragment | JSON-encoded gallery fragment | | `galleryByURL` | `{"url": ""}` | JSON-encoded gallery fragment | @@ -225,7 +225,7 @@ sceneByFragment: The above configuration would scrape from the value of `queryURL`, replacing `{filename}` with the base filename of the scene, after it has been manipulated by the regex replacements. -### scrapeXPath and scrapeJson use with `ByURL` +### scrapeXPath and scrapeJson use with `ByURL` For `sceneByURL`, `performerByURL`, `galleryByURL` the `queryURL` can also be present if we want to use `queryURLReplace`. The functionality is the same as `sceneByFragment`, the only placeholder field available though is the `url`: * `{url}` - the url of the scene/performer/gallery @@ -271,9 +271,9 @@ Likewise, the top-level `jsonScrapers` field contains json scraping configuratio Collectively, these configurations are known as mapped scraping configurations. -A mapped scraping configuration may contain a `common` field, and must contain `performer`, `scene`, `movie` or `gallery` depending on the scraping type it is configured for. +A mapped scraping configuration may contain a `common` field, and must contain `performer`, `scene`, `group` or `gallery` depending on the scraping type it is configured for. -Within the `performer`/`scene`/`movie`/`gallery` field are key/value pairs corresponding to the [golang fields](/help/ScraperDevelopment.md#object-fields) on the performer/scene object. These fields are case-sensitive. +Within the `performer`/`scene`/`group`/`gallery` field are key/value pairs corresponding to the [golang fields](/help/ScraperDevelopment.md#object-fields) on the performer/scene object. These fields are case-sensitive. The values of these may be either a simple selector value, which tells the system where to get the value of the field from, or a more advanced configuration (see below). For example, for an xpath configuration: @@ -820,7 +820,7 @@ URL Date Image Studio (see Studio Fields) -Movies (see Movie Fields) +Groups (see Group Fields) Tags (see Tag fields) Performers (list of Performer fields) ``` @@ -835,7 +835,7 @@ URL Name ``` -### Movie +### Group ``` Name Aliases From ec23b26c608eac19a34281aced1a75f9ff67df77 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Thu, 4 Jul 2024 10:52:04 +1000 Subject: [PATCH 049/103] Adds mutex protection around dms.Eventing (#5042) It's possible for concurrent map read/write panic in the Eventing.Subscribe function. --- internal/dlna/dms.go | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/internal/dlna/dms.go b/internal/dlna/dms.go index 8c5074d30ef..571526fb890 100644 --- a/internal/dlna/dms.go +++ b/internal/dlna/dms.go @@ -40,6 +40,7 @@ import ( "path" "strconv" "strings" + "sync" "time" "github.com/anacrolix/dms/soap" @@ -274,6 +275,8 @@ type Server struct { sceneServer sceneServer ipWhitelistManager *ipWhitelistManager VideoSortOrder string + + subscribeLock sync.Mutex } // UPnP SOAP service. @@ -537,13 +540,14 @@ func (me *Server) contentDirectoryEventSubHandler(w http.ResponseWriter, r *http // The following code is a work in progress. It partially implements // the spec on eventing but hasn't been completed as I have nothing to // test it with. - service := me.services["ContentDirectory"] switch { case r.Method == "SUBSCRIBE" && r.Header.Get("SID") == "": urls := upnp.ParseCallbackURLs(r.Header.Get("CALLBACK")) var timeout int _, _ = fmt.Sscanf(r.Header.Get("TIMEOUT"), "Second-%d", &timeout) - sid, timeout, _ := service.Subscribe(urls, timeout) + + sid, timeout, _ := me.subscribe(urls, timeout) + w.Header()["SID"] = []string{sid} w.Header()["TIMEOUT"] = []string{fmt.Sprintf("Second-%d", timeout)} // TODO: Shouldn't have to do this to get headers logged. @@ -559,6 +563,16 @@ func (me *Server) contentDirectoryEventSubHandler(w http.ResponseWriter, r *http } } +// wrapper around service.Subscribe which requires concurrency protection +// TODO - this should be addressed upstream +func (me *Server) subscribe(urls []*url.URL, timeout int) (sid string, actualTimeout int, err error) { + me.subscribeLock.Lock() + defer me.subscribeLock.Unlock() + + service := me.services["ContentDirectory"] + return service.Subscribe(urls, timeout) +} + func (me *Server) initMux(mux *http.ServeMux) { mux.HandleFunc("/", func(resp http.ResponseWriter, req *http.Request) { resp.Header().Set("content-type", "text/html") From a0b082a36deb16102d774860f55f930857fbcad2 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Thu, 4 Jul 2024 10:52:46 +1000 Subject: [PATCH 050/103] Various detail page refactoring (#5037) * Refactor repeated code into BackgroundImage * Move BackgroundImage into Details folder * Refactor performer tabs * Refactor studio tabs * Refactor tag tabs * Refactor repeated code into DetailTitle * Refactor repeated collapse button code into component * Reuse FavoriteIcon in details pages * Refactor performer urls into component * Refactor alias list into component * Refactor repeated image code into HeaderImage and LightboxLink components * Replace render functions with inline conditional rendering * Support new twitter hostname --- .../components/Groups/GroupDetails/Group.tsx | 344 ++++------ .../components/Performers/PerformerCard.tsx | 2 + .../Performers/PerformerDetails/Performer.tsx | 590 +++++++----------- .../src/components/Shared/CollapseButton.tsx | 19 + .../Shared/DetailsPage/AliasList.tsx | 13 + .../Shared/DetailsPage/BackgroundImage.tsx | 24 + .../Shared/DetailsPage/DetailTitle.tsx | 21 + .../Shared/DetailsPage/HeaderImage.tsx | 21 + .../components/Shared/DetailsPage/Tabs.tsx | 48 ++ .../components/Shared/ExternalLinksButton.tsx | 36 ++ .../src/components/Shared/FavoriteIcon.tsx | 8 +- .../components/Shared/LoadingIndicator.tsx | 2 +- ui/v2.5/src/components/Shared/styles.scss | 5 +- ui/v2.5/src/components/Studios/StudioCard.tsx | 2 + .../Studios/StudioDetails/Studio.tsx | 561 +++++++---------- .../src/components/Tags/TagDetails/Tag.tsx | 549 +++++++--------- ui/v2.5/src/hooks/Lightbox/LightboxLink.tsx | 22 + 17 files changed, 1045 insertions(+), 1222 deletions(-) create mode 100644 ui/v2.5/src/components/Shared/DetailsPage/AliasList.tsx create mode 100644 ui/v2.5/src/components/Shared/DetailsPage/BackgroundImage.tsx create mode 100644 ui/v2.5/src/components/Shared/DetailsPage/DetailTitle.tsx create mode 100644 ui/v2.5/src/components/Shared/DetailsPage/HeaderImage.tsx create mode 100644 ui/v2.5/src/components/Shared/DetailsPage/Tabs.tsx create mode 100644 ui/v2.5/src/hooks/Lightbox/LightboxLink.tsx diff --git a/ui/v2.5/src/components/Groups/GroupDetails/Group.tsx b/ui/v2.5/src/components/Groups/GroupDetails/Group.tsx index 1fddfa967b1..3a93abca0ae 100644 --- a/ui/v2.5/src/components/Groups/GroupDetails/Group.tsx +++ b/ui/v2.5/src/components/Groups/GroupDetails/Group.tsx @@ -1,5 +1,4 @@ import React, { useEffect, useMemo, useState } from "react"; -import { Button } from "react-bootstrap"; import { FormattedMessage, useIntl } from "react-intl"; import { Helmet } from "react-helmet"; import cx from "classnames"; @@ -14,7 +13,6 @@ import { useHistory, RouteComponentProps } from "react-router-dom"; import { DetailsEditNavbar } from "src/components/Shared/DetailsEditNavbar"; import { ErrorMessage } from "src/components/Shared/ErrorMessage"; import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; -import { useLightbox } from "src/hooks/Lightbox/hooks"; import { ModalComponent } from "src/components/Shared/Modal"; import { useToast } from "src/hooks/Toast"; import { GroupScenesPanel } from "./GroupScenesPanel"; @@ -23,12 +21,7 @@ import { GroupDetailsPanel, } from "./GroupDetailsPanel"; import { GroupEditPanel } from "./GroupEditPanel"; -import { - faChevronDown, - faChevronUp, - faTrashAlt, -} from "@fortawesome/free-solid-svg-icons"; -import { Icon } from "src/components/Shared/Icon"; +import { faTrashAlt } from "@fortawesome/free-solid-svg-icons"; import { RatingSystem } from "src/components/Shared/Rating/RatingSystem"; import { ConfigurationContext } from "src/hooks/Config"; import { DetailImage } from "src/components/Shared/DetailImage"; @@ -36,6 +29,12 @@ import { useRatingKeybinds } from "src/hooks/keybinds"; import { useLoadStickyHeader } from "src/hooks/detailsPanel"; import { useScrollToTopOnMount } from "src/hooks/scrollToTop"; import { ExternalLinksButton } from "src/components/Shared/ExternalLinksButton"; +import { BackgroundImage } from "src/components/Shared/DetailsPage/BackgroundImage"; +import { DetailTitle } from "src/components/Shared/DetailsPage/DetailTitle"; +import { ExpandCollapseButton } from "src/components/Shared/CollapseButton"; +import { AliasList } from "src/components/Shared/DetailsPage/AliasList"; +import { HeaderImage } from "src/components/Shared/DetailsPage/HeaderImage"; +import { LightboxLink } from "src/hooks/Lightbox/LightboxLink"; interface IProps { group: GQL.GroupDataFragment; @@ -69,42 +68,64 @@ const GroupPage: React.FC = ({ group }) => { const [backImage, setBackImage] = useState(); const [encodingImage, setEncodingImage] = useState(false); - const defaultImage = - group.front_image_path && group.front_image_path.includes("default=true") - ? true - : false; + const aliases = useMemo( + () => (group.aliases ? [group.aliases] : []), + [group.aliases] + ); + + const isDefaultImage = + group.front_image_path && group.front_image_path.includes("default=true"); const lightboxImages = useMemo(() => { - const covers = [ - ...(group.front_image_path && !defaultImage - ? [ - { - paths: { - thumbnail: group.front_image_path, - image: group.front_image_path, - }, - }, - ] - : []), - ...(group.back_image_path - ? [ - { - paths: { - thumbnail: group.back_image_path, - image: group.back_image_path, - }, - }, - ] - : []), - ]; + const covers = []; + + if (group.front_image_path && !isDefaultImage) { + covers.push({ + paths: { + thumbnail: group.front_image_path, + image: group.front_image_path, + }, + }); + } + + if (group.back_image_path) { + covers.push({ + paths: { + thumbnail: group.back_image_path, + image: group.back_image_path, + }, + }); + } return covers; - }, [group.front_image_path, group.back_image_path, defaultImage]); + }, [group.front_image_path, group.back_image_path, isDefaultImage]); - const index = lightboxImages.length; + const activeFrontImage = useMemo(() => { + let existingImage = group.front_image_path; + if (isEditing) { + if (frontImage === null && existingImage) { + const imageURL = new URL(existingImage); + imageURL.searchParams.set("default", "true"); + return imageURL.toString(); + } else if (frontImage) { + return frontImage; + } + } - const showLightbox = useLightbox({ - images: lightboxImages, - }); + return existingImage; + }, [isEditing, group.front_image_path, frontImage]); + + const activeBackImage = useMemo(() => { + let existingImage = group.back_image_path; + if (isEditing) { + if (backImage === null) { + return undefined; + } else if (backImage) { + return backImage; + } + } + + return existingImage; + }, [isEditing, group.back_image_path, backImage]); const [updateGroup, { loading: updating }] = useGroupUpdate(); const [deleteGroup, { loading: deleting }] = useGroupDestroy({ @@ -196,95 +217,6 @@ const GroupPage: React.FC = ({ group }) => { ); } - function getCollapseButtonIcon() { - return collapsed ? faChevronDown : faChevronUp; - } - - function maybeRenderShowCollapseButton() { - if (!isEditing) { - return ( - - - - ); - } - } - - function renderFrontImage() { - let image = group.front_image_path; - if (isEditing) { - if (frontImage === null && image) { - const imageURL = new URL(image); - imageURL.searchParams.set("default", "true"); - image = imageURL.toString(); - } else if (frontImage) { - image = frontImage; - } - } - - if (image && defaultImage) { - return ( -
    - -
    - ); - } else if (image) { - return ( - - ); - } - } - - function renderBackImage() { - let image = group.back_image_path; - if (isEditing) { - if (backImage === null) { - image = undefined; - } else if (backImage) { - image = backImage; - } - } - - if (image) { - return ( - - ); - } - } - - const renderClickableIcons = () => ( - - {group.urls.length > 0 && } - - ); - - function maybeRenderAliases() { - if (group?.aliases) { - return ( -
    - {group?.aliases} -
    - ); - } - } - function setRating(v: number | null) { if (group.id) { updateGroup({ @@ -300,75 +232,6 @@ const GroupPage: React.FC = ({ group }) => { const renderTabs = () => ; - function maybeRenderDetails() { - if (!isEditing) { - return ( - - ); - } - } - - function maybeRenderEditPanel() { - if (isEditing) { - return ( - toggleEditing()} - onDelete={onDelete} - setFrontImage={setFrontImage} - setBackImage={setBackImage} - setEncodingImage={setEncodingImage} - /> - ); - } - { - return ( - toggleEditing()} - onSave={() => {}} - onImageChange={() => {}} - onDelete={onDelete} - /> - ); - } - } - - function maybeRenderCompressedDetails() { - if (!isEditing && loadStickyHeader) { - return ; - } - } - - function maybeRenderHeaderBackgroundImage() { - let image = group.front_image_path; - if (enableBackgroundImage && !isEditing && image) { - const imageURL = new URL(image); - let isDefaultImage = imageURL.searchParams.get("default"); - if (!isDefaultImage) { - return ( -
    - - - {`${group.name} - -
    - ); - } - } - } - function maybeRenderTab() { if (!isEditing) { return renderTabs(); @@ -390,43 +253,86 @@ const GroupPage: React.FC = ({ group }) => {
    - {maybeRenderHeaderBackgroundImage()} +
    -
    -
    - {encodingImage ? ( - - ) : ( -
    - {renderFrontImage()} - {renderBackImage()} -
    + +
    + {!!activeFrontImage && ( + + + + )} + {!!activeBackImage && ( + + + )}
    -
    +
    -

    - {group.name} - {maybeRenderShowCollapseButton()} - {renderClickableIcons()} -

    - {maybeRenderAliases()} + + {!isEditing && ( + setCollapsed(v)} + /> + )} + + + + + + setRating(value)} clickToRate withoutContext /> - {maybeRenderDetails()} - {maybeRenderEditPanel()} + {!isEditing && ( + + )} + {isEditing ? ( + toggleEditing()} + onDelete={onDelete} + setFrontImage={setFrontImage} + setBackImage={setBackImage} + setEncodingImage={setEncodingImage} + /> + ) : ( + toggleEditing()} + onSave={() => {}} + onImageChange={() => {}} + onDelete={onDelete} + /> + )}
    - {maybeRenderCompressedDetails()} + + {!isEditing && loadStickyHeader && ( + + )} +
    {maybeRenderTab()}
    diff --git a/ui/v2.5/src/components/Performers/PerformerCard.tsx b/ui/v2.5/src/components/Performers/PerformerCard.tsx index eae5d121c90..04e7fe9127d 100644 --- a/ui/v2.5/src/components/Performers/PerformerCard.tsx +++ b/ui/v2.5/src/components/Performers/PerformerCard.tsx @@ -277,6 +277,8 @@ export const PerformerCard: React.FC = ({ {maybeRenderRatingBanner()} {maybeRenderFlag()} diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx index b5046e0fcbd..41a37b61d15 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx @@ -1,5 +1,5 @@ -import React, { useCallback, useEffect, useMemo, useState } from "react"; -import { Button, Tabs, Tab, Col, Row } from "react-bootstrap"; +import React, { useEffect, useMemo, useState } from "react"; +import { Tabs, Tab, Col, Row } from "react-bootstrap"; import { useIntl } from "react-intl"; import { useHistory, Redirect, RouteComponentProps } from "react-router-dom"; import { Helmet } from "react-helmet"; @@ -12,12 +12,9 @@ import { usePerformerDestroy, mutateMetadataAutoTag, } from "src/core/StashService"; -import { Counter } from "src/components/Shared/Counter"; import { DetailsEditNavbar } from "src/components/Shared/DetailsEditNavbar"; import { ErrorMessage } from "src/components/Shared/ErrorMessage"; -import { Icon } from "src/components/Shared/Icon"; import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; -import { useLightbox } from "src/hooks/Lightbox/hooks"; import { useToast } from "src/hooks/Toast"; import { ConfigurationContext } from "src/hooks/Config"; import { RatingSystem } from "src/components/Shared/Rating/RatingSystem"; @@ -32,18 +29,22 @@ import { PerformerImagesPanel } from "./PerformerImagesPanel"; import { PerformerAppearsWithPanel } from "./performerAppearsWithPanel"; import { PerformerEditPanel } from "./PerformerEditPanel"; import { PerformerSubmitButton } from "./PerformerSubmitButton"; -import { - faChevronDown, - faChevronUp, - faHeart, - faLink, -} from "@fortawesome/free-solid-svg-icons"; -import { faInstagram, faTwitter } from "@fortawesome/free-brands-svg-icons"; import { useRatingKeybinds } from "src/hooks/keybinds"; import { DetailImage } from "src/components/Shared/DetailImage"; import { useLoadStickyHeader } from "src/hooks/detailsPanel"; import { useScrollToTopOnMount } from "src/hooks/scrollToTop"; -import { ExternalLinksButton } from "src/components/Shared/ExternalLinksButton"; +import { ExternalLinkButtons } from "src/components/Shared/ExternalLinksButton"; +import { BackgroundImage } from "src/components/Shared/DetailsPage/BackgroundImage"; +import { + TabTitleCounter, + useTabKey, +} from "src/components/Shared/DetailsPage/Tabs"; +import { DetailTitle } from "src/components/Shared/DetailsPage/DetailTitle"; +import { ExpandCollapseButton } from "src/components/Shared/CollapseButton"; +import { FavoriteIcon } from "src/components/Shared/FavoriteIcon"; +import { AliasList } from "src/components/Shared/DetailsPage/AliasList"; +import { HeaderImage } from "src/components/Shared/DetailsPage/HeaderImage"; +import { LightboxLink } from "src/hooks/Lightbox/LightboxLink"; interface IProps { performer: GQL.PerformerDataFragment; @@ -69,6 +70,136 @@ function isTabKey(tab: string): tab is TabKey { return validTabs.includes(tab as TabKey); } +const PerformerTabs: React.FC<{ + tabKey?: TabKey; + performer: GQL.PerformerDataFragment; + abbreviateCounter: boolean; +}> = ({ tabKey, performer, abbreviateCounter }) => { + const populatedDefaultTab = useMemo(() => { + let ret: TabKey = "scenes"; + if (performer.scene_count == 0) { + if (performer.gallery_count != 0) { + ret = "galleries"; + } else if (performer.image_count != 0) { + ret = "images"; + } else if (performer.group_count != 0) { + ret = "groups"; + } + } + + return ret; + }, [performer]); + + const { setTabKey } = useTabKey({ + tabKey, + validTabs, + defaultTabKey: populatedDefaultTab, + baseURL: `/performers/${performer.id}`, + }); + + useEffect(() => { + Mousetrap.bind("c", () => setTabKey("scenes")); + Mousetrap.bind("g", () => setTabKey("galleries")); + Mousetrap.bind("m", () => setTabKey("groups")); + + return () => { + Mousetrap.unbind("c"); + Mousetrap.unbind("g"); + Mousetrap.unbind("m"); + }; + }); + + return ( + + + } + > + + + + + } + > + + + + + } + > + + + + + } + > + + + + + } + > + + + + ); +}; + const PerformerPage: React.FC = ({ performer, tabKey }) => { const Toast = useToast(); const history = useHistory(); @@ -89,29 +220,6 @@ const PerformerPage: React.FC = ({ performer, tabKey }) => { const [encodingImage, setEncodingImage] = useState(false); const loadStickyHeader = useLoadStickyHeader(); - // a list of urls to display in the performer details - const urls = useMemo(() => { - if (!performer.urls?.length) { - return []; - } - - const twitter = performer.urls.filter((u) => - u.match(/https?:\/\/(?:www\.)?twitter.com\//) - ); - const instagram = performer.urls.filter((u) => - u.match(/https?:\/\/(?:www\.)?instagram.com\//) - ); - const others = performer.urls.filter( - (u) => !twitter.includes(u) && !instagram.includes(u) - ); - - return [ - { icon: faLink, className: "", urls: others }, - { icon: faTwitter, className: "twitter", urls: twitter }, - { icon: faInstagram, className: "instagram", urls: instagram }, - ]; - }, [performer.urls]); - const activeImage = useMemo(() => { const performerImage = performer.image_path; if (isEditing) { @@ -131,46 +239,9 @@ const PerformerPage: React.FC = ({ performer, tabKey }) => { [activeImage] ); - const showLightbox = useLightbox({ - images: lightboxImages, - }); - const [updatePerformer] = usePerformerUpdate(); const [deletePerformer, { loading: isDestroying }] = usePerformerDestroy(); - const populatedDefaultTab = useMemo(() => { - let ret: TabKey = "scenes"; - if (performer.scene_count == 0) { - if (performer.gallery_count != 0) { - ret = "galleries"; - } else if (performer.image_count != 0) { - ret = "images"; - } else if (performer.group_count != 0) { - ret = "groups"; - } - } - - return ret; - }, [performer]); - - const setTabKey = useCallback( - (newTabKey: string | null) => { - if (!newTabKey) newTabKey = populatedDefaultTab; - if (newTabKey === tabKey) return; - - if (isTabKey(newTabKey)) { - history.replace(`/performers/${performer.id}/${newTabKey}`); - } - }, - [populatedDefaultTab, tabKey, history, performer.id] - ); - - useEffect(() => { - if (!tabKey) { - setTabKey(populatedDefaultTab); - } - }, [setTabKey, populatedDefaultTab, tabKey]); - async function onAutoTag() { try { await mutateMetadataAutoTag({ performers: [performer.id] }); @@ -189,17 +260,11 @@ const PerformerPage: React.FC = ({ performer, tabKey }) => { // set up hotkeys useEffect(() => { Mousetrap.bind("e", () => toggleEditing()); - Mousetrap.bind("c", () => setTabKey("scenes")); - Mousetrap.bind("g", () => setTabKey("galleries")); - Mousetrap.bind("m", () => setTabKey("groups")); Mousetrap.bind("f", () => setFavorite(!performer.favorite)); Mousetrap.bind(",", () => setCollapsed(!collapsed)); return () => { Mousetrap.unbind("e"); - Mousetrap.unbind("c"); - Mousetrap.unbind("g"); - Mousetrap.unbind("m"); Mousetrap.unbind("f"); Mousetrap.unbind(","); }; @@ -243,221 +308,6 @@ const PerformerPage: React.FC = ({ performer, tabKey }) => { setImage(undefined); } - function renderImage() { - if (activeImage) { - return ( - - ); - } - } - const renderTabs = () => ( - - - {intl.formatMessage({ id: "scenes" })} - - - } - > - - - - {intl.formatMessage({ id: "galleries" })} - - - } - > - - - - {intl.formatMessage({ id: "images" })} - - - } - > - - - - {intl.formatMessage({ id: "groups" })} - - - } - > - - - - {intl.formatMessage({ id: "appears_with" })} - - - } - > - - - - ); - - function maybeRenderHeaderBackgroundImage() { - if (enableBackgroundImage && !isEditing && activeImage) { - const activeImageURL = new URL(activeImage); - let isDefaultImage = activeImageURL.searchParams.get("default"); - if (!isDefaultImage) { - return ( -
    - - - {`${performer.name} - -
    - ); - } - } - } - - function maybeRenderEditPanel() { - if (isEditing) { - return ( - toggleEditing()} - setImage={setImage} - setEncodingImage={setEncodingImage} - /> - ); - } - { - return ( - - - toggleEditing()} - onDelete={onDelete} - onAutoTag={onAutoTag} - autoTagDisabled={performer.ignore_auto_tag} - isNew={false} - isEditing={false} - onSave={() => {}} - onImageChange={() => {}} - classNames="mb-2" - customButtons={ -
    - -
    - } - >
    -
    - - ); - } - } - - function getCollapseButtonIcon() { - return collapsed ? faChevronDown : faChevronUp; - } - - function maybeRenderDetails() { - if (!isEditing) { - return ( - - ); - } - } - - function maybeRenderCompressedDetails() { - if (!isEditing && loadStickyHeader) { - return ; - } - } - - function maybeRenderTab() { - if (!isEditing) { - return renderTabs(); - } - } - - function maybeRenderAliases() { - if (performer?.alias_list?.length) { - return ( -
    - {performer.alias_list?.join(", ")} -
    - ); - } - } - function setFavorite(v: boolean) { if (performer.id) { updatePerformer({ @@ -484,45 +334,6 @@ const PerformerPage: React.FC = ({ performer, tabKey }) => { } } - function maybeRenderShowCollapseButton() { - if (!isEditing) { - return ( - - - - ); - } - } - - function renderClickableIcons() { - return ( - - - {urls.map((url) => ( - - ))} - - ); - } - if (isDestroying) return ( = ({ performer, tabKey }) => {
    - {maybeRenderHeaderBackgroundImage()} +
    -
    - {encodingImage ? ( - - ) : ( - renderImage() + + {!!activeImage && ( + + + )} -
    + +
    -

    - {performer.name} - {performer.disambiguation && ( - - {` (${performer.disambiguation})`} - + + {!isEditing && ( + setCollapsed(v)} + /> )} - {maybeRenderShowCollapseButton()} - {renderClickableIcons()} -

    - {maybeRenderAliases()} + + setFavorite(v)} + /> + + + + setRating(value)} clickToRate withoutContext /> - {maybeRenderDetails()} - {maybeRenderEditPanel()} + {!isEditing && ( + + )} + {isEditing ? ( + toggleEditing()} + setImage={setImage} + setEncodingImage={setEncodingImage} + /> + ) : ( + + + toggleEditing()} + onDelete={onDelete} + onAutoTag={onAutoTag} + autoTagDisabled={performer.ignore_auto_tag} + isNew={false} + isEditing={false} + onSave={() => {}} + onImageChange={() => {}} + classNames="mb-2" + customButtons={ +
    + +
    + } + >
    +
    + + )}
    - {maybeRenderCompressedDetails()} + + {!isEditing && loadStickyHeader && ( + + )} +
    -
    {maybeRenderTab()}
    +
    + {!isEditing && ( + + )} +
    diff --git a/ui/v2.5/src/components/Shared/CollapseButton.tsx b/ui/v2.5/src/components/Shared/CollapseButton.tsx index 7f70cf0ed37..d74338ec9c3 100644 --- a/ui/v2.5/src/components/Shared/CollapseButton.tsx +++ b/ui/v2.5/src/components/Shared/CollapseButton.tsx @@ -1,6 +1,7 @@ import { faChevronDown, faChevronRight, + faChevronUp, } from "@fortawesome/free-solid-svg-icons"; import React, { useState } from "react"; import { Button, Collapse } from "react-bootstrap"; @@ -30,3 +31,21 @@ export const CollapseButton: React.FC> = (
    ); }; + +export const ExpandCollapseButton: React.FC<{ + collapsed: boolean; + setCollapsed: (collapsed: boolean) => void; +}> = ({ collapsed, setCollapsed }) => { + const buttonIcon = collapsed ? faChevronDown : faChevronUp; + + return ( + + + + ); +}; diff --git a/ui/v2.5/src/components/Shared/DetailsPage/AliasList.tsx b/ui/v2.5/src/components/Shared/DetailsPage/AliasList.tsx new file mode 100644 index 00000000000..8f595207009 --- /dev/null +++ b/ui/v2.5/src/components/Shared/DetailsPage/AliasList.tsx @@ -0,0 +1,13 @@ +export const AliasList: React.FC<{ aliases: string[] | undefined }> = ({ + aliases, +}) => { + if (!aliases?.length) { + return null; + } + + return ( +
    + {aliases.join(", ")} +
    + ); +}; diff --git a/ui/v2.5/src/components/Shared/DetailsPage/BackgroundImage.tsx b/ui/v2.5/src/components/Shared/DetailsPage/BackgroundImage.tsx new file mode 100644 index 00000000000..cd5f3ae277a --- /dev/null +++ b/ui/v2.5/src/components/Shared/DetailsPage/BackgroundImage.tsx @@ -0,0 +1,24 @@ +import React from "react"; + +export const BackgroundImage: React.FC<{ + imagePath: string | undefined; + show: boolean; + alt?: string; +}> = ({ imagePath, show, alt }) => { + if (imagePath && show) { + const imageURL = new URL(imagePath); + let isDefaultImage = imageURL.searchParams.get("default"); + if (!isDefaultImage) { + return ( +
    + + + {alt} + +
    + ); + } + } + + return null; +}; diff --git a/ui/v2.5/src/components/Shared/DetailsPage/DetailTitle.tsx b/ui/v2.5/src/components/Shared/DetailsPage/DetailTitle.tsx new file mode 100644 index 00000000000..b518d66e6f9 --- /dev/null +++ b/ui/v2.5/src/components/Shared/DetailsPage/DetailTitle.tsx @@ -0,0 +1,21 @@ +import React, { PropsWithChildren } from "react"; + +export const DetailTitle: React.FC< + PropsWithChildren<{ + name: string; + disambiguation?: string; + classNamePrefix: string; + }> +> = ({ name, disambiguation, classNamePrefix, children }) => { + return ( +

    + {name} + {disambiguation && ( + + {` (${disambiguation})`} + + )} + {children} +

    + ); +}; diff --git a/ui/v2.5/src/components/Shared/DetailsPage/HeaderImage.tsx b/ui/v2.5/src/components/Shared/DetailsPage/HeaderImage.tsx new file mode 100644 index 00000000000..057115308c6 --- /dev/null +++ b/ui/v2.5/src/components/Shared/DetailsPage/HeaderImage.tsx @@ -0,0 +1,21 @@ +import { PropsWithChildren } from "react"; +import { LoadingIndicator } from "../LoadingIndicator"; +import { FormattedMessage } from "react-intl"; + +export const HeaderImage: React.FC< + PropsWithChildren<{ + encodingImage: boolean; + }> +> = ({ encodingImage, children }) => { + return ( +
    + {encodingImage ? ( + } + /> + ) : ( + children + )} +
    + ); +}; diff --git a/ui/v2.5/src/components/Shared/DetailsPage/Tabs.tsx b/ui/v2.5/src/components/Shared/DetailsPage/Tabs.tsx new file mode 100644 index 00000000000..a29b2b5bf62 --- /dev/null +++ b/ui/v2.5/src/components/Shared/DetailsPage/Tabs.tsx @@ -0,0 +1,48 @@ +import { FormattedMessage } from "react-intl"; +import { Counter } from "../Counter"; +import { useCallback, useEffect } from "react"; +import { useHistory } from "react-router-dom"; + +export const TabTitleCounter: React.FC<{ + messageID: string; + count: number; + abbreviateCounter: boolean; +}> = ({ messageID, count, abbreviateCounter }) => { + return ( + <> + + + + ); +}; + +export function useTabKey(props: { + tabKey: string | undefined; + validTabs: readonly string[]; + defaultTabKey: string; + baseURL: string; +}) { + const { tabKey, validTabs, defaultTabKey, baseURL } = props; + + const history = useHistory(); + + const setTabKey = useCallback( + (newTabKey: string | null) => { + if (!newTabKey) newTabKey = defaultTabKey; + if (newTabKey === tabKey) return; + + if (validTabs.includes(newTabKey)) { + history.replace(`${baseURL}/${newTabKey}`); + } + }, + [defaultTabKey, validTabs, tabKey, history, baseURL] + ); + + useEffect(() => { + if (!tabKey) { + setTabKey(defaultTabKey); + } + }, [setTabKey, defaultTabKey, tabKey]); + + return { setTabKey }; +} diff --git a/ui/v2.5/src/components/Shared/ExternalLinksButton.tsx b/ui/v2.5/src/components/Shared/ExternalLinksButton.tsx index 7124d789c96..00b318c65f3 100644 --- a/ui/v2.5/src/components/Shared/ExternalLinksButton.tsx +++ b/ui/v2.5/src/components/Shared/ExternalLinksButton.tsx @@ -3,6 +3,8 @@ import { ExternalLink } from "./ExternalLink"; import TextUtils from "src/utils/text"; import { Icon } from "./Icon"; import { IconDefinition, faLink } from "@fortawesome/free-solid-svg-icons"; +import { useMemo } from "react"; +import { faInstagram, faTwitter } from "@fortawesome/free-brands-svg-icons"; export const ExternalLinksButton: React.FC<{ icon?: IconDefinition; @@ -47,3 +49,37 @@ export const ExternalLinksButton: React.FC<{ ); }; + +export const ExternalLinkButtons: React.FC<{ urls: string[] | undefined }> = ({ + urls, +}) => { + const urlSpecs = useMemo(() => { + if (!urls?.length) { + return []; + } + + const twitter = urls.filter((u) => + u.match(/https?:\/\/(?:www\.)?(?:twitter|x).com\//) + ); + const instagram = urls.filter((u) => + u.match(/https?:\/\/(?:www\.)?instagram.com\//) + ); + const others = urls.filter( + (u) => !twitter.includes(u) && !instagram.includes(u) + ); + + return [ + { icon: faLink, className: "", urls: others }, + { icon: faTwitter, className: "twitter", urls: twitter }, + { icon: faInstagram, className: "instagram", urls: instagram }, + ]; + }, [urls]); + + return ( + <> + {urlSpecs.map((spec, i) => ( + + ))} + + ); +}; diff --git a/ui/v2.5/src/components/Shared/FavoriteIcon.tsx b/ui/v2.5/src/components/Shared/FavoriteIcon.tsx index 8742095228f..ff3db29bdd5 100644 --- a/ui/v2.5/src/components/Shared/FavoriteIcon.tsx +++ b/ui/v2.5/src/components/Shared/FavoriteIcon.tsx @@ -3,22 +3,26 @@ import { Icon } from "../Shared/Icon"; import { Button } from "react-bootstrap"; import { faHeart } from "@fortawesome/free-solid-svg-icons"; import cx from "classnames"; +import { SizeProp } from "@fortawesome/fontawesome-svg-core"; export const FavoriteIcon: React.FC<{ favorite: boolean; onToggleFavorite: (v: boolean) => void; -}> = ({ favorite, onToggleFavorite }) => { + size?: SizeProp; + className?: string; +}> = ({ favorite, onToggleFavorite, size, className }) => { return ( ); }; diff --git a/ui/v2.5/src/components/Shared/LoadingIndicator.tsx b/ui/v2.5/src/components/Shared/LoadingIndicator.tsx index 661722d73ed..e5294ec3e2d 100644 --- a/ui/v2.5/src/components/Shared/LoadingIndicator.tsx +++ b/ui/v2.5/src/components/Shared/LoadingIndicator.tsx @@ -4,7 +4,7 @@ import cx from "classnames"; import { useIntl } from "react-intl"; interface ILoadingProps { - message?: string; + message?: JSX.Element | string; inline?: boolean; small?: boolean; card?: boolean; diff --git a/ui/v2.5/src/components/Shared/styles.scss b/ui/v2.5/src/components/Shared/styles.scss index 00ce5e663f7..c3bcf684dc1 100644 --- a/ui/v2.5/src/components/Shared/styles.scss +++ b/ui/v2.5/src/components/Shared/styles.scss @@ -536,7 +536,10 @@ button.btn.favorite-button { &.not-favorite { color: rgba(191, 204, 214, 0.5); filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.9)); - opacity: 0; + + &.hide-not-favorite { + opacity: 0; + } } &.favorite { diff --git a/ui/v2.5/src/components/Studios/StudioCard.tsx b/ui/v2.5/src/components/Studios/StudioCard.tsx index e2a7bada267..e66316c7478 100644 --- a/ui/v2.5/src/components/Studios/StudioCard.tsx +++ b/ui/v2.5/src/components/Studios/StudioCard.tsx @@ -236,6 +236,8 @@ export const StudioCard: React.FC = ({ onToggleFavorite(v)} + size="2x" + className="hide-not-favorite" /> } popovers={maybeRenderPopoverButtonGroup()} diff --git a/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx b/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx index 63eb47ed43a..cc4c9ac2b2a 100644 --- a/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx +++ b/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx @@ -1,5 +1,5 @@ -import { Button, Tabs, Tab } from "react-bootstrap"; -import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { Tabs, Tab } from "react-bootstrap"; +import React, { useEffect, useMemo, useState } from "react"; import { useHistory, Redirect, RouteComponentProps } from "react-router-dom"; import { FormattedMessage, useIntl } from "react-intl"; import { Helmet } from "react-helmet"; @@ -13,14 +13,12 @@ import { useStudioDestroy, mutateMetadataAutoTag, } from "src/core/StashService"; -import { Counter } from "src/components/Shared/Counter"; import { DetailsEditNavbar } from "src/components/Shared/DetailsEditNavbar"; import { ModalComponent } from "src/components/Shared/Modal"; import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; import { ErrorMessage } from "src/components/Shared/ErrorMessage"; import { useToast } from "src/hooks/Toast"; import { ConfigurationContext } from "src/hooks/Config"; -import { Icon } from "src/components/Shared/Icon"; import { StudioScenesPanel } from "./StudioScenesPanel"; import { StudioGalleriesPanel } from "./StudioGalleriesPanel"; import { StudioImagesPanel } from "./StudioImagesPanel"; @@ -32,20 +30,23 @@ import { StudioDetailsPanel, } from "./StudioDetailsPanel"; import { StudioGroupsPanel } from "./StudioGroupsPanel"; -import { - faTrashAlt, - faLink, - faChevronDown, - faChevronUp, - faHeart, -} from "@fortawesome/free-solid-svg-icons"; -import TextUtils from "src/utils/text"; +import { faTrashAlt } from "@fortawesome/free-solid-svg-icons"; import { RatingSystem } from "src/components/Shared/Rating/RatingSystem"; import { DetailImage } from "src/components/Shared/DetailImage"; import { useRatingKeybinds } from "src/hooks/keybinds"; import { useLoadStickyHeader } from "src/hooks/detailsPanel"; import { useScrollToTopOnMount } from "src/hooks/scrollToTop"; -import { ExternalLink } from "src/components/Shared/ExternalLink"; +import { BackgroundImage } from "src/components/Shared/DetailsPage/BackgroundImage"; +import { + TabTitleCounter, + useTabKey, +} from "src/components/Shared/DetailsPage/Tabs"; +import { DetailTitle } from "src/components/Shared/DetailsPage/DetailTitle"; +import { ExpandCollapseButton } from "src/components/Shared/CollapseButton"; +import { FavoriteIcon } from "src/components/Shared/FavoriteIcon"; +import { ExternalLinkButtons } from "src/components/Shared/ExternalLinksButton"; +import { AliasList } from "src/components/Shared/DetailsPage/AliasList"; +import { HeaderImage } from "src/components/Shared/DetailsPage/HeaderImage"; interface IProps { studio: GQL.StudioDataFragment; @@ -72,34 +73,12 @@ function isTabKey(tab: string): tab is TabKey { return validTabs.includes(tab as TabKey); } -const StudioPage: React.FC = ({ studio, tabKey }) => { - const history = useHistory(); - const Toast = useToast(); - const intl = useIntl(); - - // Configuration settings - const { configuration } = React.useContext(ConfigurationContext); - const uiConfig = configuration?.ui; - const abbreviateCounter = uiConfig?.abbreviateCounters ?? false; - const enableBackgroundImage = uiConfig?.enableStudioBackgroundImage ?? false; - const showAllDetails = uiConfig?.showAllDetails ?? true; - const compactExpandedDetails = uiConfig?.compactExpandedDetails ?? false; - - const [collapsed, setCollapsed] = useState(!showAllDetails); - const loadStickyHeader = useLoadStickyHeader(); - - // Editing state - const [isEditing, setIsEditing] = useState(false); - const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState(false); - - // Editing studio state - const [image, setImage] = useState(); - const [encodingImage, setEncodingImage] = useState(false); - - const [updateStudio] = useStudioUpdate(); - const [deleteStudio] = useStudioDestroy({ id: studio.id }); - - const showAllCounts = uiConfig?.showChildStudioContent; +const StudioTabs: React.FC<{ + tabKey?: TabKey; + studio: GQL.StudioDataFragment; + abbreviateCounter: boolean; + showAllCounts?: boolean; +}> = ({ tabKey, studio, abbreviateCounter, showAllCounts = false }) => { const sceneCount = (showAllCounts ? studio.scene_count_all : studio.scene_count) ?? 0; const galleryCount = @@ -137,23 +116,151 @@ const StudioPage: React.FC = ({ studio, tabKey }) => { studio, ]); - const setTabKey = useCallback( - (newTabKey: string | null) => { - if (!newTabKey) newTabKey = populatedDefaultTab; - if (newTabKey === tabKey) return; + const { setTabKey } = useTabKey({ + tabKey, + validTabs, + defaultTabKey: populatedDefaultTab, + baseURL: `/studios/${studio.id}`, + }); - if (isTabKey(newTabKey)) { - history.replace(`/studios/${studio.id}/${newTabKey}`); - } - }, - [populatedDefaultTab, tabKey, history, studio.id] + return ( + + + } + > + + + + } + > + + + + } + > + + + + } + > + + + + } + > + + + + } + > + + + ); +}; - useEffect(() => { - if (!tabKey) { - setTabKey(populatedDefaultTab); +const StudioPage: React.FC = ({ studio, tabKey }) => { + const history = useHistory(); + const Toast = useToast(); + const intl = useIntl(); + + // Configuration settings + const { configuration } = React.useContext(ConfigurationContext); + const uiConfig = configuration?.ui; + const abbreviateCounter = uiConfig?.abbreviateCounters ?? false; + const enableBackgroundImage = uiConfig?.enableStudioBackgroundImage ?? false; + const showAllDetails = uiConfig?.showAllDetails ?? true; + const compactExpandedDetails = uiConfig?.compactExpandedDetails ?? false; + + const [collapsed, setCollapsed] = useState(!showAllDetails); + const loadStickyHeader = useLoadStickyHeader(); + + // Editing state + const [isEditing, setIsEditing] = useState(false); + const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState(false); + + // Editing studio state + const [image, setImage] = useState(); + const [encodingImage, setEncodingImage] = useState(false); + + const [updateStudio] = useStudioUpdate(); + const [deleteStudio] = useStudioDestroy({ id: studio.id }); + + const showAllCounts = uiConfig?.showChildStudioContent; + + // make array of url so that it doesn't re-render on every change + const urls = useMemo(() => { + return studio?.url ? [studio.url] : []; + }, [studio.url]); + + const studioImage = useMemo(() => { + const existingPath = studio.image_path; + if (isEditing) { + if (image === null && existingPath) { + const studioImageURL = new URL(existingPath); + studioImageURL.searchParams.set("default", "true"); + return studioImageURL.toString(); + } else if (image) { + return image; + } } - }, [setTabKey, populatedDefaultTab, tabKey]); + + return existingPath; + }, [isEditing, image, studio.image_path]); function setFavorite(v: boolean) { if (studio.id) { @@ -256,20 +363,6 @@ const StudioPage: React.FC = ({ studio, tabKey }) => { ); } - function maybeRenderAliases() { - if (studio?.aliases?.length) { - return ( -
    - {studio?.aliases?.join(", ")} -
    - ); - } - } - - function getCollapseButtonIcon() { - return collapsed ? faChevronDown : faChevronUp; - } - function toggleEditing(value?: boolean) { if (value !== undefined) { setIsEditing(value); @@ -279,46 +372,6 @@ const StudioPage: React.FC = ({ studio, tabKey }) => { setImage(undefined); } - function renderImage() { - let studioImage = studio.image_path; - if (isEditing) { - if (image === null && studioImage) { - const studioImageURL = new URL(studioImage); - studioImageURL.searchParams.set("default", "true"); - studioImage = studioImageURL.toString(); - } else if (image) { - studioImage = image; - } - } - - if (studioImage) { - return ( - - ); - } - } - - const renderClickableIcons = () => ( - - - {studio.url && ( - - )} - - ); - function setRating(v: number | null) { if (studio.id) { updateStudio({ @@ -332,205 +385,6 @@ const StudioPage: React.FC = ({ studio, tabKey }) => { } } - function maybeRenderDetails() { - if (!isEditing) { - return ( - - ); - } - } - - function maybeRenderShowCollapseButton() { - if (!isEditing) { - return ( - - - - ); - } - } - - function maybeRenderCompressedDetails() { - if (!isEditing && loadStickyHeader) { - return ; - } - } - - const renderTabs = () => ( - - - {intl.formatMessage({ id: "scenes" })} - - - } - > - - - - {intl.formatMessage({ id: "galleries" })} - - - } - > - - - - {intl.formatMessage({ id: "images" })} - - - } - > - - - - {intl.formatMessage({ id: "performers" })} - - - } - > - - - - {intl.formatMessage({ id: "groups" })} - - - } - > - - - - {intl.formatMessage({ id: "subsidiary_studios" })} - - - } - > - - - - ); - - function maybeRenderHeaderBackgroundImage() { - let studioImage = studio.image_path; - if (enableBackgroundImage && !isEditing && studioImage) { - const studioImageURL = new URL(studioImage); - let isDefaultImage = studioImageURL.searchParams.get("default"); - if (!isDefaultImage) { - return ( -
    - - - {`${studio.name} - -
    - ); - } - } - } - - function maybeRenderTab() { - if (!isEditing) { - return renderTabs(); - } - } - - function maybeRenderEditPanel() { - if (isEditing) { - return ( - toggleEditing()} - onDelete={onDelete} - setImage={setImage} - setEncodingImage={setEncodingImage} - /> - ); - } - { - return ( - toggleEditing()} - onSave={() => {}} - onImageChange={() => {}} - onClearImage={() => {}} - onAutoTag={onAutoTag} - autoTagDisabled={studio.ignore_auto_tag} - onDelete={onDelete} - /> - ); - } - } - const headerClassName = cx("detail-header", { edit: isEditing, collapsed, @@ -544,41 +398,98 @@ const StudioPage: React.FC = ({ studio, tabKey }) => {
    - {maybeRenderHeaderBackgroundImage()} +
    -
    - {encodingImage ? ( - + {studioImage && ( + - ) : ( - renderImage() )} -
    +
    -

    - {studio.name} - {maybeRenderShowCollapseButton()} - {renderClickableIcons()} -

    - {maybeRenderAliases()} + + {!isEditing && ( + setCollapsed(v)} + /> + )} + + setFavorite(v)} + /> + + + + + setRating(value)} clickToRate withoutContext /> - {maybeRenderDetails()} - {maybeRenderEditPanel()} + {!isEditing && ( + + )} + {isEditing ? ( + toggleEditing()} + onDelete={onDelete} + setImage={setImage} + setEncodingImage={setEncodingImage} + /> + ) : ( + toggleEditing()} + onSave={() => {}} + onImageChange={() => {}} + onClearImage={() => {}} + onAutoTag={onAutoTag} + autoTagDisabled={studio.ignore_auto_tag} + onDelete={onDelete} + /> + )}
    - {maybeRenderCompressedDetails()} + + {!isEditing && loadStickyHeader && ( + + )} +
    -
    {maybeRenderTab()}
    +
    + {!isEditing && ( + + )} +
    {renderDeleteAlert()} diff --git a/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx b/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx index 13fb2664f15..d392707d127 100644 --- a/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx +++ b/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx @@ -1,5 +1,5 @@ -import { Tabs, Tab, Dropdown, Button } from "react-bootstrap"; -import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { Tabs, Tab, Dropdown } from "react-bootstrap"; +import React, { useEffect, useMemo, useState } from "react"; import { useHistory, Redirect, RouteComponentProps } from "react-router-dom"; import { FormattedMessage, useIntl } from "react-intl"; import { Helmet } from "react-helmet"; @@ -13,7 +13,6 @@ import { useTagDestroy, mutateMetadataAutoTag, } from "src/core/StashService"; -import { Counter } from "src/components/Shared/Counter"; import { DetailsEditNavbar } from "src/components/Shared/DetailsEditNavbar"; import { ErrorMessage } from "src/components/Shared/ErrorMessage"; import { ModalComponent } from "src/components/Shared/Modal"; @@ -32,9 +31,6 @@ import { CompressedTagDetailsPanel, TagDetailsPanel } from "./TagDetailsPanel"; import { TagEditPanel } from "./TagEditPanel"; import { TagMergeModal } from "./TagMergeDialog"; import { - faChevronDown, - faChevronUp, - faHeart, faSignInAlt, faSignOutAlt, faTrashAlt, @@ -43,6 +39,16 @@ import { DetailImage } from "src/components/Shared/DetailImage"; import { useLoadStickyHeader } from "src/hooks/detailsPanel"; import { useScrollToTopOnMount } from "src/hooks/scrollToTop"; import { TagGroupsPanel } from "./TagGroupsPanel"; +import { BackgroundImage } from "src/components/Shared/DetailsPage/BackgroundImage"; +import { + TabTitleCounter, + useTabKey, +} from "src/components/Shared/DetailsPage/Tabs"; +import { DetailTitle } from "src/components/Shared/DetailsPage/DetailTitle"; +import { ExpandCollapseButton } from "src/components/Shared/CollapseButton"; +import { FavoriteIcon } from "src/components/Shared/FavoriteIcon"; +import { AliasList } from "src/components/Shared/DetailsPage/AliasList"; +import { HeaderImage } from "src/components/Shared/DetailsPage/HeaderImage"; interface IProps { tag: GQL.TagDataFragment; @@ -70,35 +76,12 @@ function isTabKey(tab: string): tab is TabKey { return validTabs.includes(tab as TabKey); } -const TagPage: React.FC = ({ tag, tabKey }) => { - const history = useHistory(); - const Toast = useToast(); - const intl = useIntl(); - - // Configuration settings - const { configuration } = React.useContext(ConfigurationContext); - const uiConfig = configuration?.ui; - const abbreviateCounter = uiConfig?.abbreviateCounters ?? false; - const enableBackgroundImage = uiConfig?.enableTagBackgroundImage ?? false; - const showAllDetails = uiConfig?.showAllDetails ?? true; - const compactExpandedDetails = uiConfig?.compactExpandedDetails ?? false; - - const [collapsed, setCollapsed] = useState(!showAllDetails); - const loadStickyHeader = useLoadStickyHeader(); - - // Editing state - const [isEditing, setIsEditing] = useState(false); - const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState(false); - const [mergeType, setMergeType] = useState<"from" | "into" | undefined>(); - - // Editing tag state - const [image, setImage] = useState(); - const [encodingImage, setEncodingImage] = useState(false); - - const [updateTag] = useTagUpdate(); - const [deleteTag] = useTagDestroy({ id: tag.id }); - - const showAllCounts = uiConfig?.showChildTagContent; +const TagTabs: React.FC<{ + tabKey?: TabKey; + tag: GQL.TagDataFragment; + abbreviateCounter: boolean; + showAllCounts?: boolean; +}> = ({ tabKey, tag, abbreviateCounter, showAllCounts = false }) => { const sceneCount = (showAllCounts ? tag.scene_count_all : tag.scene_count) ?? 0; const imageCount = @@ -143,23 +126,153 @@ const TagPage: React.FC = ({ tag, tabKey }) => { groupCount, ]); - const setTabKey = useCallback( - (newTabKey: string | null) => { - if (!newTabKey) newTabKey = populatedDefaultTab; - if (newTabKey === tabKey) return; + const { setTabKey } = useTabKey({ + tabKey, + validTabs, + defaultTabKey: populatedDefaultTab, + baseURL: `/tags/${tag.id}`, + }); - if (isTabKey(newTabKey)) { - history.replace(`/tags/${tag.id}/${newTabKey}`); - } - }, - [populatedDefaultTab, tabKey, history, tag.id] + return ( + + + } + > + + + + } + > + + + + } + > + + + + } + > + + + + } + > + + + + } + > + + + + } + > + + + ); +}; - useEffect(() => { - if (!tabKey) { - setTabKey(populatedDefaultTab); +const TagPage: React.FC = ({ tag, tabKey }) => { + const history = useHistory(); + const Toast = useToast(); + const intl = useIntl(); + + // Configuration settings + const { configuration } = React.useContext(ConfigurationContext); + const uiConfig = configuration?.ui; + const abbreviateCounter = uiConfig?.abbreviateCounters ?? false; + const enableBackgroundImage = uiConfig?.enableTagBackgroundImage ?? false; + const showAllDetails = uiConfig?.showAllDetails ?? true; + const compactExpandedDetails = uiConfig?.compactExpandedDetails ?? false; + + const [collapsed, setCollapsed] = useState(!showAllDetails); + const loadStickyHeader = useLoadStickyHeader(); + + // Editing state + const [isEditing, setIsEditing] = useState(false); + const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState(false); + const [mergeType, setMergeType] = useState<"from" | "into" | undefined>(); + + // Editing tag state + const [image, setImage] = useState(); + const [encodingImage, setEncodingImage] = useState(false); + + const [updateTag] = useTagUpdate(); + const [deleteTag] = useTagDestroy({ id: tag.id }); + + const showAllCounts = uiConfig?.showChildTagContent; + + const tagImage = useMemo(() => { + let existingImage = tag.image_path; + if (isEditing) { + if (image === null && existingImage) { + const tagImageURL = new URL(existingImage); + tagImageURL.searchParams.set("default", "true"); + return tagImageURL.toString(); + } else if (image) { + return image; + } } - }, [setTabKey, populatedDefaultTab, tabKey]); + + return existingImage; + }, [isEditing, tag.image_path, image]); function setFavorite(v: boolean) { if (tag.id) { @@ -279,35 +392,6 @@ const TagPage: React.FC = ({ tag, tabKey }) => { ); } - function getCollapseButtonIcon() { - return collapsed ? faChevronDown : faChevronUp; - } - - function maybeRenderShowCollapseButton() { - if (!isEditing) { - return ( - - - - ); - } - } - - function maybeRenderAliases() { - if (tag?.aliases?.length) { - return ( -
    - {tag?.aliases?.join(", ")} -
    - ); - } - } - function toggleEditing(value?: boolean) { if (value !== undefined) { setIsEditing(value); @@ -317,34 +401,6 @@ const TagPage: React.FC = ({ tag, tabKey }) => { setImage(undefined); } - function renderImage() { - let tagImage = tag.image_path; - if (isEditing) { - if (image === null && tagImage) { - const tagImageURL = new URL(tagImage); - tagImageURL.searchParams.set("default", "true"); - tagImage = tagImageURL.toString(); - } else if (image) { - tagImage = image; - } - } - - if (tagImage) { - return ; - } - } - - const renderClickableIcons = () => ( - - - - ); - function renderMergeButton() { return ( @@ -386,200 +442,6 @@ const TagPage: React.FC = ({ tag, tabKey }) => { ); } - function maybeRenderDetails() { - if (!isEditing) { - return ( - - ); - } - } - - function maybeRenderEditPanel() { - if (isEditing) { - return ( - toggleEditing()} - onDelete={onDelete} - setImage={setImage} - setEncodingImage={setEncodingImage} - /> - ); - } - { - return ( - toggleEditing()} - onSave={() => {}} - onImageChange={() => {}} - onClearImage={() => {}} - onAutoTag={onAutoTag} - autoTagDisabled={tag.ignore_auto_tag} - onDelete={onDelete} - classNames="mb-2" - customButtons={renderMergeButton()} - /> - ); - } - } - - const renderTabs = () => ( - - - {intl.formatMessage({ id: "scenes" })} - - - } - > - - - - {intl.formatMessage({ id: "images" })} - - - } - > - - - - {intl.formatMessage({ id: "galleries" })} - - - } - > - - - - {intl.formatMessage({ id: "groups" })} - - - } - > - - - - {intl.formatMessage({ id: "markers" })} - - - } - > - - - - {intl.formatMessage({ id: "performers" })} - - - } - > - - - - {intl.formatMessage({ id: "studios" })} - - - } - > - - - - ); - - function maybeRenderHeaderBackgroundImage() { - let tagImage = tag.image_path; - if (enableBackgroundImage && !isEditing && tagImage) { - const tagImageURL = new URL(tagImage); - let isDefaultImage = tagImageURL.searchParams.get("default"); - if (!isDefaultImage) { - return ( -
    - - - {`${tag.name} - -
    - ); - } - } - } - - function maybeRenderTab() { - if (!isEditing) { - return renderTabs(); - } - } - - function maybeRenderCompressedDetails() { - if (!isEditing && loadStickyHeader) { - return ; - } - } - const headerClassName = cx("detail-header", { edit: isEditing, collapsed, @@ -593,35 +455,86 @@ const TagPage: React.FC = ({ tag, tabKey }) => {
    - {maybeRenderHeaderBackgroundImage()} +
    -
    - {encodingImage ? ( - - ) : ( - renderImage() + + {tagImage && ( + )} -
    +
    -

    - {tag.name} - {maybeRenderShowCollapseButton()} - {renderClickableIcons()} -

    - {maybeRenderAliases()} - {maybeRenderDetails()} - {maybeRenderEditPanel()} + + {!isEditing && ( + setCollapsed(v)} + /> + )} + + setFavorite(v)} + /> + + + + + {!isEditing && ( + + )} + {isEditing ? ( + toggleEditing()} + onDelete={onDelete} + setImage={setImage} + setEncodingImage={setEncodingImage} + /> + ) : ( + toggleEditing()} + onSave={() => {}} + onImageChange={() => {}} + onClearImage={() => {}} + onAutoTag={onAutoTag} + autoTagDisabled={tag.ignore_auto_tag} + onDelete={onDelete} + classNames="mb-2" + customButtons={renderMergeButton()} + /> + )}
    - {maybeRenderCompressedDetails()} + + {!isEditing && loadStickyHeader && ( + + )} +
    -
    {maybeRenderTab()}
    +
    + {!isEditing && ( + + )} +
    {renderDeleteAlert()} diff --git a/ui/v2.5/src/hooks/Lightbox/LightboxLink.tsx b/ui/v2.5/src/hooks/Lightbox/LightboxLink.tsx new file mode 100644 index 00000000000..9fcdee1f7fc --- /dev/null +++ b/ui/v2.5/src/hooks/Lightbox/LightboxLink.tsx @@ -0,0 +1,22 @@ +import { PropsWithChildren } from "react"; +import { useLightbox } from "./hooks"; +import { ILightboxImage } from "./types"; +import { Button } from "react-bootstrap"; + +export const LightboxLink: React.FC< + PropsWithChildren<{ images?: ILightboxImage[] | undefined; index?: number }> +> = ({ images, index, children }) => { + const showLightbox = useLightbox({ + images, + }); + + if (!images || images.length === 0) { + return <>{children}; + } + + return ( + + ); +}; From d0caf87eebdca8cee13a4f0ee2a9ca4d36ee321f Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Thu, 4 Jul 2024 10:53:29 +1000 Subject: [PATCH 051/103] Add quick fmt/validate targets --- Makefile | 11 +++++++++++ docs/DEVELOPMENT.md | 3 +++ ui/v2.5/package.json | 3 +++ 3 files changed, 17 insertions(+) diff --git a/Makefile b/Makefile index e3d9dfc2bd7..560ab0466b2 100644 --- a/Makefile +++ b/Makefile @@ -372,6 +372,17 @@ fmt-ui: validate-ui: cd ui/v2.5 && yarn run validate +# these targets run the same steps as fmt-ui and validate-ui, but only on files that have changed +fmt-ui-quick: + cd ui/v2.5 && yarn run prettier --write $$(git diff --name-only --relative --diff-filter d . ../../graphql) + +# does not run tsc checks, as they are slow +validate-ui-quick: + cd ui/v2.5 && \ + yarn run eslint $$(git diff --name-only --relative --diff-filter d src | grep -e "\.tsx\?\$$") && \ + yarn run stylelint $$(git diff --name-only --relative --diff-filter d src | grep "\.scss") && \ + yarn run prettier --check $$(git diff --name-only --relative --diff-filter d . ../../graphql) + # runs all of the backend PR-acceptance steps .PHONY: validate-backend validate-backend: lint it diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 03f6b6939fc..732004ca175 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -69,6 +69,9 @@ NOTE: The `make` command in OpenBSD will be `gmake`. For example, `make pre-ui` * `make it` - Runs all unit and integration tests * `make fmt` - Formats the Go source code * `make fmt-ui` - Formats the UI source code +* `make validate-ui` - Runs tests and checks for the UI only +* `make fmt-ui-quick` - (experimental) Formats only changed UI source code +* `make validate-ui-quick` - (experimental) Runs tests and checks of changed UI code * `make server-start` - Runs a development stash server in the `.local` directory * `make server-clean` - Removes the `.local` directory and all of its contents * `make ui-start` - Runs the UI in development mode. Requires a running Stash server to connect to - the server URL can be changed from the default of `http://localhost:9999` using the environment variable `VITE_APP_PLATFORM_URL`, but keep in mind that authentication cannot be used since the session authorization cookie cannot be sent cross-origin. The UI runs on port `3000` or the next available port. diff --git a/ui/v2.5/package.json b/ui/v2.5/package.json index 1a35cd87035..ca68160622b 100644 --- a/ui/v2.5/package.json +++ b/ui/v2.5/package.json @@ -12,6 +12,9 @@ "lint:css": "stylelint --cache \"src/**/*.scss\"", "lint:js": "eslint --cache src/", "check": "tsc --noEmit", + "eslint": "eslint", + "prettier": "prettier", + "stylelint": "stylelint", "format": "prettier --write . ../../graphql", "format-check": "prettier --check . ../../graphql", "gqlgen": "gql-gen --config codegen.ts", From 6cebf146cbc28620799bdaf71ec1bc2775d80f42 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Thu, 4 Jul 2024 11:11:26 +1000 Subject: [PATCH 052/103] Fix validate-ui-quick to only run required checks --- Makefile | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/Makefile b/Makefile index 560ab0466b2..088c1797d89 100644 --- a/Makefile +++ b/Makefile @@ -379,9 +379,12 @@ fmt-ui-quick: # does not run tsc checks, as they are slow validate-ui-quick: cd ui/v2.5 && \ - yarn run eslint $$(git diff --name-only --relative --diff-filter d src | grep -e "\.tsx\?\$$") && \ - yarn run stylelint $$(git diff --name-only --relative --diff-filter d src | grep "\.scss") && \ - yarn run prettier --check $$(git diff --name-only --relative --diff-filter d . ../../graphql) + tsfiles=$$(git diff --name-only --relative --diff-filter d src | grep -e "\.tsx\?\$$"); \ + scssfiles=$$(git diff --name-only --relative --diff-filter d src | grep "\.scss"); \ + prettyfiles=$$(git diff --name-only --relative --diff-filter d . ../../graphql); \ + if [ -n "$$tsfiles" ]; then yarn run eslint $$tsfiles; fi && \ + if [ -n "$$scssfiles" ]; then yarn run stylelint $$scssfiles; fi && \ + if [ -n "$$prettyfiles" ]; then yarn run prettier --check $$prettyfiles; fi # runs all of the backend PR-acceptance steps .PHONY: validate-backend From f598fa71daaf877fb38808c6c7a9f67e47b4e672 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Thu, 4 Jul 2024 11:24:03 +1000 Subject: [PATCH 053/103] Use the rescan option when rescanning files from menu (#5043) --- ui/v2.5/src/components/Galleries/GalleryDetails/Gallery.tsx | 1 + ui/v2.5/src/components/Images/ImageDetails/Image.tsx | 1 + ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx | 1 + 3 files changed, 3 insertions(+) diff --git a/ui/v2.5/src/components/Galleries/GalleryDetails/Gallery.tsx b/ui/v2.5/src/components/Galleries/GalleryDetails/Gallery.tsx index cd06049edf8..b272eb173bb 100644 --- a/ui/v2.5/src/components/Galleries/GalleryDetails/Gallery.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryDetails/Gallery.tsx @@ -124,6 +124,7 @@ export const GalleryPage: React.FC = ({ gallery, add }) => { await mutateMetadataScan({ paths: [path], + rescan: true, }); Toast.success( diff --git a/ui/v2.5/src/components/Images/ImageDetails/Image.tsx b/ui/v2.5/src/components/Images/ImageDetails/Image.tsx index 281fd99c71f..d2254c5b7bd 100644 --- a/ui/v2.5/src/components/Images/ImageDetails/Image.tsx +++ b/ui/v2.5/src/components/Images/ImageDetails/Image.tsx @@ -80,6 +80,7 @@ const ImagePage: React.FC = ({ image }) => { await mutateMetadataScan({ paths: [objectPath(image)], + rescan: true, }); Toast.success( diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx index a21c23290c9..bc632df34e2 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx @@ -297,6 +297,7 @@ const ScenePage: React.FC = ({ async function onRescan() { await mutateMetadataScan({ paths: [objectPath(scene)], + rescan: true, }); Toast.success( From 3ddfafa83182adf2f2fc029f7013442a6481d814 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Thu, 4 Jul 2024 11:34:44 +1000 Subject: [PATCH 054/103] Fix background image for group and studio --- ui/v2.5/src/components/Groups/GroupDetails/Group.tsx | 2 +- ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/ui/v2.5/src/components/Groups/GroupDetails/Group.tsx b/ui/v2.5/src/components/Groups/GroupDetails/Group.tsx index 3a93abca0ae..7ef2ca8e5fd 100644 --- a/ui/v2.5/src/components/Groups/GroupDetails/Group.tsx +++ b/ui/v2.5/src/components/Groups/GroupDetails/Group.tsx @@ -255,7 +255,7 @@ const GroupPage: React.FC = ({ group }) => {
    diff --git a/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx b/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx index cc4c9ac2b2a..d3c71f7874d 100644 --- a/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx +++ b/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx @@ -400,7 +400,7 @@ const StudioPage: React.FC = ({ studio, tabKey }) => {
    From 720b233be6111a208debcc6aa5403144cfe10a36 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Thu, 4 Jul 2024 11:36:05 +1000 Subject: [PATCH 055/103] Rename movie group backend (#5044) * Rename movie go files * Rename movie package to group --- .../{movieloader_gen.go => grouploader_gen.go} | 0 internal/api/resolver_model_studio.go | 4 ++-- internal/api/resolver_model_tag.go | 4 ++-- internal/api/{routes_movie.go => routes_group.go} | 0 internal/api/urlbuilders/{movie.go => group.go} | 0 internal/manager/task_export.go | 4 ++-- internal/manager/task_import.go | 4 ++-- internal/static/embed.go | 6 +++--- .../static/{movie/movie.png => group/group.png} | Bin pkg/{movie => group}/export.go | 2 +- pkg/{movie => group}/export_test.go | 2 +- pkg/{movie => group}/import.go | 2 +- pkg/{movie => group}/import_test.go | 2 +- pkg/{movie => group}/query.go | 2 +- pkg/models/{movie.go => group.go} | 0 pkg/models/jsonschema/{movie.go => group.go} | 0 pkg/models/{model_movie.go => model_group.go} | 0 .../{repository_movie.go => repository_group.go} | 0 pkg/sqlite/{movies.go => group.go} | 0 pkg/sqlite/{movies_filter.go => group_filter.go} | 0 pkg/sqlite/{movies_test.go => group_test.go} | 0 21 files changed, 16 insertions(+), 16 deletions(-) rename internal/api/loaders/{movieloader_gen.go => grouploader_gen.go} (100%) rename internal/api/{routes_movie.go => routes_group.go} (100%) rename internal/api/urlbuilders/{movie.go => group.go} (100%) rename internal/static/{movie/movie.png => group/group.png} (100%) rename pkg/{movie => group}/export.go (99%) rename pkg/{movie => group}/export_test.go (99%) rename pkg/{movie => group}/import.go (99%) rename pkg/{movie => group}/import_test.go (99%) rename pkg/{movie => group}/query.go (98%) rename pkg/models/{movie.go => group.go} (100%) rename pkg/models/jsonschema/{movie.go => group.go} (100%) rename pkg/models/{model_movie.go => model_group.go} (100%) rename pkg/models/{repository_movie.go => repository_group.go} (100%) rename pkg/sqlite/{movies.go => group.go} (100%) rename pkg/sqlite/{movies_filter.go => group_filter.go} (100%) rename pkg/sqlite/{movies_test.go => group_test.go} (100%) diff --git a/internal/api/loaders/movieloader_gen.go b/internal/api/loaders/grouploader_gen.go similarity index 100% rename from internal/api/loaders/movieloader_gen.go rename to internal/api/loaders/grouploader_gen.go diff --git a/internal/api/resolver_model_studio.go b/internal/api/resolver_model_studio.go index 9dcfd5df5b8..2111039c86b 100644 --- a/internal/api/resolver_model_studio.go +++ b/internal/api/resolver_model_studio.go @@ -6,9 +6,9 @@ import ( "github.com/stashapp/stash/internal/api/loaders" "github.com/stashapp/stash/internal/api/urlbuilders" "github.com/stashapp/stash/pkg/gallery" + "github.com/stashapp/stash/pkg/group" "github.com/stashapp/stash/pkg/image" "github.com/stashapp/stash/pkg/models" - "github.com/stashapp/stash/pkg/movie" "github.com/stashapp/stash/pkg/performer" "github.com/stashapp/stash/pkg/scene" ) @@ -100,7 +100,7 @@ func (r *studioResolver) PerformerCount(ctx context.Context, obj *models.Studio, func (r *studioResolver) GroupCount(ctx context.Context, obj *models.Studio, depth *int) (ret int, err error) { if err := r.withReadTxn(ctx, func(ctx context.Context) error { - ret, err = movie.CountByStudioID(ctx, r.repository.Group, obj.ID, depth) + ret, err = group.CountByStudioID(ctx, r.repository.Group, obj.ID, depth) return err }); err != nil { return 0, err diff --git a/internal/api/resolver_model_tag.go b/internal/api/resolver_model_tag.go index 11b9f935b5e..14237d2fe41 100644 --- a/internal/api/resolver_model_tag.go +++ b/internal/api/resolver_model_tag.go @@ -6,9 +6,9 @@ import ( "github.com/stashapp/stash/internal/api/loaders" "github.com/stashapp/stash/internal/api/urlbuilders" "github.com/stashapp/stash/pkg/gallery" + "github.com/stashapp/stash/pkg/group" "github.com/stashapp/stash/pkg/image" "github.com/stashapp/stash/pkg/models" - "github.com/stashapp/stash/pkg/movie" "github.com/stashapp/stash/pkg/performer" "github.com/stashapp/stash/pkg/scene" "github.com/stashapp/stash/pkg/studio" @@ -122,7 +122,7 @@ func (r *tagResolver) StudioCount(ctx context.Context, obj *models.Tag, depth *i func (r *tagResolver) GroupCount(ctx context.Context, obj *models.Tag, depth *int) (ret int, err error) { if err := r.withReadTxn(ctx, func(ctx context.Context) error { - ret, err = movie.CountByTagID(ctx, r.repository.Group, obj.ID, depth) + ret, err = group.CountByTagID(ctx, r.repository.Group, obj.ID, depth) return err }); err != nil { return 0, err diff --git a/internal/api/routes_movie.go b/internal/api/routes_group.go similarity index 100% rename from internal/api/routes_movie.go rename to internal/api/routes_group.go diff --git a/internal/api/urlbuilders/movie.go b/internal/api/urlbuilders/group.go similarity index 100% rename from internal/api/urlbuilders/movie.go rename to internal/api/urlbuilders/group.go diff --git a/internal/manager/task_export.go b/internal/manager/task_export.go index 15089491424..ecbcf593af5 100644 --- a/internal/manager/task_export.go +++ b/internal/manager/task_export.go @@ -15,13 +15,13 @@ import ( "github.com/stashapp/stash/internal/manager/config" "github.com/stashapp/stash/pkg/fsutil" "github.com/stashapp/stash/pkg/gallery" + "github.com/stashapp/stash/pkg/group" "github.com/stashapp/stash/pkg/image" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models/json" "github.com/stashapp/stash/pkg/models/jsonschema" "github.com/stashapp/stash/pkg/models/paths" - "github.com/stashapp/stash/pkg/movie" "github.com/stashapp/stash/pkg/performer" "github.com/stashapp/stash/pkg/scene" "github.com/stashapp/stash/pkg/sliceutil" @@ -1135,7 +1135,7 @@ func (t *ExportTask) exportGroup(ctx context.Context, wg *sync.WaitGroup, jobCha continue } - newGroupJSON, err := movie.ToJSON(ctx, groupReader, studioReader, m) + newGroupJSON, err := group.ToJSON(ctx, groupReader, studioReader, m) if err != nil { logger.Errorf("[groups] <%s> error getting tag JSON: %v", m.Name, err) diff --git a/internal/manager/task_import.go b/internal/manager/task_import.go index b83ca0b3cc7..ae9a5865765 100644 --- a/internal/manager/task_import.go +++ b/internal/manager/task_import.go @@ -13,12 +13,12 @@ import ( "github.com/stashapp/stash/pkg/file" "github.com/stashapp/stash/pkg/fsutil" "github.com/stashapp/stash/pkg/gallery" + "github.com/stashapp/stash/pkg/group" "github.com/stashapp/stash/pkg/image" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models/jsonschema" "github.com/stashapp/stash/pkg/models/paths" - "github.com/stashapp/stash/pkg/movie" "github.com/stashapp/stash/pkg/performer" "github.com/stashapp/stash/pkg/scene" "github.com/stashapp/stash/pkg/studio" @@ -351,7 +351,7 @@ func (t *ImportTask) ImportGroups(ctx context.Context) { logger.Progressf("[groups] %d of %d", index, len(files)) if err := r.WithTxn(ctx, func(ctx context.Context) error { - groupImporter := &movie.Importer{ + groupImporter := &group.Importer{ ReaderWriter: r.Group, StudioWriter: r.Studio, TagWriter: r.Tag, diff --git a/internal/static/embed.go b/internal/static/embed.go index 7c06c611b1d..3c9323a7073 100644 --- a/internal/static/embed.go +++ b/internal/static/embed.go @@ -7,7 +7,7 @@ import ( "io/fs" ) -//go:embed performer performer_male scene image tag studio movie +//go:embed performer performer_male scene image tag studio group var data embed.FS const ( @@ -26,8 +26,8 @@ const ( Studio = "studio" DefaultStudioImage = "studio/studio.svg" - Group = "movie" - DefaultGroupImage = "movie/movie.png" + Group = "group" + DefaultGroupImage = "group/group.png" ) // Sub returns an FS rooted at path, using fs.Sub. diff --git a/internal/static/movie/movie.png b/internal/static/group/group.png similarity index 100% rename from internal/static/movie/movie.png rename to internal/static/group/group.png diff --git a/pkg/movie/export.go b/pkg/group/export.go similarity index 99% rename from pkg/movie/export.go rename to pkg/group/export.go index 06f4923201b..418ce7bedd1 100644 --- a/pkg/movie/export.go +++ b/pkg/group/export.go @@ -1,4 +1,4 @@ -package movie +package group import ( "context" diff --git a/pkg/movie/export_test.go b/pkg/group/export_test.go similarity index 99% rename from pkg/movie/export_test.go rename to pkg/group/export_test.go index ee83a360a87..5f8d9f7dce6 100644 --- a/pkg/movie/export_test.go +++ b/pkg/group/export_test.go @@ -1,4 +1,4 @@ -package movie +package group import ( "errors" diff --git a/pkg/movie/import.go b/pkg/group/import.go similarity index 99% rename from pkg/movie/import.go rename to pkg/group/import.go index fea410d95eb..4bf038c8776 100644 --- a/pkg/movie/import.go +++ b/pkg/group/import.go @@ -1,4 +1,4 @@ -package movie +package group import ( "context" diff --git a/pkg/movie/import_test.go b/pkg/group/import_test.go similarity index 99% rename from pkg/movie/import_test.go rename to pkg/group/import_test.go index c1d1e18f65e..c4ca47442a5 100644 --- a/pkg/movie/import_test.go +++ b/pkg/group/import_test.go @@ -1,4 +1,4 @@ -package movie +package group import ( "context" diff --git a/pkg/movie/query.go b/pkg/group/query.go similarity index 98% rename from pkg/movie/query.go rename to pkg/group/query.go index 8b2e5baef57..bc0753b0055 100644 --- a/pkg/movie/query.go +++ b/pkg/group/query.go @@ -1,4 +1,4 @@ -package movie +package group import ( "context" diff --git a/pkg/models/movie.go b/pkg/models/group.go similarity index 100% rename from pkg/models/movie.go rename to pkg/models/group.go diff --git a/pkg/models/jsonschema/movie.go b/pkg/models/jsonschema/group.go similarity index 100% rename from pkg/models/jsonschema/movie.go rename to pkg/models/jsonschema/group.go diff --git a/pkg/models/model_movie.go b/pkg/models/model_group.go similarity index 100% rename from pkg/models/model_movie.go rename to pkg/models/model_group.go diff --git a/pkg/models/repository_movie.go b/pkg/models/repository_group.go similarity index 100% rename from pkg/models/repository_movie.go rename to pkg/models/repository_group.go diff --git a/pkg/sqlite/movies.go b/pkg/sqlite/group.go similarity index 100% rename from pkg/sqlite/movies.go rename to pkg/sqlite/group.go diff --git a/pkg/sqlite/movies_filter.go b/pkg/sqlite/group_filter.go similarity index 100% rename from pkg/sqlite/movies_filter.go rename to pkg/sqlite/group_filter.go diff --git a/pkg/sqlite/movies_test.go b/pkg/sqlite/group_test.go similarity index 100% rename from pkg/sqlite/movies_test.go rename to pkg/sqlite/group_test.go From bfd8e81ffd2b24ce07e46ce7b2191e2828115df0 Mon Sep 17 00:00:00 2001 From: dogwithakeyboard <128322708+dogwithakeyboard@users.noreply.github.com> Date: Tue, 16 Jul 2024 04:16:57 +0100 Subject: [PATCH 056/103] add birthdate to performer select and restyle (#5076) --- ui/v2.5/graphql/data/performer-slim.graphql | 2 + .../components/Performers/PerformerSelect.tsx | 70 ++++++++++++++----- ui/v2.5/src/components/Performers/styles.scss | 52 ++++++++++---- 3 files changed, 91 insertions(+), 33 deletions(-) diff --git a/ui/v2.5/graphql/data/performer-slim.graphql b/ui/v2.5/graphql/data/performer-slim.graphql index d9f5f423383..1a4b9833bc8 100644 --- a/ui/v2.5/graphql/data/performer-slim.graphql +++ b/ui/v2.5/graphql/data/performer-slim.graphql @@ -39,4 +39,6 @@ fragment SelectPerformerData on Performer { disambiguation alias_list image_path + birthdate + death_date } diff --git a/ui/v2.5/src/components/Performers/PerformerSelect.tsx b/ui/v2.5/src/components/Performers/PerformerSelect.tsx index 17d885d5a1e..1b24b737a13 100644 --- a/ui/v2.5/src/components/Performers/PerformerSelect.tsx +++ b/ui/v2.5/src/components/Performers/PerformerSelect.tsx @@ -28,6 +28,8 @@ import { useCompare } from "src/hooks/state"; import { Link } from "react-router-dom"; import { sortByRelevance } from "src/utils/query"; import { PatchComponent, PatchFunction } from "src/patch"; +import { TruncatedText } from "../Shared/TruncatedText"; +import TextUtils from "src/utils/text"; export type SelectObject = { id: string; @@ -37,7 +39,13 @@ export type SelectObject = { export type Performer = Pick< GQL.Performer, - "id" | "name" | "alias_list" | "disambiguation" | "image_path" + | "id" + | "name" + | "alias_list" + | "disambiguation" + | "image_path" + | "birthdate" + | "death_date" >; type Option = SelectOption; @@ -112,23 +120,49 @@ const _PerformerSelect: React.FC< thisOptionProps = { ...optionProps, children: ( - - - - - {name} - {object.disambiguation && ( - {` (${object.disambiguation})`} - )} - {alias && {` (${alias})`}} + + + + + + + + {name} + {alias && ( + {` (${alias})`} + )} + + } + lineCount={1} + /> + + {object.disambiguation && ( + + {object.disambiguation} + + )} + + {object.birthdate && ( + {`${ + object.birthdate + } (${TextUtils.age( + object.birthdate, + object.death_date + )})`} + )} + + ), }; diff --git a/ui/v2.5/src/components/Performers/styles.scss b/ui/v2.5/src/components/Performers/styles.scss index 3dbee55c95e..b3531b0008a 100644 --- a/ui/v2.5/src/components/Performers/styles.scss +++ b/ui/v2.5/src/components/Performers/styles.scss @@ -198,24 +198,46 @@ } } -.performer-select { - .performer-disambiguation { - white-space: pre; - } +.performer-select-option { + .performer-select-row { + align-items: center; + display: flex; + width: 100%; - .performer-select-value .performer-disambiguation { - color: initial; - } + .performer-select-image { + margin-right: 0.4em; + max-height: 50px; + max-width: 50px; + } - .alias { - font-weight: bold; - white-space: pre; - } + .performer-select-details { + display: flex; + flex-direction: column; + justify-content: flex-start; + max-height: 4.1rem; + overflow: hidden; + + .performer-select-name { + flex-shrink: 0; + white-space: pre-wrap; + word-break: break-all; + + .performer-select-alias { + font-size: 0.8rem; + font-weight: bold; + } + } - .performer-select-image { - margin-right: 0.5em; - max-height: 50px; - max-width: 50px; + .performer-select-disambiguation, + .performer-select-birthdate { + color: $text-muted; + flex-shrink: 0; + font-size: 0.9rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + } } } From f79677ba96b745ab50a0176e946647dea6951949 Mon Sep 17 00:00:00 2001 From: Lenny3D <83949327+Lenny3D@users.noreply.github.com> Date: Tue, 16 Jul 2024 05:17:18 +0200 Subject: [PATCH 057/103] Copy apikey query parameter to DASH & HLS manifest (#5061) * Copy apikey query parameter to DASH & HLS manifest When an API key is provided to the DASH and HLS manifest endpoints, this it will now be copied to the URLs inside the manifest. This allows for clients that are only able to pass an URL to an (external) video player to function in case authentication is set up on stash. --- pkg/ffmpeg/stream_segmented.go | 43 ++++++++++++++++++++++++++++------ 1 file changed, 36 insertions(+), 7 deletions(-) diff --git a/pkg/ffmpeg/stream_segmented.go b/pkg/ffmpeg/stream_segmented.go index 56ef392f152..7d529b82c41 100644 --- a/pkg/ffmpeg/stream_segmented.go +++ b/pkg/ffmpeg/stream_segmented.go @@ -8,6 +8,7 @@ import ( "io" "math" "net/http" + "net/url" "os" "os/exec" "path/filepath" @@ -45,6 +46,10 @@ const ( // maximum idle time between segment requests before // stopping transcode and deleting cache folder maxIdleTime = 30 * time.Second + + resolutionParamKey = "resolution" + // TODO - setting the apikey in here isn't ideal + apiKeyParamKey = "apikey" ) type StreamType struct { @@ -425,9 +430,21 @@ func serveHLSManifest(sm *StreamManager, w http.ResponseWriter, r *http.Request, baseUrl.RawQuery = "" baseURL := baseUrl.String() - var urlQuery string + urlQuery := url.Values{} + apikey := r.URL.Query().Get(apiKeyParamKey) + if resolution != "" { - urlQuery = fmt.Sprintf("?resolution=%s", resolution) + urlQuery.Set(resolutionParamKey, resolution) + } + + // TODO - this needs to be handled outside of this package + if apikey != "" { + urlQuery.Set(apiKeyParamKey, apikey) + } + + urlQueryString := "" + if len(urlQuery) > 0 { + urlQueryString = "?" + urlQuery.Encode() } var buf bytes.Buffer @@ -449,7 +466,7 @@ func serveHLSManifest(sm *StreamManager, w http.ResponseWriter, r *http.Request, } fmt.Fprintf(&buf, "#EXTINF:%f,\n", thisLength) - fmt.Fprintf(&buf, "%s/%d.ts%s\n", baseURL, segment, urlQuery) + fmt.Fprintf(&buf, "%s/%d.ts%s\n", baseURL, segment, urlQueryString) leftover -= thisLength segment++ @@ -508,11 +525,18 @@ func serveDASHManifest(sm *StreamManager, w http.ResponseWriter, r *http.Request videoWidth = vf.Width } - var urlQuery string + urlQuery := url.Values{} + + // TODO - this needs to be handled outside of this package + apikey := r.URL.Query().Get(apiKeyParamKey) + if apikey != "" { + urlQuery.Set(apiKeyParamKey, apikey) + } + maxTranscodeSize := sm.config.GetMaxStreamingTranscodeSize().GetMaxResolution() if resolution != "" { maxTranscodeSize = models.StreamingResolutionEnum(resolution).GetMaxResolution() - urlQuery = fmt.Sprintf("?resolution=%s", resolution) + urlQuery.Set(resolutionParamKey, resolution) } if maxTranscodeSize != 0 { videoSize := videoHeight @@ -527,6 +551,11 @@ func serveDASHManifest(sm *StreamManager, w http.ResponseWriter, r *http.Request } } + urlQueryString := "" + if len(urlQuery) > 0 { + urlQueryString = "?" + urlQuery.Encode() + } + mediaDuration := mpd.Duration(time.Duration(probeResult.FileDuration * float64(time.Second))) m := mpd.NewMPD(mpd.DASH_PROFILE_LIVE, mediaDuration.String(), "PT4.0S") @@ -536,12 +565,12 @@ func serveDASHManifest(sm *StreamManager, w http.ResponseWriter, r *http.Request video, _ := m.AddNewAdaptationSetVideo(MimeWebmVideo, "progressive", true, 1) - _, _ = video.SetNewSegmentTemplate(2, "init_v.webm"+urlQuery, "$Number$_v.webm"+urlQuery, 0, 1) + _, _ = video.SetNewSegmentTemplate(2, "init_v.webm"+urlQueryString, "$Number$_v.webm"+urlQueryString, 0, 1) _, _ = video.AddNewRepresentationVideo(200000, "vp09.00.40.08", "0", framerate, int64(videoWidth), int64(videoHeight)) if ProbeAudioCodec(vf.AudioCodec) != MissingUnsupported { audio, _ := m.AddNewAdaptationSetAudio(MimeWebmAudio, true, 1, "und") - _, _ = audio.SetNewSegmentTemplate(2, "init_a.webm"+urlQuery, "$Number$_a.webm"+urlQuery, 0, 1) + _, _ = audio.SetNewSegmentTemplate(2, "init_a.webm"+urlQueryString, "$Number$_a.webm"+urlQueryString, 0, 1) _, _ = audio.AddNewRepresentationAudio(48000, 96000, "opus", "1") } From 5512d37da3cdb0b26238ba18a8b86b2d67071ed0 Mon Sep 17 00:00:00 2001 From: CJ <72030708+cj12312021@users.noreply.github.com> Date: Sun, 28 Jul 2024 23:38:37 -0500 Subject: [PATCH 058/103] fix missing transgender color icon (#5090) --- ui/v2.5/src/components/Performers/styles.scss | 1 + 1 file changed, 1 insertion(+) diff --git a/ui/v2.5/src/components/Performers/styles.scss b/ui/v2.5/src/components/Performers/styles.scss index b3531b0008a..b1e59653e8e 100644 --- a/ui/v2.5/src/components/Performers/styles.scss +++ b/ui/v2.5/src/components/Performers/styles.scss @@ -150,6 +150,7 @@ color: #f38cac; } +.fa-transgender, .fa-transgender-alt { color: #c8a2c8; } From 48c6373afaacfbb3919da7a7fc976030455870b3 Mon Sep 17 00:00:00 2001 From: thundxrr <159169975+thundxrr@users.noreply.github.com> Date: Mon, 29 Jul 2024 18:54:04 +0700 Subject: [PATCH 059/103] Added detection for n-prefixed ffmpeg version string (#5102) --- pkg/ffmpeg/ffmpeg.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/ffmpeg/ffmpeg.go b/pkg/ffmpeg/ffmpeg.go index 5ee98a87332..c32a3e2fd5e 100644 --- a/pkg/ffmpeg/ffmpeg.go +++ b/pkg/ffmpeg/ffmpeg.go @@ -158,7 +158,7 @@ func (f *FFMpeg) getVersion() error { return err } - version_re := regexp.MustCompile(`ffmpeg version ((\d+)\.(\d+)(?:\.(\d+))?)`) + version_re := regexp.MustCompile(`ffmpeg version n?((\d+)\.(\d+)(?:\.(\d+))?)`) stdoutStr := stdout.String() match := version_re.FindStringSubmatchIndex(stdoutStr) if match == nil { From d96850c00811dff4ac0345a6f568981c35128289 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Tue, 30 Jul 2024 14:14:16 +1000 Subject: [PATCH 060/103] Rename movie tables to groups in database schema (#5082) * Rename movie tables to groups * Correct index name * Rename synopsis to description in schema --- pkg/sqlite/anonymise.go | 22 +++---- pkg/sqlite/database.go | 2 +- pkg/sqlite/group.go | 60 +++++++++---------- pkg/sqlite/group_filter.go | 58 +++++++++--------- .../migrations/65_movie_group_rename.up.sql | 24 ++++++++ pkg/sqlite/scene.go | 4 +- pkg/sqlite/scene_filter.go | 10 ++-- pkg/sqlite/table.go | 8 +-- pkg/sqlite/tag.go | 8 +-- pkg/sqlite/tag_filter.go | 4 +- 10 files changed, 112 insertions(+), 88 deletions(-) create mode 100644 pkg/sqlite/migrations/65_movie_group_rename.up.sql diff --git a/pkg/sqlite/anonymise.go b/pkg/sqlite/anonymise.go index 1dc12df1c96..1f595128c68 100644 --- a/pkg/sqlite/anonymise.go +++ b/pkg/sqlite/anonymise.go @@ -86,8 +86,8 @@ func (db *Anonymiser) deleteBlobs() error { func() error { return db.truncateColumn("studios", "image_blob") }, func() error { return db.truncateColumn("performers", "image_blob") }, func() error { return db.truncateColumn("scenes", "cover_blob") }, - func() error { return db.truncateColumn("movies", "front_image_blob") }, - func() error { return db.truncateColumn("movies", "back_image_blob") }, + func() error { return db.truncateColumn("groups", "front_image_blob") }, + func() error { return db.truncateColumn("groups", "back_image_blob") }, func() error { return db.truncateTable("blobs") }, }) @@ -838,7 +838,7 @@ func (db *Anonymiser) anonymiseGroups(ctx context.Context) error { table.Col(idColumn), table.Col("name"), table.Col("aliases"), - table.Col("synopsis"), + table.Col("description"), table.Col("director"), ).Where(table.Col(idColumn).Gt(lastID)).Limit(1000) @@ -847,18 +847,18 @@ func (db *Anonymiser) anonymiseGroups(ctx context.Context) error { const single = false return queryFunc(ctx, query, single, func(rows *sqlx.Rows) error { var ( - id int - name sql.NullString - aliases sql.NullString - synopsis sql.NullString - director sql.NullString + id int + name sql.NullString + aliases sql.NullString + description sql.NullString + director sql.NullString ) if err := rows.Scan( &id, &name, &aliases, - &synopsis, + &description, &director, ); err != nil { return err @@ -867,7 +867,7 @@ func (db *Anonymiser) anonymiseGroups(ctx context.Context) error { set := goqu.Record{} db.obfuscateNullString(set, "name", name) db.obfuscateNullString(set, "aliases", aliases) - db.obfuscateNullString(set, "synopsis", synopsis) + db.obfuscateNullString(set, "description", description) db.obfuscateNullString(set, "director", director) if len(set) > 0 { @@ -893,7 +893,7 @@ func (db *Anonymiser) anonymiseGroups(ctx context.Context) error { } } - if err := db.anonymiseURLs(ctx, goqu.T(groupURLsTable), "movie_id"); err != nil { + if err := db.anonymiseURLs(ctx, goqu.T(groupURLsTable), "group_id"); err != nil { return err } diff --git a/pkg/sqlite/database.go b/pkg/sqlite/database.go index 675ee4972f6..3486313840f 100644 --- a/pkg/sqlite/database.go +++ b/pkg/sqlite/database.go @@ -30,7 +30,7 @@ const ( dbConnTimeout = 30 ) -var appSchemaVersion uint = 64 +var appSchemaVersion uint = 65 //go:embed migrations/*.sql var migrationsBox embed.FS diff --git a/pkg/sqlite/group.go b/pkg/sqlite/group.go index ca9b0b8f244..21c224242f8 100644 --- a/pkg/sqlite/group.go +++ b/pkg/sqlite/group.go @@ -17,15 +17,15 @@ import ( ) const ( - groupTable = "movies" - groupIDColumn = "movie_id" + groupTable = "groups" + groupIDColumn = "group_id" groupFrontImageBlobColumn = "front_image_blob" groupBackImageBlobColumn = "back_image_blob" - groupsTagsTable = "movies_tags" + groupsTagsTable = "groups_tags" - groupURLsTable = "movie_urls" + groupURLsTable = "group_urls" groupURLColumn = "url" ) @@ -36,12 +36,12 @@ type groupRow struct { Duration null.Int `db:"duration"` Date NullDate `db:"date"` // expressed as 1-100 - Rating null.Int `db:"rating"` - StudioID null.Int `db:"studio_id,omitempty"` - Director zero.String `db:"director"` - Synopsis zero.String `db:"synopsis"` - CreatedAt Timestamp `db:"created_at"` - UpdatedAt Timestamp `db:"updated_at"` + Rating null.Int `db:"rating"` + StudioID null.Int `db:"studio_id,omitempty"` + Director zero.String `db:"director"` + Description zero.String `db:"description"` + CreatedAt Timestamp `db:"created_at"` + UpdatedAt Timestamp `db:"updated_at"` // not used in resolutions or updates FrontImageBlob zero.String `db:"front_image_blob"` @@ -57,7 +57,7 @@ func (r *groupRow) fromGroup(o models.Group) { r.Rating = intFromPtr(o.Rating) r.StudioID = intFromPtr(o.StudioID) r.Director = zero.StringFrom(o.Director) - r.Synopsis = zero.StringFrom(o.Synopsis) + r.Description = zero.StringFrom(o.Synopsis) r.CreatedAt = Timestamp{Timestamp: o.CreatedAt} r.UpdatedAt = Timestamp{Timestamp: o.UpdatedAt} } @@ -72,7 +72,7 @@ func (r *groupRow) resolve() *models.Group { Rating: nullIntPtr(r.Rating), StudioID: nullIntPtr(r.StudioID), Director: r.Director.String, - Synopsis: r.Synopsis.String, + Synopsis: r.Description.String, CreatedAt: r.CreatedAt.Timestamp, UpdatedAt: r.UpdatedAt.Timestamp, } @@ -92,7 +92,7 @@ func (r *groupRowRecord) fromPartial(o models.GroupPartial) { r.setNullInt("rating", o.Rating) r.setNullInt("studio_id", o.StudioID) r.setNullString("director", o.Director) - r.setNullString("synopsis", o.Synopsis) + r.setNullString("description", o.Synopsis) r.setTimestamp("created_at", o.CreatedAt) r.setTimestamp("updated_at", o.UpdatedAt) } @@ -330,7 +330,7 @@ func (qb *GroupStore) getMany(ctx context.Context, q *goqu.SelectDataset) ([]*mo } func (qb *GroupStore) FindByName(ctx context.Context, name string, nocase bool) (*models.Group, error) { - // query := "SELECT * FROM movies WHERE name = ?" + // query := "SELECT * FROM groups WHERE name = ?" // if nocase { // query += " COLLATE NOCASE" // } @@ -350,7 +350,7 @@ func (qb *GroupStore) FindByName(ctx context.Context, name string, nocase bool) } func (qb *GroupStore) FindByNames(ctx context.Context, names []string, nocase bool) ([]*models.Group, error) { - // query := "SELECT * FROM movies WHERE name" + // query := "SELECT * FROM groups WHERE name" // if nocase { // query += " COLLATE NOCASE" // } @@ -400,7 +400,7 @@ func (qb *GroupStore) makeQuery(ctx context.Context, groupFilter *models.GroupFi distinctIDs(&query, groupTable) if q := findFilter.Q; q != nil && *q != "" { - searchColumns := []string{"movies.name", "movies.aliases"} + searchColumns := []string{"groups.name", "groups.aliases"} query.parseQueryString(searchColumns, *q) } @@ -487,11 +487,11 @@ func (qb *GroupStore) getGroupSort(findFilter *models.FindFilterType) (string, e case "scenes_count": // generic getSort won't work for this sortQuery += getCountSort(groupTable, groupsScenesTable, groupIDColumn, direction) default: - sortQuery += getSort(sort, direction, "movies") + sortQuery += getSort(sort, direction, "groups") } // Whatever the sorting, always use name/id as a final sort - sortQuery += ", COALESCE(movies.name, movies.id) COLLATE NATURAL_CI ASC" + sortQuery += ", COALESCE(groups.name, groups.id) COLLATE NATURAL_CI ASC" return sortQuery, nil } @@ -551,10 +551,10 @@ func (qb *GroupStore) HasBackImage(ctx context.Context, groupID int) (bool, erro } func (qb *GroupStore) FindByPerformerID(ctx context.Context, performerID int) ([]*models.Group, error) { - query := `SELECT DISTINCT movies.* -FROM movies -INNER JOIN movies_scenes ON movies.id = movies_scenes.movie_id -INNER JOIN performers_scenes ON performers_scenes.scene_id = movies_scenes.scene_id + query := `SELECT DISTINCT groups.* +FROM groups +INNER JOIN groups_scenes ON groups.id = groups_scenes.group_id +INNER JOIN performers_scenes ON performers_scenes.scene_id = groups_scenes.scene_id WHERE performers_scenes.performer_id = ? ` args := []interface{}{performerID} @@ -562,9 +562,9 @@ WHERE performers_scenes.performer_id = ? } func (qb *GroupStore) CountByPerformerID(ctx context.Context, performerID int) (int, error) { - query := `SELECT COUNT(DISTINCT movies_scenes.movie_id) AS count -FROM movies_scenes -INNER JOIN performers_scenes ON performers_scenes.scene_id = movies_scenes.scene_id + query := `SELECT COUNT(DISTINCT groups_scenes.group_id) AS count +FROM groups_scenes +INNER JOIN performers_scenes ON performers_scenes.scene_id = groups_scenes.scene_id WHERE performers_scenes.performer_id = ? ` args := []interface{}{performerID} @@ -572,9 +572,9 @@ WHERE performers_scenes.performer_id = ? } func (qb *GroupStore) FindByStudioID(ctx context.Context, studioID int) ([]*models.Group, error) { - query := `SELECT movies.* -FROM movies -WHERE movies.studio_id = ? + query := `SELECT groups.* +FROM groups +WHERE groups.studio_id = ? ` args := []interface{}{studioID} return qb.queryGroups(ctx, query, args) @@ -582,8 +582,8 @@ WHERE movies.studio_id = ? func (qb *GroupStore) CountByStudioID(ctx context.Context, studioID int) (int, error) { query := `SELECT COUNT(1) AS count -FROM movies -WHERE movies.studio_id = ? +FROM groups +WHERE groups.studio_id = ? ` args := []interface{}{studioID} return groupRepository.runCountQuery(ctx, query, args) diff --git a/pkg/sqlite/group_filter.go b/pkg/sqlite/group_filter.go index 54c4cd03b29..97bde1f2474 100644 --- a/pkg/sqlite/group_filter.go +++ b/pkg/sqlite/group_filter.go @@ -54,32 +54,32 @@ func (qb *groupFilterHandler) handle(ctx context.Context, f *filterBuilder) { func (qb *groupFilterHandler) criterionHandler() criterionHandler { groupFilter := qb.groupFilter return compoundHandler{ - stringCriterionHandler(groupFilter.Name, "movies.name"), - stringCriterionHandler(groupFilter.Director, "movies.director"), - stringCriterionHandler(groupFilter.Synopsis, "movies.synopsis"), - intCriterionHandler(groupFilter.Rating100, "movies.rating", nil), - floatIntCriterionHandler(groupFilter.Duration, "movies.duration", nil), + stringCriterionHandler(groupFilter.Name, "groups.name"), + stringCriterionHandler(groupFilter.Director, "groups.director"), + stringCriterionHandler(groupFilter.Synopsis, "groups.description"), + intCriterionHandler(groupFilter.Rating100, "groups.rating", nil), + floatIntCriterionHandler(groupFilter.Duration, "groups.duration", nil), qb.missingCriterionHandler(groupFilter.IsMissing), qb.urlsCriterionHandler(groupFilter.URL), studioCriterionHandler(groupTable, groupFilter.Studios), qb.performersCriterionHandler(groupFilter.Performers), qb.tagsCriterionHandler(groupFilter.Tags), qb.tagCountCriterionHandler(groupFilter.TagCount), - &dateCriterionHandler{groupFilter.Date, "movies.date", nil}, - ×tampCriterionHandler{groupFilter.CreatedAt, "movies.created_at", nil}, - ×tampCriterionHandler{groupFilter.UpdatedAt, "movies.updated_at", nil}, + &dateCriterionHandler{groupFilter.Date, "groups.date", nil}, + ×tampCriterionHandler{groupFilter.CreatedAt, "groups.created_at", nil}, + ×tampCriterionHandler{groupFilter.UpdatedAt, "groups.updated_at", nil}, &relatedFilterHandler{ - relatedIDCol: "movies_scenes.scene_id", + relatedIDCol: "groups_scenes.scene_id", relatedRepo: sceneRepository.repository, relatedHandler: &sceneFilterHandler{groupFilter.ScenesFilter}, joinFn: func(f *filterBuilder) { - groupRepository.scenes.innerJoin(f, "", "movies.id") + groupRepository.scenes.innerJoin(f, "", "groups.id") }, }, &relatedFilterHandler{ - relatedIDCol: "movies.studio_id", + relatedIDCol: "groups.studio_id", relatedRepo: studioRepository.repository, relatedHandler: &studioFilterHandler{groupFilter.StudiosFilter}, }, @@ -91,14 +91,14 @@ func (qb *groupFilterHandler) missingCriterionHandler(isMissing *string) criteri if isMissing != nil && *isMissing != "" { switch *isMissing { case "front_image": - f.addWhere("movies.front_image_blob IS NULL") + f.addWhere("groups.front_image_blob IS NULL") case "back_image": - f.addWhere("movies.back_image_blob IS NULL") + f.addWhere("groups.back_image_blob IS NULL") case "scenes": - f.addLeftJoin("movies_scenes", "", "movies_scenes.movie_id = movies.id") - f.addWhere("movies_scenes.scene_id IS NULL") + f.addLeftJoin("groups_scenes", "", "groups_scenes.group_id = groups.id") + f.addWhere("groups_scenes.scene_id IS NULL") default: - f.addWhere("(movies." + *isMissing + " IS NULL OR TRIM(movies." + *isMissing + ") = '')") + f.addWhere("(groups." + *isMissing + " IS NULL OR TRIM(groups." + *isMissing + ") = '')") } } } @@ -111,7 +111,7 @@ func (qb *groupFilterHandler) urlsCriterionHandler(url *models.StringCriterionIn joinTable: groupURLsTable, stringColumn: groupURLColumn, addJoinTable: func(f *filterBuilder) { - groupsURLsTableMgr.join(f, "", "movies.id") + groupsURLsTableMgr.join(f, "", "groups.id") }, } @@ -127,8 +127,8 @@ func (qb *groupFilterHandler) performersCriterionHandler(performers *models.Mult notClause = "NOT" } - f.addLeftJoin("movies_scenes", "", "movies.id = movies_scenes.movie_id") - f.addLeftJoin("performers_scenes", "", "movies_scenes.scene_id = performers_scenes.scene_id") + f.addLeftJoin("groups_scenes", "", "groups.id = groups_scenes.group_id") + f.addLeftJoin("performers_scenes", "", "groups_scenes.scene_id = performers_scenes.scene_id") f.addWhere(fmt.Sprintf("performers_scenes.performer_id IS %s NULL", notClause)) return @@ -144,22 +144,22 @@ func (qb *groupFilterHandler) performersCriterionHandler(performers *models.Mult } // Hack, can't apply args to join, nor inner join on a left join, so use CTE instead - f.addWith(`movies_performers AS ( - SELECT movies_scenes.movie_id, performers_scenes.performer_id - FROM movies_scenes - INNER JOIN performers_scenes ON movies_scenes.scene_id = performers_scenes.scene_id + f.addWith(`groups_performers AS ( + SELECT groups_scenes.group_id, performers_scenes.performer_id + FROM groups_scenes + INNER JOIN performers_scenes ON groups_scenes.scene_id = performers_scenes.scene_id WHERE performers_scenes.performer_id IN`+getInBinding(len(performers.Value))+` )`, args...) - f.addLeftJoin("movies_performers", "", "movies.id = movies_performers.movie_id") + f.addLeftJoin("groups_performers", "", "groups.id = groups_performers.group_id") switch performers.Modifier { case models.CriterionModifierIncludes: - f.addWhere("movies_performers.performer_id IS NOT NULL") + f.addWhere("groups_performers.performer_id IS NOT NULL") case models.CriterionModifierIncludesAll: - f.addWhere("movies_performers.performer_id IS NOT NULL") - f.addHaving("COUNT(DISTINCT movies_performers.performer_id) = ?", len(performers.Value)) + f.addWhere("groups_performers.performer_id IS NOT NULL") + f.addHaving("COUNT(DISTINCT groups_performers.performer_id) = ?", len(performers.Value)) case models.CriterionModifierExcludes: - f.addWhere("movies_performers.performer_id IS NULL") + f.addWhere("groups_performers.performer_id IS NULL") } } } @@ -172,7 +172,7 @@ func (qb *groupFilterHandler) tagsCriterionHandler(tags *models.HierarchicalMult foreignFK: "tag_id", relationsTable: "tags_relations", - joinAs: "movie_tag", + joinAs: "group_tag", joinTable: groupsTagsTable, primaryFK: groupIDColumn, } diff --git a/pkg/sqlite/migrations/65_movie_group_rename.up.sql b/pkg/sqlite/migrations/65_movie_group_rename.up.sql new file mode 100644 index 00000000000..d8b41955b4c --- /dev/null +++ b/pkg/sqlite/migrations/65_movie_group_rename.up.sql @@ -0,0 +1,24 @@ +ALTER TABLE `movies` RENAME TO `groups`; +ALTER TABLE `groups` RENAME COLUMN `synopsis` TO `description`; + +DROP INDEX `index_movies_on_name`; +CREATE INDEX `index_groups_on_name` ON `groups`(`name`); +DROP INDEX `index_movies_on_studio_id`; +CREATE INDEX `index_groups_on_studio_id` on `groups` (`studio_id`); + +ALTER TABLE `movie_urls` RENAME TO `group_urls`; +ALTER TABLE `group_urls` RENAME COLUMN `movie_id` TO `group_id`; + +DROP INDEX `movie_urls_url`; +CREATE INDEX `group_urls_url` on `group_urls` (`url`); + +ALTER TABLE `movies_tags` RENAME TO `groups_tags`; +ALTER TABLE `groups_tags` RENAME COLUMN `movie_id` TO `group_id`; + +DROP INDEX `index_movies_tags_on_tag_id`; +CREATE INDEX `index_groups_tags_on_tag_id` on `groups_tags` (`tag_id`); +DROP INDEX `index_movies_tags_on_movie_id`; +CREATE INDEX `index_groups_tags_on_movie_id` on `groups_tags` (`group_id`); + +ALTER TABLE `movies_scenes` RENAME TO `groups_scenes`; +ALTER TABLE `groups_scenes` RENAME COLUMN `movie_id` TO `group_id`; diff --git a/pkg/sqlite/scene.go b/pkg/sqlite/scene.go index 5b4caff11bf..99da461e7d5 100644 --- a/pkg/sqlite/scene.go +++ b/pkg/sqlite/scene.go @@ -28,7 +28,7 @@ const ( performersScenesTable = "performers_scenes" scenesTagsTable = "scenes_tags" scenesGalleriesTable = "scenes_galleries" - groupsScenesTable = "movies_scenes" + groupsScenesTable = "groups_scenes" scenesURLsTable = "scene_urls" sceneURLColumn = "url" scenesViewDatesTable = "scenes_view_dates" @@ -1142,7 +1142,7 @@ func (qb *SceneStore) setSceneSort(query *queryBuilder, findFilter *models.FindF direction := findFilter.GetDirection() switch sort { case "movie_scene_number", "group_scene_number": - query.join(groupsScenesTable, "", "scenes.id = movies_scenes.scene_id") + query.join(groupsScenesTable, "", "scenes.id = groups_scenes.scene_id") query.sortAndPagination += getSort("scene_index", direction, groupsScenesTable) case "tag_count": query.sortAndPagination += getCountSort(sceneTable, scenesTagsTable, sceneIDColumn, direction) diff --git a/pkg/sqlite/scene_filter.go b/pkg/sqlite/scene_filter.go index 53eacaf3446..3f2233395fa 100644 --- a/pkg/sqlite/scene_filter.go +++ b/pkg/sqlite/scene_filter.go @@ -194,7 +194,7 @@ func (qb *sceneFilterHandler) criterionHandler() criterionHandler { }, &relatedFilterHandler{ - relatedIDCol: "movies_scenes.movie_id", + relatedIDCol: "groups_scenes.group_id", relatedRepo: groupRepository.repository, relatedHandler: &groupFilterHandler{sceneFilter.MoviesFilter}, joinFn: func(f *filterBuilder) { @@ -320,8 +320,8 @@ func (qb *sceneFilterHandler) isMissingCriterionHandler(isMissing *string) crite case "studio": f.addWhere("scenes.studio_id IS NULL") case "movie": - sceneRepository.groups.join(f, "movies_join", "scenes.id") - f.addWhere("movies_join.scene_id IS NULL") + sceneRepository.groups.join(f, "groups_join", "scenes.id") + f.addWhere("groups_join.scene_id IS NULL") case "performers": sceneRepository.performers.join(f, "performers_join", "scenes.id") f.addWhere("performers_join.scene_id IS NULL") @@ -486,9 +486,9 @@ func (qb *sceneFilterHandler) performerAgeCriterionHandler(performerAge *models. func (qb *sceneFilterHandler) groupsCriterionHandler(movies *models.MultiCriterionInput) criterionHandlerFunc { addJoinsFunc := func(f *filterBuilder) { sceneRepository.groups.join(f, "", "scenes.id") - f.addLeftJoin("movies", "", "movies_scenes.movie_id = movies.id") + f.addLeftJoin("groups", "", "groups_scenes.group_id = groups.id") } - h := qb.getMultiCriterionHandlerBuilder(groupTable, groupsScenesTable, "movie_id", addJoinsFunc) + h := qb.getMultiCriterionHandlerBuilder(groupTable, groupsScenesTable, "group_id", addJoinsFunc) return h.handler(movies) } diff --git a/pkg/sqlite/table.go b/pkg/sqlite/table.go index 65716cff35e..240918f3e0c 100644 --- a/pkg/sqlite/table.go +++ b/pkg/sqlite/table.go @@ -594,7 +594,7 @@ type scenesGroupsTable struct { type groupsScenesRow struct { SceneID null.Int `db:"scene_id"` - GroupID null.Int `db:"movie_id"` + GroupID null.Int `db:"group_id"` SceneIndex null.Int `db:"scene_index"` } @@ -606,7 +606,7 @@ func (r groupsScenesRow) resolve(sceneID int) models.GroupsScenes { } func (t *scenesGroupsTable) get(ctx context.Context, id int) ([]models.GroupsScenes, error) { - q := dialect.Select("movie_id", "scene_index").From(t.table.table).Where(t.idColumn.Eq(id)) + q := dialect.Select("group_id", "scene_index").From(t.table.table).Where(t.idColumn.Eq(id)) const single = false var ret []models.GroupsScenes @@ -627,7 +627,7 @@ func (t *scenesGroupsTable) get(ctx context.Context, id int) ([]models.GroupsSce } func (t *scenesGroupsTable) insertJoin(ctx context.Context, id int, v models.GroupsScenes) (sql.Result, error) { - q := dialect.Insert(t.table.table).Cols(t.idColumn.GetCol(), "movie_id", "scene_index").Vals( + q := dialect.Insert(t.table.table).Cols(t.idColumn.GetCol(), "group_id", "scene_index").Vals( goqu.Vals{id, v.GroupID, intFromPtr(v.SceneIndex)}, ) ret, err := exec(ctx, q) @@ -686,7 +686,7 @@ func (t *scenesGroupsTable) destroyJoins(ctx context.Context, id int, v []models for _, vv := range v { q := dialect.Delete(t.table.table).Where( t.idColumn.Eq(id), - t.table.table.Col("movie_id").Eq(vv.GroupID), + t.table.table.Col("group_id").Eq(vv.GroupID), ) if _, err := exec(ctx, q); err != nil { diff --git a/pkg/sqlite/tag.go b/pkg/sqlite/tag.go index faab05c5e19..42bdd9bbe45 100644 --- a/pkg/sqlite/tag.go +++ b/pkg/sqlite/tag.go @@ -424,15 +424,15 @@ func (qb *TagStore) FindByGalleryID(ctx context.Context, galleryID int) ([]*mode return qb.queryTags(ctx, query, args) } -func (qb *TagStore) FindByGroupID(ctx context.Context, movieID int) ([]*models.Tag, error) { +func (qb *TagStore) FindByGroupID(ctx context.Context, groupID int) ([]*models.Tag, error) { query := ` SELECT tags.* FROM tags - LEFT JOIN movies_tags as movies_join on movies_join.tag_id = tags.id - WHERE movies_join.movie_id = ? + LEFT JOIN groups_tags as groups_join on groups_join.tag_id = tags.id + WHERE groups_join.group_id = ? GROUP BY tags.id ` query += qb.getDefaultTagSort() - args := []interface{}{movieID} + args := []interface{}{groupID} return qb.queryTags(ctx, query, args) } diff --git a/pkg/sqlite/tag_filter.go b/pkg/sqlite/tag_filter.go index 8daee02a827..26e33c36c6e 100644 --- a/pkg/sqlite/tag_filter.go +++ b/pkg/sqlite/tag_filter.go @@ -193,8 +193,8 @@ func (qb *tagFilterHandler) studioCountCriterionHandler(studioCount *models.IntC func (qb *tagFilterHandler) groupCountCriterionHandler(groupCount *models.IntCriterionInput) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if groupCount != nil { - f.addLeftJoin("movies_tags", "", "movies_tags.tag_id = tags.id") - clause, args := getIntCriterionWhereClause("count(distinct movies_tags.movie_id)", *groupCount) + f.addLeftJoin("groups_tags", "", "groups_tags.tag_id = tags.id") + clause, args := getIntCriterionWhereClause("count(distinct groups_tags.group_id)", *groupCount) f.addHaving(clause, args...) } From 540d72bc4433337e96a7b054a16e25b1f6531329 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Wed, 31 Jul 2024 10:53:40 +1000 Subject: [PATCH 061/103] Fix bulk scene setting groups (#5106) --- internal/api/resolver_mutation_scene.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/api/resolver_mutation_scene.go b/internal/api/resolver_mutation_scene.go index 3020f13fdc8..356214d59e1 100644 --- a/internal/api/resolver_mutation_scene.go +++ b/internal/api/resolver_mutation_scene.go @@ -373,12 +373,12 @@ func (r *mutationResolver) BulkSceneUpdate(ctx context.Context, input BulkSceneU return nil, fmt.Errorf("converting gallery ids: %w", err) } - if translator.hasField("groups") { + if translator.hasField("group_ids") { updatedScene.GroupIDs, err = translator.updateGroupIDsBulk(input.GroupIds, "group_ids") if err != nil { return nil, fmt.Errorf("converting group ids: %w", err) } - } else if translator.hasField("movies") { + } else if translator.hasField("movie_ids") { updatedScene.GroupIDs, err = translator.updateGroupIDsBulk(input.MovieIds, "movie_ids") if err != nil { return nil, fmt.Errorf("converting movie ids: %w", err) From 6a5dc4e7744846d82498e0958f8a45bf51e0ac9f Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Wed, 31 Jul 2024 16:35:37 +1000 Subject: [PATCH 062/103] Refactor ItemList code and re-enable viewing sub-tag/studio content (#5080) * Refactor list filter to use contexts * Refactor FilteredListToolbar * Move components into separate files * Convert ItemList hook into components * Fix criteria clone functions * Add toggle for sub-studio content * Add toggle for sub-tag content * Make LoadingIndicator height smaller and fade in. --- .../src/components/Galleries/GalleryList.tsx | 49 +- ui/v2.5/src/components/Groups/GroupList.tsx | 47 +- ui/v2.5/src/components/Images/ImageList.tsx | 122 +-- .../src/components/List/FilterProvider.tsx | 76 ++ .../components/List/FilteredListToolbar.tsx | 87 ++ ui/v2.5/src/components/List/ItemList.tsx | 849 ++++++------------ ui/v2.5/src/components/List/ListFilter.tsx | 5 +- .../components/List/ListOperationButtons.tsx | 2 +- ui/v2.5/src/components/List/ListProvider.tsx | 156 ++++ ui/v2.5/src/components/List/PagedList.tsx | 98 ++ ui/v2.5/src/components/List/util.ts | 321 ++++++- .../components/Performers/PerformerList.tsx | 47 +- ui/v2.5/src/components/Scenes/SceneList.tsx | 119 +-- .../src/components/Scenes/SceneMarkerList.tsx | 41 +- .../src/components/Shared/FilterSelect.tsx | 5 +- ui/v2.5/src/components/Shared/styles.scss | 22 +- .../Studios/StudioDetails/Studio.tsx | 59 +- .../StudioDetails/StudioChildrenPanel.tsx | 26 +- .../StudioDetails/StudioGalleriesPanel.tsx | 4 +- .../StudioDetails/StudioGroupsPanel.tsx | 4 +- .../StudioDetails/StudioImagesPanel.tsx | 4 +- .../StudioDetails/StudioPerformersPanel.tsx | 4 +- .../StudioDetails/StudioScenesPanel.tsx | 4 +- ui/v2.5/src/components/Studios/StudioList.tsx | 45 +- .../src/components/Tags/TagDetails/Tag.tsx | 82 +- .../Tags/TagDetails/TagGalleriesPanel.tsx | 4 +- .../Tags/TagDetails/TagGroupsPanel.tsx | 5 +- .../Tags/TagDetails/TagImagesPanel.tsx | 9 +- .../Tags/TagDetails/TagMarkersPanel.tsx | 30 +- .../Tags/TagDetails/TagPerformersPanel.tsx | 4 +- .../Tags/TagDetails/TagScenesPanel.tsx | 9 +- .../Tags/TagDetails/TagStudiosPanel.tsx | 4 +- ui/v2.5/src/components/Tags/TagList.tsx | 55 +- ui/v2.5/src/core/studios.ts | 10 +- ui/v2.5/src/core/tags.ts | 10 +- ui/v2.5/src/hooks/modal.ts | 10 + ui/v2.5/src/index.scss | 9 + ui/v2.5/src/locales/en-GB.json | 2 + .../models/list-filter/criteria/criterion.ts | 61 +- ui/v2.5/src/models/list-filter/filter.ts | 78 +- ui/v2.5/src/utils/bulkUpdate.ts | 5 +- ui/v2.5/src/utils/data.ts | 4 + 42 files changed, 1651 insertions(+), 936 deletions(-) create mode 100644 ui/v2.5/src/components/List/FilterProvider.tsx create mode 100644 ui/v2.5/src/components/List/FilteredListToolbar.tsx create mode 100644 ui/v2.5/src/components/List/ListProvider.tsx create mode 100644 ui/v2.5/src/components/List/PagedList.tsx create mode 100644 ui/v2.5/src/hooks/modal.ts diff --git a/ui/v2.5/src/components/Galleries/GalleryList.tsx b/ui/v2.5/src/components/Galleries/GalleryList.tsx index ba6334e3339..7becbe93a4c 100644 --- a/ui/v2.5/src/components/Galleries/GalleryList.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryList.tsx @@ -4,7 +4,7 @@ import cloneDeep from "lodash-es/cloneDeep"; import { useHistory } from "react-router-dom"; import Mousetrap from "mousetrap"; import * as GQL from "src/core/generated-graphql"; -import { makeItemList, showWhenSelected } from "../List/ItemList"; +import { ItemList, ItemListContext, showWhenSelected } from "../List/ItemList"; import { ListFilterModel } from "src/models/list-filter/filter"; import { DisplayMode } from "src/models/list-filter/types"; import { queryFindGalleries, useFindGalleries } from "src/core/StashService"; @@ -16,16 +16,13 @@ import { GalleryListTable } from "./GalleryListTable"; import { GalleryCardGrid } from "./GalleryGridCard"; import { View } from "../List/views"; -const GalleryItemList = makeItemList({ - filterMode: GQL.FilterMode.Galleries, - useResult: useFindGalleries, - getItems(result: GQL.FindGalleriesQueryResult) { - return result?.data?.findGalleries?.galleries ?? []; - }, - getCount(result: GQL.FindGalleriesQueryResult) { - return result?.data?.findGalleries?.count ?? 0; - }, -}); +function getItems(result: GQL.FindGalleriesQueryResult) { + return result?.data?.findGalleries?.galleries ?? []; +} + +function getCount(result: GQL.FindGalleriesQueryResult) { + return result?.data?.findGalleries?.count ?? 0; +} interface IGalleryList { filterHook?: (filter: ListFilterModel) => ListFilterModel; @@ -43,6 +40,8 @@ export const GalleryList: React.FC = ({ const [isExportDialogOpen, setIsExportDialogOpen] = useState(false); const [isExportAll, setIsExportAll] = useState(false); + const filterMode = GQL.FilterMode.Galleries; + const otherOperations = [ { text: intl.formatMessage({ id: "actions.view_random" }), @@ -185,17 +184,25 @@ export const GalleryList: React.FC = ({ } return ( - + selectable + > + + ); }; diff --git a/ui/v2.5/src/components/Groups/GroupList.tsx b/ui/v2.5/src/components/Groups/GroupList.tsx index 72758749666..ba45912762b 100644 --- a/ui/v2.5/src/components/Groups/GroupList.tsx +++ b/ui/v2.5/src/components/Groups/GroupList.tsx @@ -11,23 +11,20 @@ import { useFindGroups, useGroupsDestroy, } from "src/core/StashService"; -import { makeItemList, showWhenSelected } from "../List/ItemList"; +import { ItemList, ItemListContext, showWhenSelected } from "../List/ItemList"; import { ExportDialog } from "../Shared/ExportDialog"; import { DeleteEntityDialog } from "../Shared/DeleteEntityDialog"; import { GroupCardGrid } from "./GroupCardGrid"; import { EditGroupsDialog } from "./EditGroupsDialog"; import { View } from "../List/views"; -const GroupItemList = makeItemList({ - filterMode: GQL.FilterMode.Groups, - useResult: useFindGroups, - getItems(result: GQL.FindGroupsQueryResult) { - return result?.data?.findGroups?.groups ?? []; - }, - getCount(result: GQL.FindGroupsQueryResult) { - return result?.data?.findGroups?.count ?? 0; - }, -}); +function getItems(result: GQL.FindGroupsQueryResult) { + return result?.data?.findGroups?.groups ?? []; +} + +function getCount(result: GQL.FindGroupsQueryResult) { + return result?.data?.findGroups?.count ?? 0; +} interface IGroupList { filterHook?: (filter: ListFilterModel) => ListFilterModel; @@ -45,6 +42,8 @@ export const GroupList: React.FC = ({ const [isExportDialogOpen, setIsExportDialogOpen] = useState(false); const [isExportAll, setIsExportAll] = useState(false); + const filterMode = GQL.FilterMode.Groups; + const otherOperations = [ { text: intl.formatMessage({ id: "actions.view_random" }), @@ -174,16 +173,24 @@ export const GroupList: React.FC = ({ } return ( - + selectable + > + + ); }; diff --git a/ui/v2.5/src/components/Images/ImageList.tsx b/ui/v2.5/src/components/Images/ImageList.tsx index 3000195d9da..5bcea0d4a78 100644 --- a/ui/v2.5/src/components/Images/ImageList.tsx +++ b/ui/v2.5/src/components/Images/ImageList.tsx @@ -11,11 +11,7 @@ import { useHistory } from "react-router-dom"; import Mousetrap from "mousetrap"; import * as GQL from "src/core/generated-graphql"; import { queryFindImages, useFindImages } from "src/core/StashService"; -import { - makeItemList, - IItemListOperation, - showWhenSelected, -} from "../List/ItemList"; +import { ItemList, ItemListContext, showWhenSelected } from "../List/ItemList"; import { useLightbox } from "src/hooks/Lightbox/hooks"; import { ListFilterModel } from "src/models/list-filter/filter"; import { DisplayMode } from "src/models/list-filter/types"; @@ -31,6 +27,7 @@ import TextUtils from "src/utils/text"; import { ConfigurationContext } from "src/hooks/Config"; import { ImageGridCard } from "./ImageGridCard"; import { View } from "../List/views"; +import { IItemListOperation } from "../List/FilteredListToolbar"; interface IImageWallProps { images: GQL.SlimImageDataFragment[]; @@ -222,51 +219,49 @@ const ImageListImages: React.FC = ({ return <>; }; -const ImageItemList = makeItemList({ - filterMode: GQL.FilterMode.Images, - useResult: useFindImages, - getItems(result: GQL.FindImagesQueryResult) { - return result?.data?.findImages?.images ?? []; - }, - getCount(result: GQL.FindImagesQueryResult) { - return result?.data?.findImages?.count ?? 0; - }, - renderMetadataByline(result: GQL.FindImagesQueryResult) { - const megapixels = result?.data?.findImages?.megapixels; - const size = result?.data?.findImages?.filesize; - const filesize = size ? TextUtils.fileSize(size) : undefined; - - if (!megapixels && !size) { - return; - } +function getItems(result: GQL.FindImagesQueryResult) { + return result?.data?.findImages?.images ?? []; +} - const separator = megapixels && size ? " - " : ""; +function getCount(result: GQL.FindImagesQueryResult) { + return result?.data?.findImages?.count ?? 0; +} - return ( - -  ( - {megapixels ? ( - - Megapixels - - ) : undefined} - {separator} - {size && filesize ? ( - - - {` ${TextUtils.formatFileSizeUnit(filesize.unit)}`} - - ) : undefined} - ) - - ); - }, -}); +function renderMetadataByline(result: GQL.FindImagesQueryResult) { + const megapixels = result?.data?.findImages?.megapixels; + const size = result?.data?.findImages?.filesize; + const filesize = size ? TextUtils.fileSize(size) : undefined; + + if (!megapixels && !size) { + return; + } + + const separator = megapixels && size ? " - " : ""; + + return ( + +  ( + {megapixels ? ( + + Megapixels + + ) : undefined} + {separator} + {size && filesize ? ( + + + {` ${TextUtils.formatFileSizeUnit(filesize.unit)}`} + + ) : undefined} + ) + + ); +} interface IImageList { filterHook?: (filter: ListFilterModel) => ListFilterModel; @@ -289,6 +284,8 @@ export const ImageList: React.FC = ({ const [isExportAll, setIsExportAll] = useState(false); const [slideshowRunning, setSlideshowRunning] = useState(false); + const filterMode = GQL.FilterMode.Images; + const otherOperations = [ ...(extraOperations ?? []), { @@ -415,17 +412,26 @@ export const ImageList: React.FC = ({ } return ( - + selectable + > + + ); }; diff --git a/ui/v2.5/src/components/List/FilterProvider.tsx b/ui/v2.5/src/components/List/FilterProvider.tsx new file mode 100644 index 00000000000..9f0abc4e006 --- /dev/null +++ b/ui/v2.5/src/components/List/FilterProvider.tsx @@ -0,0 +1,76 @@ +import React from "react"; +import { ListFilterModel } from "src/models/list-filter/filter"; +import { isFunction } from "lodash-es"; +import { useFilterURL } from "./util"; + +interface IFilterContextOptions { + filter: ListFilterModel; + setFilter: React.Dispatch>; +} + +export interface IFilterContextState { + filter: ListFilterModel; + setFilter: React.Dispatch>; +} + +export const FilterStateContext = + React.createContext(null); + +export const FilterContext = ( + props: IFilterContextOptions & { + children?: + | ((props: IFilterContextState) => React.ReactNode) + | React.ReactNode; + } +) => { + const { filter, setFilter, children } = props; + + const state = { + filter, + setFilter, + }; + + return ( + + {isFunction(children) + ? (children as (props: IFilterContextState) => React.ReactNode)(state) + : children} + + ); +}; + +export function useFilter() { + const context = React.useContext(FilterStateContext); + + if (context === null) { + throw new Error("useFilter must be used within a FilterStateContext"); + } + + return context; +} + +// This component is used to set the filter from the URL. +// It replaces the setFilter function to set the URL instead. +// It also loads the default filter if the URL is empty. +export const SetFilterURL = (props: { + defaultFilter?: ListFilterModel; + setURL?: boolean; + children?: + | ((props: IFilterContextState) => React.ReactNode) + | React.ReactNode; +}) => { + const { defaultFilter, setURL = true, children } = props; + + const { filter, setFilter: setFilterOrig } = useFilter(); + + const { setFilter } = useFilterURL(filter, setFilterOrig, { + defaultFilter, + setURL, + }); + + return ( + + {children} + + ); +}; diff --git a/ui/v2.5/src/components/List/FilteredListToolbar.tsx b/ui/v2.5/src/components/List/FilteredListToolbar.tsx new file mode 100644 index 00000000000..d6887c51de0 --- /dev/null +++ b/ui/v2.5/src/components/List/FilteredListToolbar.tsx @@ -0,0 +1,87 @@ +import React from "react"; +import { QueryResult } from "@apollo/client"; +import { ListFilterModel } from "src/models/list-filter/filter"; +import { IconDefinition } from "@fortawesome/fontawesome-svg-core"; +import { ListFilter } from "./ListFilter"; +import { ListViewOptions } from "./ListViewOptions"; +import { + IListFilterOperation, + ListOperationButtons, +} from "./ListOperationButtons"; +import { DisplayMode } from "src/models/list-filter/types"; +import { ButtonToolbar } from "react-bootstrap"; +import { View } from "./views"; +import { useListContext } from "./ListProvider"; +import { useFilter } from "./FilterProvider"; + +export interface IItemListOperation { + text: string; + onClick: ( + result: T, + filter: ListFilterModel, + selectedIds: Set + ) => Promise; + isDisplayed?: ( + result: T, + filter: ListFilterModel, + selectedIds: Set + ) => boolean; + postRefetch?: boolean; + icon?: IconDefinition; + buttonVariant?: string; +} + +export const FilteredListToolbar: React.FC<{ + showEditFilter: (editingCriterion?: string) => void; + view?: View; + onEdit?: () => void; + onDelete?: () => void; + operations?: IListFilterOperation[]; + zoomable?: boolean; +}> = ({ + showEditFilter, + view, + onEdit, + onDelete, + operations, + zoomable = false, +}) => { + const { getSelected, onSelectAll, onSelectNone } = useListContext(); + const { filter, setFilter } = useFilter(); + + const filterOptions = filter.options; + + function onChangeDisplayMode(displayMode: DisplayMode) { + setFilter(filter.setDisplayMode(displayMode)); + } + + function onChangeZoom(newZoomIndex: number) { + setFilter(filter.setZoom(newZoomIndex)); + } + + return ( + + showEditFilter()} + view={view} + /> + 0} + onEdit={onEdit} + onDelete={onDelete} + /> + + + ); +}; diff --git a/ui/v2.5/src/components/List/ItemList.tsx b/ui/v2.5/src/components/List/ItemList.tsx index 5ffe97d4e6a..5be226c331d 100644 --- a/ui/v2.5/src/components/List/ItemList.tsx +++ b/ui/v2.5/src/components/List/ItemList.tsx @@ -1,16 +1,10 @@ import React, { + PropsWithChildren, useCallback, - useContext, useEffect, - useLayoutEffect, useMemo, - useRef, useState, } from "react"; -import clone from "lodash-es/clone"; -import cloneDeep from "lodash-es/cloneDeep"; -import isEqual from "lodash-es/isEqual"; -import Mousetrap from "mousetrap"; import * as GQL from "src/core/generated-graphql"; import { QueryResult } from "@apollo/client"; import { @@ -18,69 +12,30 @@ import { CriterionValue, } from "src/models/list-filter/criteria/criterion"; import { ListFilterModel } from "src/models/list-filter/filter"; -import { IconDefinition } from "@fortawesome/fontawesome-svg-core"; -import { useHistory, useLocation } from "react-router-dom"; -import { ConfigurationContext } from "src/hooks/Config"; -import { getFilterOptions } from "src/models/list-filter/factory"; -import { Pagination, PaginationIndex } from "./Pagination"; import { EditFilterDialog } from "src/components/List/EditFilterDialog"; -import { ListFilter } from "./ListFilter"; import { FilterTags } from "./FilterTags"; -import { ListViewOptions } from "./ListViewOptions"; -import { ListOperationButtons } from "./ListOperationButtons"; -import { LoadingIndicator } from "../Shared/LoadingIndicator"; -import { DisplayMode } from "src/models/list-filter/types"; -import { ButtonToolbar } from "react-bootstrap"; import { View } from "./views"; -import { useDefaultFilter } from "./util"; - -interface IDataItem { - id: string; -} - -export interface IItemListOperation { - text: string; - onClick: ( - result: T, - filter: ListFilterModel, - selectedIds: Set - ) => Promise; - isDisplayed?: ( - result: T, - filter: ListFilterModel, - selectedIds: Set - ) => boolean; - postRefetch?: boolean; - icon?: IconDefinition; - buttonVariant?: string; -} - -interface IItemListOptions { - filterMode: GQL.FilterMode; - useResult: (filter: ListFilterModel) => T; - getCount: (data: T) => number; - renderMetadataByline?: (data: T) => React.ReactNode; - getItems: (data: T) => E[]; -} - -interface IRenderListProps { - filter: ListFilterModel; - onChangePage: (page: number) => void; - updateFilter: (filter: ListFilterModel) => void; -} - -interface IItemListProps { +import { IHasID } from "src/utils/data"; +import { + ListContext, + QueryResultContext, + useListContext, + useQueryResultContext, +} from "./ListProvider"; +import { FilterContext, SetFilterURL, useFilter } from "./FilterProvider"; +import { useModal } from "src/hooks/modal"; +import { + useDefaultFilter, + useEnsureValidPage, + useListKeyboardShortcuts, + useScrollToTopOnPageChange, +} from "./util"; +import { FilteredListToolbar, IItemListOperation } from "./FilteredListToolbar"; +import { PagedList } from "./PagedList"; + +interface IItemListProps { view?: View; - defaultSort?: string; - filterHook?: (filter: ListFilterModel) => ListFilterModel; - filterDialog?: ( - criteria: Criterion[], - setCriteria: (v: Criterion[]) => void - ) => React.ReactNode; zoomable?: boolean; - selectable?: boolean; - alterQuery?: boolean; - defaultZoomIndex?: number; otherOperations?: IItemListOperation[]; renderContent: ( result: T, @@ -90,6 +45,7 @@ interface IItemListProps { onChangePage: (page: number) => void, pageCount: number ) => React.ReactNode; + renderMetadataByline?: (data: T) => React.ReactNode; renderEditDialog?: ( selected: E[], onClose: (applied: boolean) => void @@ -105,570 +61,273 @@ interface IItemListProps { ) => () => void; } -const getSelectedData = ( - data: I[], - selectedIds: Set -) => data.filter((value) => selectedIds.has(value.id)); - -/** - * A factory function for ItemList components. - * IMPORTANT: as the component manipulates the URL query string, if there are - * ever multiple ItemLists rendered at once, all but one of them need to have - * `alterQuery` set to false to prevent conflicts. - */ -export function makeItemList({ - filterMode, - useResult, - getCount, - renderMetadataByline, - getItems, -}: IItemListOptions) { - const filterOptions = getFilterOptions(filterMode); - - const RenderList: React.FC & IRenderListProps> = ({ - filter, - filterHook, - onChangePage: _onChangePage, - updateFilter, +export const ItemList = ( + props: IItemListProps +) => { + const { view, zoomable, - selectable, otherOperations, renderContent, renderEditDialog, renderDeleteDialog, + renderMetadataByline, addKeybinds, - }) => { - const [isEditDialogOpen, setIsEditDialogOpen] = useState(false); - const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); - const [selectedIds, setSelectedIds] = useState>(new Set()); - const [lastClickedId, setLastClickedId] = useState(); - - const [editingCriterion, setEditingCriterion] = useState(); - const [showEditFilter, setShowEditFilter] = useState(false); - - const effectiveFilter = useMemo(() => { - if (filterHook) { - return filterHook(cloneDeep(filter)); + } = props; + + const { filter, setFilter: updateFilter } = useFilter(); + const { effectiveFilter, result, cachedResult, totalCount } = + useQueryResultContext(); + const { + selectedIds, + getSelected, + onSelectChange, + onSelectAll, + onSelectNone, + } = useListContext(); + + const { modal, showModal, closeModal } = useModal(); + + const metadataByline = useMemo(() => { + if (cachedResult.loading) return ""; + + return renderMetadataByline?.(cachedResult) ?? ""; + }, [renderMetadataByline, cachedResult]); + + const pages = Math.ceil(totalCount / filter.itemsPerPage); + + const onChangePage = useCallback( + (p: number) => { + updateFilter(filter.changePage(p)); + }, + [filter, updateFilter] + ); + + useEnsureValidPage(filter, totalCount, updateFilter); + + const showEditFilter = useCallback( + (editingCriterion?: string) => { + function onApplyEditFilter(f: ListFilterModel) { + closeModal(); + updateFilter(f); } - return filter; - }, [filter, filterHook]); - - const result = useResult(effectiveFilter); - const [totalCount, setTotalCount] = useState(0); - const [metadataByline, setMetadataByline] = useState(); - const items = useMemo(() => getItems(result), [result]); - - const [arePaging, setArePaging] = useState(false); - const hidePagination = !arePaging && result.loading; - - // useLayoutEffect to set total count before paint, avoiding a 0 being displayed - useLayoutEffect(() => { - if (result.loading) return; - setArePaging(false); - - setTotalCount(getCount(result)); - setMetadataByline(renderMetadataByline?.(result)); - }, [result]); - - const onChangePage = useCallback( - (page: number) => { - setArePaging(true); - _onChangePage(page); - }, - [_onChangePage] - ); - - // handle case where page is more than there are pages - useEffect(() => { - const pages = Math.ceil(totalCount / filter.itemsPerPage); - if (pages > 0 && filter.currentPage > pages) { - onChangePage(pages); - } - }, [filter, onChangePage, totalCount]); - - // set up hotkeys - useEffect(() => { - Mousetrap.bind("f", (e) => { - setShowEditFilter(true); - // prevent default behavior of typing f in a text field - // otherwise the filter dialog closes, the query field is focused and - // f is typed. - e.preventDefault(); - }); - - return () => { - Mousetrap.unbind("f"); - }; - }, []); - useEffect(() => { - const pages = Math.ceil(totalCount / filter.itemsPerPage); - Mousetrap.bind("right", () => { - if (filter.currentPage < pages) { - onChangePage(filter.currentPage + 1); - } - }); - Mousetrap.bind("left", () => { - if (filter.currentPage > 1) { - onChangePage(filter.currentPage - 1); - } - }); - Mousetrap.bind("shift+right", () => { - onChangePage(Math.min(pages, filter.currentPage + 10)); - }); - Mousetrap.bind("shift+left", () => { - onChangePage(Math.max(1, filter.currentPage - 10)); - }); - Mousetrap.bind("ctrl+end", () => { - onChangePage(pages); - }); - Mousetrap.bind("ctrl+home", () => { - onChangePage(1); - }); + showModal( + closeModal()} + editingCriterion={editingCriterion} + /> + ); + }, + [filter, updateFilter, showModal, closeModal] + ); + + useListKeyboardShortcuts({ + currentPage: filter.currentPage, + onChangePage, + onSelectAll, + onSelectNone, + pages, + showEditFilter, + }); + + useEffect(() => { + if (addKeybinds) { + const unbindExtras = addKeybinds(result, effectiveFilter, selectedIds); return () => { - Mousetrap.unbind("right"); - Mousetrap.unbind("left"); - Mousetrap.unbind("shift+right"); - Mousetrap.unbind("shift+left"); - Mousetrap.unbind("ctrl+end"); - Mousetrap.unbind("ctrl+home"); + unbindExtras(); }; - }, [filter, onChangePage, totalCount]); - useEffect(() => { - if (addKeybinds) { - const unbindExtras = addKeybinds(result, effectiveFilter, selectedIds); - return () => { - unbindExtras(); - }; - } - }, [addKeybinds, result, effectiveFilter, selectedIds]); - - function singleSelect(id: string, selected: boolean) { - setLastClickedId(id); - - const newSelectedIds = clone(selectedIds); - if (selected) { - newSelectedIds.add(id); - } else { - newSelectedIds.delete(id); - } - - setSelectedIds(newSelectedIds); } + }, [addKeybinds, result, effectiveFilter, selectedIds]); - function selectRange(startIndex: number, endIndex: number) { - let start = startIndex; - let end = endIndex; - if (start > end) { - const tmp = start; - start = end; - end = tmp; - } - - const subset = items.slice(start, end + 1); - const newSelectedIds = new Set(); - - subset.forEach((item) => { - newSelectedIds.add(item.id); - }); - - setSelectedIds(newSelectedIds); - } - - function multiSelect(id: string) { - let startIndex = 0; - let thisIndex = -1; - - if (lastClickedId) { - startIndex = items.findIndex((item) => { - return item.id === lastClickedId; - }); - } - - thisIndex = items.findIndex((item) => { - return item.id === id; - }); - - selectRange(startIndex, thisIndex); - } - - function onSelectChange(id: string, selected: boolean, shiftKey: boolean) { - if (shiftKey) { - multiSelect(id); - } else { - singleSelect(id, selected); - } - } - - function onSelectAll() { - const newSelectedIds = new Set(); - items.forEach((item) => { - newSelectedIds.add(item.id); - }); - - setSelectedIds(newSelectedIds); - setLastClickedId(undefined); - } - - function onSelectNone() { - const newSelectedIds = new Set(); - setSelectedIds(newSelectedIds); - setLastClickedId(undefined); - } - - function onChangeZoom(newZoomIndex: number) { - const newFilter = cloneDeep(filter); - newFilter.zoomIndex = newZoomIndex; - updateFilter(newFilter); - } - - async function onOperationClicked(o: IItemListOperation) { - await o.onClick(result, effectiveFilter, selectedIds); - if (o.postRefetch) { - result.refetch(); - } - } - - const operations = otherOperations?.map((o) => ({ - text: o.text, - onClick: () => { - onOperationClicked(o); - }, - isDisplayed: () => { - if (o.isDisplayed) { - return o.isDisplayed(result, effectiveFilter, selectedIds); - } - - return true; - }, - icon: o.icon, - buttonVariant: o.buttonVariant, - })); - - function onEdit() { - setIsEditDialogOpen(true); - } - - function onEditDialogClosed(applied: boolean) { - if (applied) { - onSelectNone(); - } - setIsEditDialogOpen(false); - - // refetch + async function onOperationClicked(o: IItemListOperation) { + await o.onClick(result, effectiveFilter, selectedIds); + if (o.postRefetch) { result.refetch(); } - - function onDelete() { - setIsDeleteDialogOpen(true); - } - - function onDeleteDialogClosed(deleted: boolean) { - if (deleted) { - onSelectNone(); + } + + const operations = otherOperations?.map((o) => ({ + text: o.text, + onClick: () => { + onOperationClicked(o); + }, + isDisplayed: () => { + if (o.isDisplayed) { + return o.isDisplayed(result, effectiveFilter, selectedIds); } - setIsDeleteDialogOpen(false); - - // refetch - result.refetch(); - } - function renderPagination() { - if (hidePagination) return; - return ( - - ); - } + return true; + }, + icon: o.icon, + buttonVariant: o.buttonVariant, + })); - function renderPaginationIndex() { - if (hidePagination) return; - return ( - - ); - } - - function maybeRenderContent() { - if (result.loading) { - return ; - } - if (result.error) { - return

    {result.error.message}

    ; - } - - const pages = Math.ceil(totalCount / filter.itemsPerPage); - return ( - <> - {renderContent( - result, - // #4780 - use effectiveFilter to ensure filterHook is applied - effectiveFilter, - selectedIds, - onSelectChange, - onChangePage, - pages - )} - {!!pages && ( - <> - {renderPaginationIndex()} - {renderPagination()} - - )} - - ); + function onEdit() { + if (!renderEditDialog) { + return; } - function onChangeDisplayMode(displayMode: DisplayMode) { - const newFilter = cloneDeep(filter); - newFilter.displayMode = displayMode; - updateFilter(newFilter); - } + showModal( + renderEditDialog(getSelected(), (applied) => onEditDialogClosed(applied)) + ); + } - function onRemoveCriterion(removedCriterion: Criterion) { - const newFilter = cloneDeep(filter); - newFilter.criteria = newFilter.criteria.filter( - (criterion) => criterion.getId() !== removedCriterion.getId() - ); - newFilter.currentPage = 1; - updateFilter(newFilter); + function onEditDialogClosed(applied: boolean) { + if (applied) { + onSelectNone(); } + closeModal(); - function onClearAllCriteria() { - const newFilter = cloneDeep(filter); - newFilter.criteria = []; - newFilter.currentPage = 1; - updateFilter(newFilter); - } + // refetch + result.refetch(); + } - function onApplyEditFilter(f: ListFilterModel) { - setShowEditFilter(false); - setEditingCriterion(undefined); - updateFilter(f); + function onDelete() { + if (!renderDeleteDialog) { + return; } - function onCancelEditFilter() { - setShowEditFilter(false); - setEditingCriterion(undefined); - } - - return ( -
    - - setShowEditFilter(true)} - view={view} - /> - 0} - onEdit={renderEditDialog ? onEdit : undefined} - onDelete={renderDeleteDialog ? onDelete : undefined} - /> - - - setEditingCriterion(c.criterionOption.type)} - onRemoveCriterion={onRemoveCriterion} - onRemoveAll={() => onClearAllCriteria()} - /> - {(showEditFilter || editingCriterion) && ( - - )} - {isEditDialogOpen && - renderEditDialog && - renderEditDialog(getSelectedData(items, selectedIds), (applied) => - onEditDialogClosed(applied) - )} - {isDeleteDialogOpen && - renderDeleteDialog && - renderDeleteDialog(getSelectedData(items, selectedIds), (deleted) => - onDeleteDialogClosed(deleted) - )} - {renderPagination()} - {renderPaginationIndex()} - {maybeRenderContent()} -
    - ); - }; - - const ItemList: React.FC> = (props) => { - const { - view, - defaultSort = filterOptions.defaultSortBy, - defaultZoomIndex, - alterQuery = true, - } = props; - - const history = useHistory(); - const location = useLocation(); - const [filterInitialised, setFilterInitialised] = useState(false); - const { configuration: config } = useContext(ConfigurationContext); - - const lastPathname = useRef(location.pathname); - const defaultDisplayMode = filterOptions.displayModeOptions[0]; - const [filter, setFilter] = useState( - () => new ListFilterModel(filterMode) - ); - - const { defaultFilter, loading: defaultFilterLoading } = useDefaultFilter( - filterMode, - view - ); - - const updateQueryParams = useCallback( - (newFilter: ListFilterModel) => { - if (!alterQuery) return; - - const newParams = newFilter.makeQueryParameters(); - history.replace({ ...history.location, search: newParams }); - }, - [alterQuery, history] - ); - - const updateFilter = useCallback( - (newFilter: ListFilterModel) => { - setFilter(newFilter); - updateQueryParams(newFilter); - }, - [updateQueryParams] + showModal( + renderDeleteDialog(getSelected(), (deleted) => + onDeleteDialogClosed(deleted) + ) ); + } - // 'Startup' hook, initialises the filters - useEffect(() => { - // Only run once - if (filterInitialised) return; - - let newFilter = new ListFilterModel(filterMode, config, defaultZoomIndex); - let loadDefault = true; - if (alterQuery && location.search) { - loadDefault = false; - newFilter.configureFromQueryString(location.search); - } - - if (view) { - // only set default filter if uninitialised - if (loadDefault) { - // wait until default filter is loaded - if (defaultFilterLoading) return; - - if (defaultFilter) { - newFilter = defaultFilter.clone(); - - // #1507 - reset random seed when loaded - newFilter.randomSeed = -1; - } - } - } - - setFilter(newFilter); - updateQueryParams(newFilter); - - setFilterInitialised(true); - }, [ - filterInitialised, - location, - config, - defaultSort, - defaultDisplayMode, - defaultZoomIndex, - alterQuery, - view, - updateQueryParams, - defaultFilter, - defaultFilterLoading, - ]); - - // This hook runs on every page location change (ie navigation), - // and updates the filter accordingly. - useEffect(() => { - if (!filterInitialised || !alterQuery) return; - - // re-init if the pathname has changed - if (location.pathname !== lastPathname.current) { - lastPathname.current = location.pathname; - setFilterInitialised(false); - return; - } - - // re-init to load default filter on empty new query params - if (!location.search) { - setFilterInitialised(false); - return; - } - - // the query has changed, update filter if necessary - setFilter((prevFilter) => { - let newFilter = prevFilter.clone(); - newFilter.configureFromQueryString(location.search); - if (!isEqual(newFilter, prevFilter)) { - return newFilter; - } else { - return prevFilter; - } - }); - }, [filterInitialised, alterQuery, location]); - - const onChangePage = useCallback( - (page: number) => { - const newFilter = cloneDeep(filter); - newFilter.currentPage = page; - updateFilter(newFilter); - - // if the current page has a detail-header, then - // scroll up relative to that rather than 0, 0 - const detailHeader = document.querySelector(".detail-header"); - if (detailHeader) { - window.scrollTo(0, detailHeader.scrollHeight - 50); - } else { - window.scrollTo(0, 0); - } - }, - [filter, updateFilter] - ); - - if (!filterInitialised) return null; + function onDeleteDialogClosed(deleted: boolean) { + if (deleted) { + onSelectNone(); + } + closeModal(); + + // refetch + result.refetch(); + } + + function onRemoveCriterion(removedCriterion: Criterion) { + updateFilter(filter.removeCriterion(removedCriterion.criterionOption.type)); + } + + function onClearAllCriteria() { + updateFilter(filter.clearCriteria()); + } + + return ( +
    + + showEditFilter(c.criterionOption.type)} + onRemoveCriterion={onRemoveCriterion} + onRemoveAll={() => onClearAllCriteria()} + /> + {modal} - return ( - - ); - }; + metadataByline={metadataByline} + > + {renderContent( + result, + // #4780 - use effectiveFilter to ensure filterHook is applied + effectiveFilter, + selectedIds, + onSelectChange, + onChangePage, + pages + )} + +
    + ); +}; - return ItemList; +interface IItemListContextProps { + filterMode: GQL.FilterMode; + defaultSort?: string; + useResult: (filter: ListFilterModel) => T; + getCount: (data: T) => number; + getItems: (data: T) => E[]; + filterHook?: (filter: ListFilterModel) => ListFilterModel; + view?: View; + alterQuery?: boolean; + selectable?: boolean; } +// Provides the contexts for the ItemList component. Includes functionality to scroll +// to top on page change. +export const ItemListContext = ( + props: PropsWithChildren> +) => { + const { + filterMode, + defaultSort, + useResult, + getCount, + getItems, + view, + filterHook, + alterQuery = true, + selectable, + children, + } = props; + + const emptyFilter = useMemo( + () => + new ListFilterModel(filterMode, undefined, { + defaultSortBy: defaultSort, + }), + [filterMode, defaultSort] + ); + + const [filter, setFilterState] = useState( + () => + new ListFilterModel(filterMode, undefined, { defaultSortBy: defaultSort }) + ); + + const { defaultFilter, loading: defaultFilterLoading } = useDefaultFilter( + emptyFilter, + view + ); + + // scroll to the top of the page when the page changes + useScrollToTopOnPageChange(filter.currentPage); + + if (defaultFilterLoading) return null; + + return ( + + + + {({ items }) => ( + + {children} + + )} + + + + ); +}; + export const showWhenSelected = ( result: T, filter: ListFilterModel, diff --git a/ui/v2.5/src/components/List/ListFilter.tsx b/ui/v2.5/src/components/List/ListFilter.tsx index ccfd3240954..23244cbf023 100644 --- a/ui/v2.5/src/components/List/ListFilter.tsx +++ b/ui/v2.5/src/components/List/ListFilter.tsx @@ -19,7 +19,6 @@ import { import { Icon } from "../Shared/Icon"; import { ListFilterModel } from "src/models/list-filter/filter"; import useFocus from "src/utils/focus"; -import { ListFilterOptions } from "src/models/list-filter/filter-options"; import { FormattedMessage, useIntl } from "react-intl"; import { SavedFilterDropdown } from "./SavedFilterList"; import { @@ -36,7 +35,6 @@ import { View } from "./views"; interface IListFilterProps { onFilterUpdate: (newFilter: ListFilterModel) => void; filter: ListFilterModel; - filterOptions: ListFilterOptions; view?: View; openFilterDialog: () => void; } @@ -46,7 +44,6 @@ const PAGE_SIZE_OPTIONS = ["20", "40", "60", "120", "250", "500", "1000"]; export const ListFilter: React.FC = ({ onFilterUpdate, filter, - filterOptions, openFilterDialog, view, }) => { @@ -58,6 +55,8 @@ export const ListFilter: React.FC = ({ const perPageSelect = useRef(null); const [perPageInput, perPageFocus] = useFocus(); + const filterOptions = filter.options; + const searchQueryUpdated = useCallback( (value: string) => { const newFilter = cloneDeep(filter); diff --git a/ui/v2.5/src/components/List/ListOperationButtons.tsx b/ui/v2.5/src/components/List/ListOperationButtons.tsx index c279020e9e0..4373d933847 100644 --- a/ui/v2.5/src/components/List/ListOperationButtons.tsx +++ b/ui/v2.5/src/components/List/ListOperationButtons.tsx @@ -16,7 +16,7 @@ import { faTrash, } from "@fortawesome/free-solid-svg-icons"; -interface IListFilterOperation { +export interface IListFilterOperation { text: string; onClick: () => void; isDisplayed?: () => boolean; diff --git a/ui/v2.5/src/components/List/ListProvider.tsx b/ui/v2.5/src/components/List/ListProvider.tsx new file mode 100644 index 00000000000..a3a41a93d9f --- /dev/null +++ b/ui/v2.5/src/components/List/ListProvider.tsx @@ -0,0 +1,156 @@ +import React, { useMemo } from "react"; +import { IListSelect, useCachedQueryResult, useListSelect } from "./util"; +import { isFunction } from "lodash-es"; +import { IHasID } from "src/utils/data"; +import { useFilter } from "./FilterProvider"; +import { ListFilterModel } from "src/models/list-filter/filter"; +import { QueryResult } from "@apollo/client"; + +interface IListContextOptions { + selectable?: boolean; + items: T[]; +} + +export type IListContextState = IListSelect & { + selectable: boolean; + items: T[]; +}; + +export const ListStateContext = React.createContext( + null +); + +export const ListContext = ( + props: IListContextOptions & { + children?: + | ((props: IListContextState) => React.ReactNode) + | React.ReactNode; + } +) => { + const { selectable = false, items, children } = props; + + const { + selectedIds, + getSelected, + onSelectChange, + onSelectAll, + onSelectNone, + } = useListSelect(items); + + const state: IListContextState = { + selectable, + selectedIds, + getSelected, + onSelectChange, + onSelectAll, + onSelectNone, + items, + }; + + return ( + + {isFunction(children) + ? (children as (props: IListContextState) => React.ReactNode)(state) + : children} + + ); +}; + +export function useListContext() { + const context = React.useContext(ListStateContext); + + if (context === null) { + throw new Error("useListContext must be used within a ListStateContext"); + } + + return context as IListContextState; +} + +interface IQueryResultContextOptions< + T extends QueryResult, + E extends IHasID = IHasID +> { + filterHook?: (filter: ListFilterModel) => ListFilterModel; + useResult: (filter: ListFilterModel) => T; + getCount: (data: T) => number; + getItems: (data: T) => E[]; +} + +export interface IQueryResultContextState< + T extends QueryResult = QueryResult, + E extends IHasID = IHasID +> { + effectiveFilter: ListFilterModel; + result: T; + cachedResult: T; + items: E[]; + totalCount: number; +} + +export const QueryResultStateContext = + React.createContext(null); + +export const QueryResultContext = < + T extends QueryResult, + E extends IHasID = IHasID +>( + props: IQueryResultContextOptions & { + children?: + | ((props: IQueryResultContextState) => React.ReactNode) + | React.ReactNode; + } +) => { + const { filterHook, useResult, getItems, getCount, children } = props; + + const { filter } = useFilter(); + const effectiveFilter = useMemo(() => { + if (filterHook) { + return filterHook(filter.clone()); + } + return filter; + }, [filter, filterHook]); + + const result = useResult(effectiveFilter); + + // use cached query result for pagination and metadata rendering + const cachedResult = useCachedQueryResult(effectiveFilter, result); + + const items = useMemo(() => getItems(result), [getItems, result]); + const totalCount = useMemo( + () => getCount(cachedResult), + [getCount, cachedResult] + ); + + const state: IQueryResultContextState = { + effectiveFilter, + result, + cachedResult, + items, + totalCount, + }; + + return ( + + {isFunction(children) + ? (children as (props: IQueryResultContextState) => React.ReactNode)( + state + ) + : children} + + ); +}; + +export function useQueryResultContext< + T extends QueryResult, + E extends IHasID = IHasID +>() { + const context = React.useContext(QueryResultStateContext); + + if (context === null) { + throw new Error( + "useQueryResultContext must be used within a ListStateContext" + ); + } + + return context as IQueryResultContextState; +} diff --git a/ui/v2.5/src/components/List/PagedList.tsx b/ui/v2.5/src/components/List/PagedList.tsx new file mode 100644 index 00000000000..bf34f4fdd12 --- /dev/null +++ b/ui/v2.5/src/components/List/PagedList.tsx @@ -0,0 +1,98 @@ +import React, { PropsWithChildren, useMemo } from "react"; +import { QueryResult } from "@apollo/client"; +import { ListFilterModel } from "src/models/list-filter/filter"; +import { Pagination, PaginationIndex } from "./Pagination"; +import { LoadingIndicator } from "../Shared/LoadingIndicator"; + +export const PagedList: React.FC< + PropsWithChildren<{ + result: QueryResult; + cachedResult: QueryResult; + filter: ListFilterModel; + totalCount: number; + onChangePage: (page: number) => void; + metadataByline?: React.ReactNode; + }> +> = ({ + result, + cachedResult, + filter, + totalCount, + onChangePage, + metadataByline, + children, +}) => { + const pages = Math.ceil(totalCount / filter.itemsPerPage); + + const pagination = useMemo(() => { + return ( + + ); + }, [ + filter.itemsPerPage, + filter.currentPage, + totalCount, + metadataByline, + onChangePage, + ]); + + const paginationIndex = useMemo(() => { + if (cachedResult.loading) return; + return ( + + ); + }, [ + cachedResult.loading, + filter.itemsPerPage, + filter.currentPage, + totalCount, + metadataByline, + ]); + + const content = useMemo(() => { + if (result.loading) { + return ; + } + if (result.error) { + return

    {result.error.message}

    ; + } + + return ( + <> + {children} + {!!pages && ( + <> + {paginationIndex} + {pagination} + + )} + + ); + }, [ + result.loading, + result.error, + pages, + children, + pagination, + paginationIndex, + ]); + + return ( + <> + {pagination} + {paginationIndex} + {content} + + ); +}; diff --git a/ui/v2.5/src/components/List/util.ts b/ui/v2.5/src/components/List/util.ts index 7ebe679b9bb..0cda1b4cb3a 100644 --- a/ui/v2.5/src/components/List/util.ts +++ b/ui/v2.5/src/components/List/util.ts @@ -1,11 +1,69 @@ -import { useContext, useMemo } from "react"; +import { useCallback, useContext, useEffect, useMemo, useState } from "react"; +import Mousetrap from "mousetrap"; import { ListFilterModel } from "src/models/list-filter/filter"; -import * as GQL from "src/core/generated-graphql"; +import { useHistory, useLocation } from "react-router-dom"; +import { isEqual, isFunction } from "lodash-es"; +import { QueryResult } from "@apollo/client"; +import { IHasID } from "src/utils/data"; import { ConfigurationContext } from "src/hooks/Config"; import { View } from "./views"; -export function useDefaultFilter(mode: GQL.FilterMode, view?: View) { - const emptyFilter = useMemo(() => new ListFilterModel(mode), [mode]); +export function useFilterURL( + filter: ListFilterModel, + setFilter: React.Dispatch>, + options?: { + defaultFilter?: ListFilterModel; + setURL?: boolean; + } +) { + const { defaultFilter, setURL = true } = options ?? {}; + + const history = useHistory(); + const location = useLocation(); + + // when the filter changes, update the URL + const updateFilter = useCallback( + ( + value: ListFilterModel | ((prevState: ListFilterModel) => ListFilterModel) + ) => { + const newFilter = isFunction(value) ? value(filter) : value; + + if (setURL) { + const newParams = newFilter.makeQueryParameters(); + history.replace({ ...history.location, search: newParams }); + } else { + // set the filter without updating the URL + setFilter(newFilter); + } + }, + [history, setURL, setFilter, filter] + ); + + // This hook runs on every page location change (ie navigation), + // and updates the filter accordingly. + useEffect(() => { + // re-init to load default filter on empty new query params + if (!location.search) { + if (defaultFilter) updateFilter(defaultFilter.clone()); + return; + } + + // the query has changed, update filter if necessary + setFilter((prevFilter) => { + let newFilter = prevFilter.empty(); + newFilter.configureFromQueryString(location.search); + if (!isEqual(newFilter, prevFilter)) { + return newFilter; + } else { + return prevFilter; + } + }); + }, [location.search, defaultFilter, setFilter, updateFilter]); + + return { setFilter: updateFilter }; +} + +export function useDefaultFilter(emptyFilter: ListFilterModel, view?: View) { const { configuration: config, loading } = useContext(ConfigurationContext); const defaultFilter = useMemo(() => { @@ -30,3 +88,258 @@ export function useDefaultFilter(mode: GQL.FilterMode, view?: View) { return { defaultFilter: retFilter, loading }; } + +export function useListKeyboardShortcuts(props: { + currentPage?: number; + onChangePage?: (page: number) => void; + showEditFilter?: () => void; + pages?: number; + onSelectAll?: () => void; + onSelectNone?: () => void; +}) { + const { + currentPage, + onChangePage, + showEditFilter, + pages = 0, + onSelectAll, + onSelectNone, + } = props; + + // set up hotkeys + useEffect(() => { + if (showEditFilter) { + Mousetrap.bind("f", (e) => { + showEditFilter(); + // prevent default behavior of typing f in a text field + // otherwise the filter dialog closes, the query field is focused and + // f is typed. + e.preventDefault(); + }); + + return () => { + Mousetrap.unbind("f"); + }; + } + }, [showEditFilter]); + + useEffect(() => { + if (!currentPage || !changePage || !pages) return; + + function changePage(page: number) { + if (!currentPage || !onChangePage || !pages) return; + if (page >= 1 && page <= pages) { + onChangePage(page); + } + } + + Mousetrap.bind("right", () => { + changePage(currentPage + 1); + }); + Mousetrap.bind("left", () => { + changePage(currentPage - 1); + }); + Mousetrap.bind("shift+right", () => { + changePage(Math.min(pages, currentPage + 10)); + }); + Mousetrap.bind("shift+left", () => { + changePage(Math.max(1, currentPage - 10)); + }); + Mousetrap.bind("ctrl+end", () => { + changePage(pages); + }); + Mousetrap.bind("ctrl+home", () => { + changePage(1); + }); + + return () => { + Mousetrap.unbind("right"); + Mousetrap.unbind("left"); + Mousetrap.unbind("shift+right"); + Mousetrap.unbind("shift+left"); + Mousetrap.unbind("ctrl+end"); + Mousetrap.unbind("ctrl+home"); + }; + }, [currentPage, onChangePage, pages]); + + useEffect(() => { + Mousetrap.bind("s a", () => onSelectAll?.()); + Mousetrap.bind("s n", () => onSelectNone?.()); + + return () => { + Mousetrap.unbind("s a"); + Mousetrap.unbind("s n"); + }; + }, [onSelectAll, onSelectNone]); +} + +export function useListSelect(items: T[]) { + const [selectedIds, setSelectedIds] = useState>(new Set()); + const [lastClickedId, setLastClickedId] = useState(); + + function singleSelect(id: string, selected: boolean) { + setLastClickedId(id); + + const newSelectedIds = new Set(selectedIds); + if (selected) { + newSelectedIds.add(id); + } else { + newSelectedIds.delete(id); + } + + setSelectedIds(newSelectedIds); + } + + function selectRange(startIndex: number, endIndex: number) { + let start = startIndex; + let end = endIndex; + if (start > end) { + const tmp = start; + start = end; + end = tmp; + } + + const subset = items.slice(start, end + 1); + const newSelectedIds = new Set(); + + subset.forEach((item) => { + newSelectedIds.add(item.id); + }); + + setSelectedIds(newSelectedIds); + } + + function multiSelect(id: string) { + let startIndex = 0; + let thisIndex = -1; + + if (lastClickedId) { + startIndex = items.findIndex((item) => { + return item.id === lastClickedId; + }); + } + + thisIndex = items.findIndex((item) => { + return item.id === id; + }); + + selectRange(startIndex, thisIndex); + } + + function onSelectChange(id: string, selected: boolean, shiftKey: boolean) { + if (shiftKey) { + multiSelect(id); + } else { + singleSelect(id, selected); + } + } + + function onSelectAll() { + const newSelectedIds = new Set(); + items.forEach((item) => { + newSelectedIds.add(item.id); + }); + + setSelectedIds(newSelectedIds); + setLastClickedId(undefined); + } + + function onSelectNone() { + const newSelectedIds = new Set(); + setSelectedIds(newSelectedIds); + setLastClickedId(undefined); + } + + const getSelected = useMemo(() => { + let cached: T[] | undefined; + return () => { + if (cached) { + return cached; + } + + cached = items.filter((value) => selectedIds.has(value.id)); + return cached; + }; + }, [items, selectedIds]); + + return { + selectedIds, + getSelected, + onSelectChange, + onSelectAll, + onSelectNone, + }; +} + +export type IListSelect = ReturnType>; + +// returns true if the filter has changed in a way that impacts the total count +function totalCountImpacted( + oldFilter: ListFilterModel, + newFilter: ListFilterModel +) { + return ( + oldFilter.criteria.length !== newFilter.criteria.length || + oldFilter.criteria.some((c) => { + const newCriterion = newFilter.criteria.find( + (nc) => nc.getId() === c.getId() + ); + return !newCriterion || !isEqual(c, newCriterion); + }) + ); +} + +// this hook caches a query result and count, and only updates it when the filter changes +// in a way that would impact the result count +// it is used to prevent the result count/pagination from flickering when changing pages or sorting +export function useCachedQueryResult( + filter: ListFilterModel, + result: T +) { + const [cachedResult, setCachedResult] = useState(result); + const [lastFilter, setLastFilter] = useState(filter); + + // if we are only changing the page or sort, don't update the result count + useEffect(() => { + if (!result.loading) { + setCachedResult(result); + } else { + if (totalCountImpacted(lastFilter, filter)) { + setCachedResult(result); + } + } + + setLastFilter(filter); + }, [filter, result, lastFilter]); + + return cachedResult; +} + +export function useScrollToTopOnPageChange(currentPage: number) { + // scroll to the top of the page when the page changes + useEffect(() => { + // if the current page has a detail-header, then + // scroll up relative to that rather than 0, 0 + const detailHeader = document.querySelector(".detail-header"); + if (detailHeader) { + window.scrollTo(0, detailHeader.scrollHeight - 50); + } else { + window.scrollTo(0, 0); + } + }, [currentPage]); +} + +// handle case where page is more than there are pages +export function useEnsureValidPage( + filter: ListFilterModel, + totalCount: number, + setFilter: React.Dispatch> +) { + useEffect(() => { + const totalPages = Math.ceil(totalCount / filter.itemsPerPage); + + if (totalPages > 0 && filter.currentPage > totalPages) { + setFilter((prevFilter) => prevFilter.changePage(1)); + } + }, [filter, totalCount, setFilter]); +} diff --git a/ui/v2.5/src/components/Performers/PerformerList.tsx b/ui/v2.5/src/components/Performers/PerformerList.tsx index 9dd0ff277ab..68733d3b8da 100644 --- a/ui/v2.5/src/components/Performers/PerformerList.tsx +++ b/ui/v2.5/src/components/Performers/PerformerList.tsx @@ -9,7 +9,7 @@ import { useFindPerformers, usePerformersDestroy, } from "src/core/StashService"; -import { makeItemList, showWhenSelected } from "../List/ItemList"; +import { ItemList, ItemListContext, showWhenSelected } from "../List/ItemList"; import { ListFilterModel } from "src/models/list-filter/filter"; import { DisplayMode } from "src/models/list-filter/types"; import { PerformerTagger } from "../Tagger/performers/PerformerTagger"; @@ -23,16 +23,13 @@ import TextUtils from "src/utils/text"; import { PerformerCardGrid } from "./PerformerCardGrid"; import { View } from "../List/views"; -const PerformerItemList = makeItemList({ - filterMode: GQL.FilterMode.Performers, - useResult: useFindPerformers, - getItems(result: GQL.FindPerformersQueryResult) { - return result?.data?.findPerformers?.performers ?? []; - }, - getCount(result: GQL.FindPerformersQueryResult) { - return result?.data?.findPerformers?.count ?? 0; - }, -}); +function getItems(result: GQL.FindPerformersQueryResult) { + return result?.data?.findPerformers?.performers ?? []; +} + +function getCount(result: GQL.FindPerformersQueryResult) { + return result?.data?.findPerformers?.count ?? 0; +} export const FormatHeight = (height?: number | null) => { const intl = useIntl(); @@ -175,6 +172,8 @@ export const PerformerList: React.FC = ({ const [isExportDialogOpen, setIsExportDialogOpen] = useState(false); const [isExportAll, setIsExportAll] = useState(false); + const filterMode = GQL.FilterMode.Performers; + const otherOperations = [ { text: intl.formatMessage({ id: "actions.open_random" }), @@ -319,16 +318,24 @@ export const PerformerList: React.FC = ({ } return ( - + selectable + > + + ); }; diff --git a/ui/v2.5/src/components/Scenes/SceneList.tsx b/ui/v2.5/src/components/Scenes/SceneList.tsx index 4ffd2504079..6fa7c5dbd18 100644 --- a/ui/v2.5/src/components/Scenes/SceneList.tsx +++ b/ui/v2.5/src/components/Scenes/SceneList.tsx @@ -5,7 +5,7 @@ import { useHistory } from "react-router-dom"; import Mousetrap from "mousetrap"; import * as GQL from "src/core/generated-graphql"; import { queryFindScenes, useFindScenes } from "src/core/StashService"; -import { makeItemList, showWhenSelected } from "../List/ItemList"; +import { ItemList, ItemListContext, showWhenSelected } from "../List/ItemList"; import { ListFilterModel } from "src/models/list-filter/filter"; import { DisplayMode } from "src/models/list-filter/types"; import { Tagger } from "../Tagger/scenes/SceneTagger"; @@ -26,51 +26,49 @@ import { objectTitle } from "src/core/files"; import TextUtils from "src/utils/text"; import { View } from "../List/views"; -const SceneItemList = makeItemList({ - filterMode: GQL.FilterMode.Scenes, - useResult: useFindScenes, - getItems(result: GQL.FindScenesQueryResult) { - return result?.data?.findScenes?.scenes ?? []; - }, - getCount(result: GQL.FindScenesQueryResult) { - return result?.data?.findScenes?.count ?? 0; - }, - renderMetadataByline(result: GQL.FindScenesQueryResult) { - const duration = result?.data?.findScenes?.duration; - const size = result?.data?.findScenes?.filesize; - const filesize = size ? TextUtils.fileSize(size) : undefined; - - if (!duration && !size) { - return; - } +function getItems(result: GQL.FindScenesQueryResult) { + return result?.data?.findScenes?.scenes ?? []; +} - const separator = duration && size ? " - " : ""; +function getCount(result: GQL.FindScenesQueryResult) { + return result?.data?.findScenes?.count ?? 0; +} - return ( - -  ( - {duration ? ( - - {TextUtils.secondsAsTimeString(duration, 3)} - - ) : undefined} - {separator} - {size && filesize ? ( - - - {` ${TextUtils.formatFileSizeUnit(filesize.unit)}`} - - ) : undefined} - ) - - ); - }, -}); +function renderMetadataByline(result: GQL.FindScenesQueryResult) { + const duration = result?.data?.findScenes?.duration; + const size = result?.data?.findScenes?.filesize; + const filesize = size ? TextUtils.fileSize(size) : undefined; + + if (!duration && !size) { + return; + } + + const separator = duration && size ? " - " : ""; + + return ( + +  ( + {duration ? ( + + {TextUtils.secondsAsTimeString(duration, 3)} + + ) : undefined} + {separator} + {size && filesize ? ( + + + {` ${TextUtils.formatFileSizeUnit(filesize.unit)}`} + + ) : undefined} + ) + + ); +} interface ISceneList { filterHook?: (filter: ListFilterModel) => ListFilterModel; @@ -95,6 +93,8 @@ export const SceneList: React.FC = ({ const [isExportDialogOpen, setIsExportDialogOpen] = useState(false); const [isExportAll, setIsExportAll] = useState(false); + const filterMode = GQL.FilterMode.Scenes; + const otherOperations = [ { text: intl.formatMessage({ id: "actions.play_selected" }), @@ -350,19 +350,28 @@ export const SceneList: React.FC = ({ return ( - + selectable + > + + ); }; diff --git a/ui/v2.5/src/components/Scenes/SceneMarkerList.tsx b/ui/v2.5/src/components/Scenes/SceneMarkerList.tsx index b81a4aecf3e..2bf7ae8dba5 100644 --- a/ui/v2.5/src/components/Scenes/SceneMarkerList.tsx +++ b/ui/v2.5/src/components/Scenes/SceneMarkerList.tsx @@ -9,22 +9,19 @@ import { useFindSceneMarkers, } from "src/core/StashService"; import NavUtils from "src/utils/navigation"; -import { makeItemList } from "../List/ItemList"; +import { ItemList, ItemListContext } from "../List/ItemList"; import { ListFilterModel } from "src/models/list-filter/filter"; import { DisplayMode } from "src/models/list-filter/types"; import { MarkerWallPanel } from "../Wall/WallPanel"; import { View } from "../List/views"; -const SceneMarkerItemList = makeItemList({ - filterMode: GQL.FilterMode.SceneMarkers, - useResult: useFindSceneMarkers, - getItems(result: GQL.FindSceneMarkersQueryResult) { - return result?.data?.findSceneMarkers?.scene_markers ?? []; - }, - getCount(result: GQL.FindSceneMarkersQueryResult) { - return result?.data?.findSceneMarkers?.count ?? 0; - }, -}); +function getItems(result: GQL.FindSceneMarkersQueryResult) { + return result?.data?.findSceneMarkers?.scene_markers ?? []; +} + +function getCount(result: GQL.FindSceneMarkersQueryResult) { + return result?.data?.findSceneMarkers?.count ?? 0; +} interface ISceneMarkerList { filterHook?: (filter: ListFilterModel) => ListFilterModel; @@ -40,6 +37,8 @@ export const SceneMarkerList: React.FC = ({ const intl = useIntl(); const history = useHistory(); + const filterMode = GQL.FilterMode.SceneMarkers; + const otherOperations = [ { text: intl.formatMessage({ id: "actions.play_random" }), @@ -97,14 +96,22 @@ export const SceneMarkerList: React.FC = ({ } return ( - + > + + ); }; diff --git a/ui/v2.5/src/components/Shared/FilterSelect.tsx b/ui/v2.5/src/components/Shared/FilterSelect.tsx index c8fcb7013c6..732b1cffbf6 100644 --- a/ui/v2.5/src/components/Shared/FilterSelect.tsx +++ b/ui/v2.5/src/components/Shared/FilterSelect.tsx @@ -14,10 +14,7 @@ import cx from "classnames"; import { useToast } from "src/hooks/Toast"; import { useDebounce } from "src/hooks/debounce"; - -interface IHasID { - id: string; -} +import { IHasID } from "src/utils/data"; export type Option = { value: string; object: T }; diff --git a/ui/v2.5/src/components/Shared/styles.scss b/ui/v2.5/src/components/Shared/styles.scss index c3bcf684dc1..2fc991cf161 100644 --- a/ui/v2.5/src/components/Shared/styles.scss +++ b/ui/v2.5/src/components/Shared/styles.scss @@ -1,3 +1,23 @@ +.LoadingIndicator { + // fade in animation - delay showing + animation: fadeInAnimation ease 200ms; + animation-delay: 200ms; + animation-fill-mode: forwards; + animation-iteration-count: 1; + + opacity: 0; +} + +@keyframes fadeInAnimation { + 0% { + opacity: 0; + } + + 100% { + opacity: 1; + } +} + .LoadingIndicator { align-items: center; display: flex; @@ -6,7 +26,7 @@ width: 100%; &:not(.card-based) { - height: 70vh; + padding-top: 2rem; } &-message { diff --git a/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx b/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx index d3c71f7874d..522dfe64ad7 100644 --- a/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx +++ b/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx @@ -1,4 +1,4 @@ -import { Tabs, Tab } from "react-bootstrap"; +import { Tabs, Tab, Form } from "react-bootstrap"; import React, { useEffect, useMemo, useState } from "react"; import { useHistory, Redirect, RouteComponentProps } from "react-router-dom"; import { FormattedMessage, useIntl } from "react-intl"; @@ -79,16 +79,18 @@ const StudioTabs: React.FC<{ abbreviateCounter: boolean; showAllCounts?: boolean; }> = ({ tabKey, studio, abbreviateCounter, showAllCounts = false }) => { + const [showAllDetails, setShowAllDetails] = useState(showAllCounts); + const sceneCount = - (showAllCounts ? studio.scene_count_all : studio.scene_count) ?? 0; + (showAllDetails ? studio.scene_count_all : studio.scene_count) ?? 0; const galleryCount = - (showAllCounts ? studio.gallery_count_all : studio.gallery_count) ?? 0; + (showAllDetails ? studio.gallery_count_all : studio.gallery_count) ?? 0; const imageCount = - (showAllCounts ? studio.image_count_all : studio.image_count) ?? 0; + (showAllDetails ? studio.image_count_all : studio.image_count) ?? 0; const performerCount = - (showAllCounts ? studio.performer_count_all : studio.performer_count) ?? 0; + (showAllDetails ? studio.performer_count_all : studio.performer_count) ?? 0; const groupCount = - (showAllCounts ? studio.group_count_all : studio.group_count) ?? 0; + (showAllDetails ? studio.group_count_all : studio.group_count) ?? 0; const populatedDefaultTab = useMemo(() => { let ret: TabKey = "scenes"; @@ -123,6 +125,21 @@ const StudioTabs: React.FC<{ baseURL: `/studios/${studio.id}`, }); + const contentSwitch = useMemo( + () => ( +
    + setShowAllDetails(!showAllDetails)} + type="switch" + label={} + /> +
    + ), + [showAllDetails] + ); + return ( } > - + {contentSwitch} + } > - + {contentSwitch} + } > - + {contentSwitch} + } > + {contentSwitch} } > - + {contentSwitch} + = ({ - active, - studio, -}) => { - function filterHook(filter: ListFilterModel) { +function useFilterHook(studio: GQL.StudioDataFragment) { + return (filter: ListFilterModel) => { const studioValue = { id: studio.id!, label: studio.name! }; // if studio is already present, then we modify it, otherwise add let parentStudioCriterion = filter.criteria.find((c) => { @@ -44,7 +36,19 @@ export const StudioChildrenPanel: React.FC = ({ } return filter; - } + }; +} + +interface IStudioChildrenPanel { + active: boolean; + studio: GQL.StudioDataFragment; +} + +export const StudioChildrenPanel: React.FC = ({ + active, + studio, +}) => { + const filterHook = useFilterHook(studio); return ( = ({ active, studio, + showChildStudioContent, }) => { - const filterHook = useStudioFilterHook(studio); + const filterHook = useStudioFilterHook(studio, showChildStudioContent); return ( = ({ active, studio, + showChildStudioContent, }) => { - const filterHook = useStudioFilterHook(studio); + const filterHook = useStudioFilterHook(studio, showChildStudioContent); return ( = ({ active, studio, + showChildStudioContent, }) => { - const filterHook = useStudioFilterHook(studio); + const filterHook = useStudioFilterHook(studio, showChildStudioContent); return ( = ({ active, studio, + showChildStudioContent, }) => { const studioCriterion = new StudiosCriterion(); studioCriterion.value = { @@ -28,7 +30,7 @@ export const StudioPerformersPanel: React.FC = ({ groups: [studioCriterion], }; - const filterHook = useStudioFilterHook(studio); + const filterHook = useStudioFilterHook(studio, showChildStudioContent); return ( = ({ active, studio, + showChildStudioContent, }) => { - const filterHook = useStudioFilterHook(studio); + const filterHook = useStudioFilterHook(studio, showChildStudioContent); return ( = ({ const [isExportDialogOpen, setIsExportDialogOpen] = useState(false); const [isExportAll, setIsExportAll] = useState(false); + const filterMode = GQL.FilterMode.Studios; + const otherOperations = [ { text: intl.formatMessage({ id: "actions.view_random" }), @@ -177,15 +176,23 @@ export const StudioList: React.FC = ({ } return ( - + selectable + > + + ); }; diff --git a/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx b/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx index d392707d127..1c80dc157b8 100644 --- a/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx +++ b/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx @@ -1,4 +1,4 @@ -import { Tabs, Tab, Dropdown } from "react-bootstrap"; +import { Tabs, Tab, Dropdown, Form } from "react-bootstrap"; import React, { useEffect, useMemo, useState } from "react"; import { useHistory, Redirect, RouteComponentProps } from "react-router-dom"; import { FormattedMessage, useIntl } from "react-intl"; @@ -82,20 +82,22 @@ const TagTabs: React.FC<{ abbreviateCounter: boolean; showAllCounts?: boolean; }> = ({ tabKey, tag, abbreviateCounter, showAllCounts = false }) => { + const [showAllDetails, setShowAllDetails] = useState(showAllCounts); + const sceneCount = - (showAllCounts ? tag.scene_count_all : tag.scene_count) ?? 0; + (showAllDetails ? tag.scene_count_all : tag.scene_count) ?? 0; const imageCount = - (showAllCounts ? tag.image_count_all : tag.image_count) ?? 0; + (showAllDetails ? tag.image_count_all : tag.image_count) ?? 0; const galleryCount = - (showAllCounts ? tag.gallery_count_all : tag.gallery_count) ?? 0; + (showAllDetails ? tag.gallery_count_all : tag.gallery_count) ?? 0; const groupCount = - (showAllCounts ? tag.group_count_all : tag.group_count) ?? 0; + (showAllDetails ? tag.group_count_all : tag.group_count) ?? 0; const sceneMarkerCount = - (showAllCounts ? tag.scene_marker_count_all : tag.scene_marker_count) ?? 0; + (showAllDetails ? tag.scene_marker_count_all : tag.scene_marker_count) ?? 0; const performerCount = - (showAllCounts ? tag.performer_count_all : tag.performer_count) ?? 0; + (showAllDetails ? tag.performer_count_all : tag.performer_count) ?? 0; const studioCount = - (showAllCounts ? tag.studio_count_all : tag.studio_count) ?? 0; + (showAllDetails ? tag.studio_count_all : tag.studio_count) ?? 0; const populatedDefaultTab = useMemo(() => { let ret: TabKey = "scenes"; @@ -133,6 +135,21 @@ const TagTabs: React.FC<{ baseURL: `/tags/${tag.id}`, }); + const contentSwitch = useMemo( + () => ( +
    + setShowAllDetails(!showAllDetails)} + type="switch" + label={} + /> +
    + ), + [showAllDetails] + ); + return ( } > - + {contentSwitch} +
    } > - + {contentSwitch} + } > - + {contentSwitch} + } > - + {contentSwitch} + } > - + {contentSwitch} + } > - + {contentSwitch} + } > - + {contentSwitch} +
    ); diff --git a/ui/v2.5/src/components/Tags/TagDetails/TagGalleriesPanel.tsx b/ui/v2.5/src/components/Tags/TagDetails/TagGalleriesPanel.tsx index 7d46c4e3133..bb95a7ea1c0 100644 --- a/ui/v2.5/src/components/Tags/TagDetails/TagGalleriesPanel.tsx +++ b/ui/v2.5/src/components/Tags/TagDetails/TagGalleriesPanel.tsx @@ -7,13 +7,15 @@ import { View } from "src/components/List/views"; interface ITagGalleriesPanel { active: boolean; tag: GQL.TagDataFragment; + showSubTagContent?: boolean; } export const TagGalleriesPanel: React.FC = ({ active, tag, + showSubTagContent, }) => { - const filterHook = useTagFilterHook(tag); + const filterHook = useTagFilterHook(tag, showSubTagContent); return ( = ({ active, tag }) => { - const filterHook = useTagFilterHook(tag); + showSubTagContent?: boolean; +}> = ({ active, tag, showSubTagContent }) => { + const filterHook = useTagFilterHook(tag, showSubTagContent); return ; }; diff --git a/ui/v2.5/src/components/Tags/TagDetails/TagImagesPanel.tsx b/ui/v2.5/src/components/Tags/TagDetails/TagImagesPanel.tsx index 61e235499bb..19ceb543183 100644 --- a/ui/v2.5/src/components/Tags/TagDetails/TagImagesPanel.tsx +++ b/ui/v2.5/src/components/Tags/TagDetails/TagImagesPanel.tsx @@ -7,10 +7,15 @@ import { View } from "src/components/List/views"; interface ITagImagesPanel { active: boolean; tag: GQL.TagDataFragment; + showSubTagContent?: boolean; } -export const TagImagesPanel: React.FC = ({ active, tag }) => { - const filterHook = useTagFilterHook(tag); +export const TagImagesPanel: React.FC = ({ + active, + tag, + showSubTagContent, +}) => { + const filterHook = useTagFilterHook(tag, showSubTagContent); return ( = ({ - active, - tag, -}) => { - function filterHook(filter: ListFilterModel) { +function useFilterHook(tag: GQL.TagDataFragment, showSubTagContent?: boolean) { + return (filter: ListFilterModel) => { const tagValue = { id: tag.id, label: tag.name }; // if tag is already present, then we modify it, otherwise add let tagCriterion = filter.criteria.find((c) => { @@ -45,13 +37,27 @@ export const TagMarkersPanel: React.FC = ({ tagCriterion.value = { items: [tagValue], excluded: [], - depth: 0, + depth: showSubTagContent ? -1 : 0, }; filter.criteria.push(tagCriterion); } return filter; - } + }; +} + +interface ITagMarkersPanel { + active: boolean; + tag: GQL.TagDataFragment; + showSubTagContent?: boolean; +} + +export const TagMarkersPanel: React.FC = ({ + active, + tag, + showSubTagContent, +}) => { + const filterHook = useFilterHook(tag, showSubTagContent); return ( = ({ active, tag, + showSubTagContent, }) => { - const filterHook = useTagFilterHook(tag); + const filterHook = useTagFilterHook(tag, showSubTagContent); return ( = ({ active, tag }) => { - const filterHook = useTagFilterHook(tag); +export const TagScenesPanel: React.FC = ({ + active, + tag, + showSubTagContent, +}) => { + const filterHook = useTagFilterHook(tag, showSubTagContent); return ( = ({ active, tag, + showSubTagContent, }) => { - const filterHook = useTagFilterHook(tag); + const filterHook = useTagFilterHook(tag, showSubTagContent); return ; }; diff --git a/ui/v2.5/src/components/Tags/TagList.tsx b/ui/v2.5/src/components/Tags/TagList.tsx index 42a6316f9c0..cbde60a5cd9 100644 --- a/ui/v2.5/src/components/Tags/TagList.tsx +++ b/ui/v2.5/src/components/Tags/TagList.tsx @@ -3,7 +3,7 @@ import cloneDeep from "lodash-es/cloneDeep"; import Mousetrap from "mousetrap"; import { ListFilterModel } from "src/models/list-filter/filter"; import { DisplayMode } from "src/models/list-filter/types"; -import { makeItemList, showWhenSelected } from "../List/ItemList"; +import { ItemList, ItemListContext, showWhenSelected } from "../List/ItemList"; import { Button } from "react-bootstrap"; import { Link, useHistory } from "react-router-dom"; import * as GQL from "src/core/generated-graphql"; @@ -27,27 +27,27 @@ import { TagCardGrid } from "./TagCardGrid"; import { EditTagsDialog } from "./EditTagsDialog"; import { View } from "../List/views"; +function getItems(result: GQL.FindTagsQueryResult) { + return result?.data?.findTags?.tags ?? []; +} + +function getCount(result: GQL.FindTagsQueryResult) { + return result?.data?.findTags?.count ?? 0; +} + interface ITagList { filterHook?: (filter: ListFilterModel) => ListFilterModel; alterQuery?: boolean; } -const TagItemList = makeItemList({ - filterMode: GQL.FilterMode.Tags, - useResult: useFindTags, - getItems(result: GQL.FindTagsQueryResult) { - return result?.data?.findTags?.tags ?? []; - }, - getCount(result: GQL.FindTagsQueryResult) { - return result?.data?.findTags?.count ?? 0; - }, -}); - export const TagList: React.FC = ({ filterHook, alterQuery }) => { const Toast = useToast(); const [deletingTag, setDeletingTag] = useState | null>(null); + const filterMode = GQL.FilterMode.Tags; + const view = View.Tags; + function getDeleteTagInput() { const tagInput: Partial = {}; if (deletingTag) { @@ -355,18 +355,25 @@ export const TagList: React.FC = ({ filterHook, alterQuery }) => { } return ( - + filterHook={filterHook} + view={view} + selectable + > + + ); }; diff --git a/ui/v2.5/src/core/studios.ts b/ui/v2.5/src/core/studios.ts index ca1d88c5ff3..37781274172 100644 --- a/ui/v2.5/src/core/studios.ts +++ b/ui/v2.5/src/core/studios.ts @@ -1,11 +1,11 @@ import * as GQL from "src/core/generated-graphql"; import { StudiosCriterion } from "src/models/list-filter/criteria/studios"; import { ListFilterModel } from "src/models/list-filter/filter"; -import React from "react"; -import { ConfigurationContext } from "src/hooks/Config"; -export const useStudioFilterHook = (studio: GQL.StudioDataFragment) => { - const { configuration } = React.useContext(ConfigurationContext); +export const useStudioFilterHook = ( + studio: GQL.StudioDataFragment, + showChildStudioContent?: boolean +) => { return (filter: ListFilterModel) => { const studioValue = { id: studio.id, label: studio.name }; // if studio is already present, then we modify it, otherwise add @@ -22,7 +22,7 @@ export const useStudioFilterHook = (studio: GQL.StudioDataFragment) => { studioCriterion.value = { items: [studioValue], excluded: [], - depth: configuration?.ui.showChildStudioContent ? -1 : 0, + depth: showChildStudioContent ? -1 : 0, }; studioCriterion.modifier = GQL.CriterionModifier.Includes; filter.criteria.push(studioCriterion); diff --git a/ui/v2.5/src/core/tags.ts b/ui/v2.5/src/core/tags.ts index 6ce586941b0..b62e6954781 100644 --- a/ui/v2.5/src/core/tags.ts +++ b/ui/v2.5/src/core/tags.ts @@ -6,11 +6,11 @@ import { TagsCriterionOption, } from "src/models/list-filter/criteria/tags"; import { ListFilterModel } from "src/models/list-filter/filter"; -import React from "react"; -import { ConfigurationContext } from "src/hooks/Config"; -export const useTagFilterHook = (tag: GQL.TagDataFragment) => { - const { configuration } = React.useContext(ConfigurationContext); +export const useTagFilterHook = ( + tag: GQL.TagDataFragment, + showSubTagContent?: boolean +) => { return (filter: ListFilterModel) => { const tagValue = { id: tag.id, label: tag.name }; // if tag is already present, then we modify it, otherwise add @@ -42,7 +42,7 @@ export const useTagFilterHook = (tag: GQL.TagDataFragment) => { tagCriterion.value = { items: [tagValue], excluded: [], - depth: configuration?.ui.showChildTagContent ? -1 : 0, + depth: showSubTagContent ? -1 : 0, }; tagCriterion.modifier = GQL.CriterionModifier.IncludesAll; filter.criteria.push(tagCriterion); diff --git a/ui/v2.5/src/hooks/modal.ts b/ui/v2.5/src/hooks/modal.ts new file mode 100644 index 00000000000..8b69f94c1bf --- /dev/null +++ b/ui/v2.5/src/hooks/modal.ts @@ -0,0 +1,10 @@ +import React from "react"; + +export function useModal() { + const [modal, setModal] = React.useState(); + + const closeModal = () => setModal(undefined); + const showModal = (m: React.ReactNode) => setModal(m); + + return { modal, closeModal, showModal }; +} diff --git a/ui/v2.5/src/index.scss b/ui/v2.5/src/index.scss index 7bdb49040ea..c277e864ac1 100755 --- a/ui/v2.5/src/index.scss +++ b/ui/v2.5/src/index.scss @@ -258,6 +258,15 @@ dd { padding: 5px 0; } + .item-list-header { + align-content: center; + // border-bottom: solid 2px #192127; + display: flex; + justify-content: center; + margin: 0; + padding: 5px 0 0 0; + } + .item-list-container { padding-top: 15px; diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index 5145ea829cd..59ea1459e17 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -1086,7 +1086,9 @@ "image_index": "Image #", "images": "Images", "include_parent_tags": "Include parent tags", + "include_sub_studio_content": "Include sub-studio content", "include_sub_studios": "Include subsidiary studios", + "include_sub_tag_content": "Include sub-tag content", "include_sub_tags": "Include sub-tags", "index_of_total": "{index} of {total}", "instagram": "Instagram", 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 6be7d604087..2c36cf54586 100644 --- a/ui/v2.5/src/models/list-filter/criteria/criterion.ts +++ b/ui/v2.5/src/models/list-filter/criteria/criterion.ts @@ -89,6 +89,15 @@ export abstract class Criterion { this.value = value; } + public clone(): Criterion { + const newCriterion = new (this.constructor as new ( + type: CriterionOption, + value: V + ) => Criterion)(this.criterionOption, this.value); + newCriterion.modifier = this.modifier; + return newCriterion; + } + public static getModifierLabel(intl: IntlShape, modifier: CriterionModifier) { const modifierMessageID = modifierMessageIDs[modifier]; @@ -251,6 +260,19 @@ export class ILabeledIdCriterionOption extends CriterionOption { } export class ILabeledIdCriterion extends Criterion { + constructor(type: CriterionOption, value: ILabeledId[] = []) { + super(type, value); + } + + public clone(): Criterion { + const newCriterion = new ILabeledIdCriterion( + this.criterionOption, + this.value.map((v) => ({ ...v })) + ); + newCriterion.modifier = this.modifier; + return newCriterion; + } + protected getLabelValue(_intl: IntlShape): string { return this.value.map((v) => v.label).join(", "); } @@ -272,23 +294,33 @@ export class ILabeledIdCriterion extends Criterion { return this.value.length > 0; } - - constructor(type: CriterionOption) { - super(type, []); - } } export class IHierarchicalLabeledIdCriterion extends Criterion { - constructor(type: CriterionOption) { - const value: IHierarchicalLabelValue = { + constructor( + type: CriterionOption, + value: IHierarchicalLabelValue = { items: [], excluded: [], depth: 0, - }; - + } + ) { super(type, value); } + public clone(): Criterion { + const newCriterion = new IHierarchicalLabeledIdCriterion( + this.criterionOption, + { + ...this.value, + items: this.value.items.map((v) => ({ ...v })), + excluded: this.value.excluded.map((v) => ({ ...v })), + } + ); + newCriterion.modifier = this.modifier; + return newCriterion; + } + override get modifier(): CriterionModifier { return this._modifier; } @@ -501,8 +533,17 @@ export class StringCriterion extends Criterion { } export class MultiStringCriterion extends Criterion { - constructor(type: CriterionOption) { - super(type, []); + constructor(type: CriterionOption, value: string[] = []) { + super(type, value); + } + + public clone(): Criterion { + const newCriterion = new MultiStringCriterion( + this.criterionOption, + this.value.slice() + ); + newCriterion.modifier = this.modifier; + return newCriterion; } protected getLabelValue(_intl: IntlShape) { diff --git a/ui/v2.5/src/models/list-filter/filter.ts b/ui/v2.5/src/models/list-filter/filter.ts index 794fd2a7e90..599a6bbaedc 100644 --- a/ui/v2.5/src/models/list-filter/filter.ts +++ b/ui/v2.5/src/models/list-filter/filter.ts @@ -67,26 +67,48 @@ export class ListFilterModel { public constructor( mode: FilterMode, config?: ConfigDataFragment, - defaultZoomIndex?: number + options?: { + defaultZoomIndex?: number; + defaultSortBy?: string; + defaultSortDir?: SortDirectionEnum; + } ) { this.mode = mode; this.config = config; this.options = getFilterOptions(mode); const { defaultSortBy, displayModeOptions } = this.options; - this.sortBy = defaultSortBy; - if (this.sortBy === "date") { - this.sortDirection = SortDirectionEnum.Desc; + if (options?.defaultSortBy) { + this.sortBy = options.defaultSortBy; + if (options.defaultSortDir) { + this.sortDirection = options.defaultSortDir; + } + } else { + this.sortBy = defaultSortBy; + if (this.sortBy === "date") { + this.sortDirection = SortDirectionEnum.Desc; + } } this.displayMode = displayModeOptions[0]; - if (defaultZoomIndex !== undefined) { - this.defaultZoomIndex = defaultZoomIndex; - this.zoomIndex = defaultZoomIndex; + if (options?.defaultZoomIndex !== undefined) { + this.defaultZoomIndex = options.defaultZoomIndex; + this.zoomIndex = options.defaultZoomIndex; } } public clone() { - return Object.assign(new ListFilterModel(this.mode, this.config), this); + const ret = Object.assign( + new ListFilterModel(this.mode, this.config), + this + ); + ret.criteria = this.criteria.map((c) => c.clone()); + return ret; + } + + public empty() { + return new ListFilterModel(this.mode, this.config, { + defaultZoomIndex: this.defaultZoomIndex, + }); } // returns the number of filters applied @@ -443,4 +465,44 @@ export class ListFilterModel { zoom_index: this.zoomIndex, }; } + + public clearCriteria() { + const ret = this.clone(); + ret.criteria = []; + ret.currentPage = 1; + return ret; + } + + public removeCriterion(type: CriterionType) { + const ret = this.clone(); + const c = ret.criteria.find((cc) => cc.criterionOption.type === type); + + if (!c) return ret; + + const newCriteria = ret.criteria.filter((cc) => { + return cc.getId() !== c.getId(); + }); + + ret.criteria = newCriteria; + ret.currentPage = 1; + return ret; + } + + public changePage(page: number) { + const ret = this.clone(); + ret.currentPage = page; + return ret; + } + + public setZoom(zoomIndex: number) { + const ret = this.clone(); + ret.zoomIndex = zoomIndex; + return ret; + } + + public setDisplayMode(displayMode: DisplayMode) { + const ret = this.clone(); + ret.displayMode = displayMode; + return ret; + } } diff --git a/ui/v2.5/src/utils/bulkUpdate.ts b/ui/v2.5/src/utils/bulkUpdate.ts index 70a5b38fca6..deb8956bb30 100644 --- a/ui/v2.5/src/utils/bulkUpdate.ts +++ b/ui/v2.5/src/utils/bulkUpdate.ts @@ -1,5 +1,6 @@ import * as GQL from "src/core/generated-graphql"; import isEqual from "lodash-es/isEqual"; +import { IHasID } from "./data"; interface IHasRating { rating100?: GQL.Maybe | undefined; @@ -21,10 +22,6 @@ export function getAggregateRating(state: IHasRating[]) { return ret; } -interface IHasID { - id: string; -} - interface IHasStudio { studio?: GQL.Maybe | undefined; } diff --git a/ui/v2.5/src/utils/data.ts b/ui/v2.5/src/utils/data.ts index 16660255223..26505bebb35 100644 --- a/ui/v2.5/src/utils/data.ts +++ b/ui/v2.5/src/utils/data.ts @@ -1,6 +1,10 @@ export const filterData = (data?: (T | null | undefined)[] | null) => data ? (data.filter((item) => item) as T[]) : []; +export interface IHasID { + id: string; +} + export interface ITypename { __typename?: string; } From c79f299d1a4ebee1155141cd94564302478fbe54 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Fri, 2 Aug 2024 18:32:11 +1000 Subject: [PATCH 063/103] Add clone methods to all criterion classes (#5109) --- .../models/list-filter/criteria/criterion.ts | 68 +++++++++++++------ .../models/list-filter/criteria/performers.ts | 7 ++ .../src/models/list-filter/criteria/phash.ts | 7 ++ .../src/models/list-filter/criteria/rating.ts | 17 +++-- .../models/list-filter/criteria/stash-ids.ts | 7 ++ 5 files changed, 81 insertions(+), 25 deletions(-) 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 2c36cf54586..36ed39894e7 100644 --- a/ui/v2.5/src/models/list-filter/criteria/criterion.ts +++ b/ui/v2.5/src/models/list-filter/criteria/criterion.ts @@ -89,14 +89,7 @@ export abstract class Criterion { this.value = value; } - public clone(): Criterion { - const newCriterion = new (this.constructor as new ( - type: CriterionOption, - value: V - ) => Criterion)(this.criterionOption, this.value); - newCriterion.modifier = this.modifier; - return newCriterion; - } + public abstract clone(): Criterion; public static getModifierLabel(intl: IntlShape, modifier: CriterionModifier) { const modifierMessageID = modifierMessageIDs[modifier]; @@ -519,6 +512,13 @@ export class StringCriterion extends Criterion { super(type, ""); } + public clone() { + const newCriterion = new StringCriterion(this.criterionOption); + newCriterion.modifier = this.modifier; + newCriterion.value = this.value; + return newCriterion; + } + protected getLabelValue(_intl: IntlShape) { return this.value; } @@ -714,6 +714,17 @@ export function createMandatoryNumberCriterionOption( } export class NumberCriterion extends Criterion { + constructor(type: CriterionOption) { + super(type, { value: undefined, value2: undefined }); + } + + public clone() { + const newCriterion = new NumberCriterion(this.criterionOption); + newCriterion.modifier = this.modifier; + newCriterion.value = { ...this.value }; + return newCriterion; + } + public get value(): INumberValue { return this._value; } @@ -772,10 +783,6 @@ export class NumberCriterion extends Criterion { return true; } - - constructor(type: CriterionOption) { - super(type, { value: undefined, value2: undefined }); - } } export class DurationCriterionOption extends MandatoryNumberCriterionOption { @@ -796,6 +803,13 @@ export class DurationCriterion extends Criterion { super(type, { value: undefined, value2: undefined }); } + public clone() { + const newCriterion = new DurationCriterion(this.criterionOption); + newCriterion.modifier = this.modifier; + newCriterion.value = { ...this.value }; + return newCriterion; + } + public toCriterionInput(): IntCriterionInput { return { modifier: this.modifier, @@ -869,6 +883,17 @@ export function createDateCriterionOption(value: CriterionType) { } export class DateCriterion extends Criterion { + constructor(type: CriterionOption) { + super(type, { value: "", value2: undefined }); + } + + public clone() { + const newCriterion = new DateCriterion(this.criterionOption); + newCriterion.modifier = this.modifier; + newCriterion.value = { ...this.value }; + return newCriterion; + } + public encodeValue() { return { value: this.value.value, @@ -915,10 +940,6 @@ export class DateCriterion extends Criterion { return true; } - - constructor(type: CriterionOption) { - super(type, { value: "", value2: undefined }); - } } export class TimestampCriterionOption extends CriterionOption { @@ -968,6 +989,17 @@ export function createMandatoryTimestampCriterionOption(value: CriterionType) { } export class TimestampCriterion extends Criterion { + constructor(type: CriterionOption) { + super(type, { value: "", value2: undefined }); + } + + public clone() { + const newCriterion = new TimestampCriterion(this.criterionOption); + newCriterion.modifier = this.modifier; + newCriterion.value = { ...this.value }; + return newCriterion; + } + public encodeValue() { return { value: this.value?.value, @@ -1025,8 +1057,4 @@ export class TimestampCriterion extends Criterion { return true; } - - constructor(type: CriterionOption) { - super(type, { value: "", value2: undefined }); - } } diff --git a/ui/v2.5/src/models/list-filter/criteria/performers.ts b/ui/v2.5/src/models/list-filter/criteria/performers.ts index 3d29dd9dc13..a4ce1a1bfdc 100644 --- a/ui/v2.5/src/models/list-filter/criteria/performers.ts +++ b/ui/v2.5/src/models/list-filter/criteria/performers.ts @@ -33,6 +33,13 @@ export class PerformersCriterion extends Criterion { super(PerformersCriterionOption, { items: [], excluded: [] }); } + public clone() { + const newCriterion = new PerformersCriterion(); + newCriterion.modifier = this.modifier; + newCriterion.value = { ...this.value }; + return newCriterion; + } + override get modifier(): CriterionModifier { return this._modifier; } diff --git a/ui/v2.5/src/models/list-filter/criteria/phash.ts b/ui/v2.5/src/models/list-filter/criteria/phash.ts index 4887751b368..ea794c30ea4 100644 --- a/ui/v2.5/src/models/list-filter/criteria/phash.ts +++ b/ui/v2.5/src/models/list-filter/criteria/phash.ts @@ -29,6 +29,13 @@ export class PhashCriterion extends Criterion { super(PhashCriterionOption, { value: "", distance: 0 }); } + public clone() { + const newCriterion = new PhashCriterion(); + newCriterion.modifier = this.modifier; + newCriterion.value = { ...this.value }; + return newCriterion; + } + protected getLabelValue() { const { value, distance } = this.value; if ( 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 b3feb85afe1..59760a53d57 100644 --- a/ui/v2.5/src/models/list-filter/criteria/rating.ts +++ b/ui/v2.5/src/models/list-filter/criteria/rating.ts @@ -40,6 +40,18 @@ export const RatingCriterionOption = new CriterionOption({ export class RatingCriterion extends Criterion { ratingSystem: RatingSystemOptions; + constructor(ratingSystem: RatingSystemOptions) { + super(RatingCriterionOption, { value: 0, value2: undefined }); + this.ratingSystem = ratingSystem; + } + + public clone() { + const newCriterion = new RatingCriterion(this.ratingSystem); + newCriterion.modifier = this.modifier; + newCriterion.value = { ...this.value }; + return newCriterion; + } + public get value(): INumberValue { return this._value; } @@ -76,9 +88,4 @@ export class RatingCriterion extends Criterion { return `${convertToRatingFormat(value, this.ratingSystem) ?? 0}`; } } - - constructor(ratingSystem: RatingSystemOptions) { - super(RatingCriterionOption, { 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 index 951c88b41fb..0ea7b0655fc 100644 --- a/ui/v2.5/src/models/list-filter/criteria/stash-ids.ts +++ b/ui/v2.5/src/models/list-filter/criteria/stash-ids.ts @@ -27,6 +27,13 @@ export class StashIDCriterion extends Criterion { }); } + public clone() { + const newCriterion = new StashIDCriterion(); + newCriterion.modifier = this.modifier; + newCriterion.value = { ...this.value }; + return newCriterion; + } + public get value(): IStashIDValue { return this._value; } From c8d4dacffd011653b083cfe0e3f0591fb0e3de43 Mon Sep 17 00:00:00 2001 From: blackx69 <107708032+blackx69@users.noreply.github.com> Date: Mon, 5 Aug 2024 19:34:27 -0500 Subject: [PATCH 064/103] Interactive Tools Enhancements Support (#5115) * added `useInteractive` hook and exposed to `PluginApi` * made `SceneFileInfoPanel` patchable --- .../SceneDetails/SceneFileInfoPanel.tsx | 7 ++- ui/v2.5/src/hooks/Interactive/context.tsx | 5 +- ui/v2.5/src/pluginApi.d.ts | 51 +++++++++++++++++-- ui/v2.5/src/pluginApi.tsx | 2 + 4 files changed, 60 insertions(+), 5 deletions(-) diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneFileInfoPanel.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneFileInfoPanel.tsx index de65f7ed070..8db3f93886e 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneFileInfoPanel.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneFileInfoPanel.tsx @@ -17,6 +17,7 @@ import NavUtils from "src/utils/navigation"; import TextUtils from "src/utils/text"; import { TextField, URLField, URLsField } from "src/utils/field"; import { StashIDPill } from "src/components/Shared/StashID"; +import { PatchComponent } from "../../../patch"; interface IFileInfoPanelProps { sceneID: string; @@ -174,7 +175,7 @@ interface ISceneFileInfoPanelProps { scene: GQL.SceneDataFragment; } -export const SceneFileInfoPanel: React.FC = ( +const _SceneFileInfoPanel: React.FC = ( props: ISceneFileInfoPanelProps ) => { const Toast = useToast(); @@ -315,4 +316,8 @@ export const SceneFileInfoPanel: React.FC = ( ); }; +export const SceneFileInfoPanel = PatchComponent( + "SceneFileInfoPanel", + _SceneFileInfoPanel +); export default SceneFileInfoPanel; diff --git a/ui/v2.5/src/hooks/Interactive/context.tsx b/ui/v2.5/src/hooks/Interactive/context.tsx index 487fa8468f5..04c2548ab92 100644 --- a/ui/v2.5/src/hooks/Interactive/context.tsx +++ b/ui/v2.5/src/hooks/Interactive/context.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useState } from "react"; +import React, { useCallback, useContext, useEffect, useState } from "react"; import { ConfigurationContext } from "../Config"; import { useLocalForage } from "../LocalForage"; import { Interactive as InteractiveAPI } from "./interactive"; @@ -208,4 +208,7 @@ export const InteractiveProvider: React.FC = ({ children }) => { ); }; +export const useInteractive = () => { + return useContext(InteractiveContext); +}; export default InteractiveProvider; diff --git a/ui/v2.5/src/pluginApi.d.ts b/ui/v2.5/src/pluginApi.d.ts index bbdc9fcff97..21629dda5d7 100644 --- a/ui/v2.5/src/pluginApi.d.ts +++ b/ui/v2.5/src/pluginApi.d.ts @@ -687,7 +687,9 @@ declare namespace PluginApi { NumberSetting: React.FC; StringListSetting: React.FC; ConstantSetting: React.FC; + SceneFileInfoPanel: React.FC; }; + type PatchableComponentNames = keyof typeof components | string; namespace utils { namespace NavUtils { function makePerformerScenesUrl(...args: any[]): any; @@ -955,11 +957,54 @@ declare namespace PluginApi { refetch: () => void; }; + export enum ConnectionState { + Missing, + Disconnected, + Error, + Connecting, + Syncing, + Uploading, + Ready, + } + + type Handy = typeof import("thehandy").default; + export type InteractiveAPI = { + readonly _connected: boolean; + readonly _playing: boolean; + readonly _scriptOffset: number; + readonly _handy: Handy; + readonly _useStashHostedFunscript: boolean; + connect(): Promise; + set handyKey(key: string); + get handyKey(): string; + set useStashHostedFunscript(useStashHostedFunscript: boolean); + get useStashHostedFunscript(): boolean; + set scriptOffset(offset: number); + uploadScript(funscriptPath: string, apiKey?: string): Promise; + sync(): Promise; + setServerTimeOffset(offset: number): void; + play(position: number): Promise; + pause(): Promise; + ensurePlaying(position: number): Promise; + setLooping(looping: boolean): Promise; + }; + + function useInteractive(): { + interactive: InteractiveAPI; + state: ConnectionState; + serverOffset: number; + initialised: boolean; + currentScript?: string; + error?: string; + initialise: () => Promise; + uploadScript: (funscriptPath: string) => Promise; + sync: () => Promise; + }; } namespace patch { - function before(target: string, fn: Function): void; - function instead(target: string, fn: Function): void; - function after(target: string, fn: Function): void; + function before(target: PatchableComponentNames, fn: Function): void; + function instead(target: PatchableComponentNames, fn: Function): void; + function after(target: PatchableComponentNames, fn: Function): void; } namespace register { function route(path: string, component: React.FC): void; diff --git a/ui/v2.5/src/pluginApi.tsx b/ui/v2.5/src/pluginApi.tsx index f35fb0fe6f0..ac8fcf840ec 100644 --- a/ui/v2.5/src/pluginApi.tsx +++ b/ui/v2.5/src/pluginApi.tsx @@ -16,6 +16,7 @@ import { useToast } from "./hooks/Toast"; import Event from "./hooks/event"; import { before, instead, after, components, RegisterComponent } from "./patch"; import { useSettings } from "./components/Settings/context"; +import { useInteractive } from "./hooks/Interactive/context"; // due to code splitting, some components may not have been loaded when a plugin // page is loaded. This function will load all components passed to it. @@ -94,6 +95,7 @@ export const PluginApi = { useSpriteInfo, useToast, useSettings, + useInteractive, }, patch: { // intercept the arguments of supported functions From aa1894964f97cb427a170c43368215198fb59666 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Mon, 12 Aug 2024 09:29:34 +1000 Subject: [PATCH 065/103] Codeberg weblate update (#5123) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Update translation files Updated by "Cleanup translation files" add-on in Weblate. Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/ * Translated using Weblate (French) Currently translated at 100.0% (1155 of 1155 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/fr/ * Translated using Weblate (Spanish) Currently translated at 100.0% (1155 of 1155 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/es/ * Translated using Weblate (Estonian) Currently translated at 95.7% (1106 of 1155 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/et/ * Translated using Weblate (French) Currently translated at 100.0% (1155 of 1155 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/fr/ * Translated using Weblate (Swedish) Currently translated at 100.0% (1155 of 1155 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/sv/ * Translated using Weblate (Chinese (Simplified)) Currently translated at 100.0% (1155 of 1155 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/zh_Hans/ * Translated using Weblate (German) Currently translated at 85.7% (990 of 1155 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/de/ * Translated using Weblate (German) Currently translated at 86.8% (1003 of 1155 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/de/ * Translated using Weblate (Portuguese (Brazil)) Currently translated at 74.0% (855 of 1155 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/pt_BR/ * Translated using Weblate (Finnish) Currently translated at 75.7% (875 of 1155 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/fi/ * Translated using Weblate (Norwegian Bokmål) Currently translated at 0.4% (5 of 1155 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/nb_NO/ * Added translation using Weblate (Hindi) * Added translation using Weblate (Latvian) * Translated using Weblate (Hindi) Currently translated at 5.7% (66 of 1155 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/hi/ * Translated using Weblate (Latvian) Currently translated at 5.6% (65 of 1155 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/lv/ * Translated using Weblate (German) Currently translated at 87.9% (1016 of 1155 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/de/ * Translated using Weblate (Chinese (Simplified)) Currently translated at 100.0% (1155 of 1155 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/zh_Hans/ * Added translation using Weblate (Norwegian Nynorsk) * Translated using Weblate (Norwegian Nynorsk) Currently translated at 10.3% (120 of 1155 strings) Translation: stash/stash Translate-URL: https://translate.codeberg.org/projects/stash/stash/nn/ --------- Co-authored-by: Codeberg Translate Co-authored-by: Larsluph Co-authored-by: gallegonovato Co-authored-by: Zesty6249 Co-authored-by: doodoo Co-authored-by: AlpacaSerious Co-authored-by: wql219 Co-authored-by: BSSPM Co-authored-by: icaro Co-authored-by: IiroS Co-authored-by: DogmaDragon Co-authored-by: saumya Co-authored-by: Marky05 Co-authored-by: human-corset Co-authored-by: tzuuuss Co-authored-by: throbbing --- ui/v2.5/src/locales/cs-CZ.json | 4 - ui/v2.5/src/locales/da-DK.json | 4 - ui/v2.5/src/locales/de-DE.json | 64 +++++++++--- ui/v2.5/src/locales/es-ES.json | 22 +++-- ui/v2.5/src/locales/et-EE.json | 13 +-- ui/v2.5/src/locales/fi-FI.json | 51 +++++++--- ui/v2.5/src/locales/fr-FR.json | 32 +++--- ui/v2.5/src/locales/hi.json | 70 +++++++++++++ ui/v2.5/src/locales/hu-HU.json | 2 - ui/v2.5/src/locales/id-ID.json | 2 - ui/v2.5/src/locales/it-IT.json | 4 - ui/v2.5/src/locales/ja-JP.json | 4 - ui/v2.5/src/locales/ko-KR.json | 4 - ui/v2.5/src/locales/lv.json | 73 ++++++++++++++ ui/v2.5/src/locales/nb_NO.json | 10 +- ui/v2.5/src/locales/nl-NL.json | 4 - ui/v2.5/src/locales/nn.json | 174 +++++++++++++++++++++++++++++++++ ui/v2.5/src/locales/pl-PL.json | 4 - ui/v2.5/src/locales/pt-BR.json | 17 ++-- ui/v2.5/src/locales/ro-RO.json | 2 - ui/v2.5/src/locales/ru-RU.json | 4 - ui/v2.5/src/locales/sv-SE.json | 22 +++-- ui/v2.5/src/locales/th-TH.json | 4 - ui/v2.5/src/locales/tr-TR.json | 4 - ui/v2.5/src/locales/zh-CN.json | 32 +++--- ui/v2.5/src/locales/zh-TW.json | 4 - 26 files changed, 497 insertions(+), 133 deletions(-) create mode 100644 ui/v2.5/src/locales/hi.json create mode 100644 ui/v2.5/src/locales/lv.json create mode 100644 ui/v2.5/src/locales/nn.json diff --git a/ui/v2.5/src/locales/cs-CZ.json b/ui/v2.5/src/locales/cs-CZ.json index 76571cf17c3..f2617b0f428 100644 --- a/ui/v2.5/src/locales/cs-CZ.json +++ b/ui/v2.5/src/locales/cs-CZ.json @@ -784,7 +784,6 @@ "galleries": "{count, plural, one {Galerie} other {Galerie}}", "images": "{count, plural, one {Obrázek} other {Obrázky}}", "markers": "{count, plural, one {Marker} other {Markery}}", - "movies": "{count, plural, one {Film} other {Filmy}}", "performers": "{count, plural, one {Účinkující} other {Účinkující}}", "scenes": "{count, plural, one {Scéna} other {Scény}}", "studios": "{count, plural, one {Studio} other {Studia}}", @@ -1270,8 +1269,6 @@ "o_count": "Počet O" }, "megabits_per_second": "{value} mbps", - "movie": "Film", - "movie_scene_number": "Číslo scény", "o_counter": "O-Počítadlo", "none": "Žádný", "pagination": { @@ -1318,7 +1315,6 @@ "ignore_auto_tag": "Ignoruj automatické tagování", "instagram": "Instragram", "metadata": "Metadata", - "movies": "Filmy", "name": "Jméno", "new": "Nový", "organized": "Organizovaný", diff --git a/ui/v2.5/src/locales/da-DK.json b/ui/v2.5/src/locales/da-DK.json index 0c440ea45b1..033f1896eda 100644 --- a/ui/v2.5/src/locales/da-DK.json +++ b/ui/v2.5/src/locales/da-DK.json @@ -745,7 +745,6 @@ "galleries": "{count, plural, one {Galleri} other {Gallerier}}", "images": "{count, plural, one {Billede} other {Billeder}}", "markers": "{count, plural, one {Markør} other {Markører}}", - "movies": "{count, plural, one {Film} other {Film}}", "performers": "{count, plural, one {Kunstner} other {Kunstnere}}", "scenes": "{count, plural, one {Scene} other {Scener}}", "studios": "{count, plural, one {Studie} other {Studier}}", @@ -1031,9 +1030,6 @@ }, "megabits_per_second": "{value} mbps", "metadata": "Metadata", - "movie": "Film", - "movie_scene_number": "Scene Nummer", - "movies": "Film", "name": "Navn", "new": "Ny", "none": "Ingen", diff --git a/ui/v2.5/src/locales/de-DE.json b/ui/v2.5/src/locales/de-DE.json index 7226e0f912e..7e80f5b5c48 100644 --- a/ui/v2.5/src/locales/de-DE.json +++ b/ui/v2.5/src/locales/de-DE.json @@ -131,7 +131,11 @@ "create_parent_studio": "Übergeordnetes Studio erstellen", "add_manual_date": "Manuelles Datum hinzufügen", "copy_to_clipboard": "In die Zwischenablage kopieren", - "view_history": "Verlauf anzeigen" + "view_history": "Verlauf anzeigen", + "add_o": "O hinzufügen", + "add_play": "abspielen hinzufügen", + "clear_date_data": "Datum-Eintrag löschen", + "assign_stashid_to_parent_studio": "Stash ID existierendem Elternstudio hinzufügen und Metadaten aktualisieren" }, "actions_name": "Aktionen", "age": "Alter", @@ -179,7 +183,9 @@ "set_tag_label": "Tags anhängen", "show_male_desc": "Auswahl ob männliche Darsteller der Szene hinzugefügt werden können.", "show_male_label": "Männliche Darsteller anzeigen", - "source": "Quelle" + "source": "Quelle", + "mark_organized_label": "Beim speichern als organisiert markieren", + "mark_organized_desc": "Markiere die Szene nach dem klicken auf Speichern als organisiert." }, "noun_query": "Anfrage", "results": { @@ -252,7 +258,9 @@ "successfully_cancelled_temporary_behaviour": "Erfolgreich temporäres Verhalten aufgehoben", "until_restart": "bis Neustart", "video_sort_order": "Standard-Videosortierreihenfolge", - "video_sort_order_desc": "Reihenfolge, in der Videos standardmäßig sortiert werden." + "video_sort_order_desc": "Reihenfolge, in der Videos standardmäßig sortiert werden.", + "server_port": "Server Port", + "server_port_desc": "Port, auf dem der DLNA Server laufen soll. Benötigt nach einer Änderung einen DLNA Neustart." }, "general": { "auth": { @@ -334,7 +342,16 @@ } }, "download_ffmpeg": { - "heading": "FFmpeg herunterladen" + "heading": "FFmpeg herunterladen", + "description": "Lädt FFmpeg in das Konfigurationsverzeichnis herunter und löscht die Pfade ffmpeg und ffprobe, um sie aus dem Konfigurationsverzeichnis aufzulösen." + }, + "ffmpeg_path": { + "heading": "FFmpeg.exe Pfad", + "description": "Pfad zu ffmpeg.exe (nicht nur der Ordner). Wenn nicht angegeben wird ffmpeg.exe aus der Umgebungsvariable $PATH, dem Konfigurationsverzeichnis oder aus $HOME/.stash aufgelöst" + }, + "ffprobe_path": { + "description": "Pfad zu ffprobe.exe (nicht nur der Ordner). Wenn nicht angegeben wird ffprobe.exe aus der Umgebungsvariable $PATH, dem Konfigurationsverzeichnis oder aus $HOME/.stash aufgelöst", + "heading": "FFprobe.exe Pfad" } }, "funscript_heatmap_draw_range": "Reichweite in generierte Heatmaps einbeziehen", @@ -380,7 +397,11 @@ "sqlite_location": "Dateispeicherort für die SQLite-Datenbank (erfordert Neustart). ACHTUNG: Ein Speicherort auf einem anderen System als dem Server auf dem Stash läuft (z.B. Netzwerkspeicher) wird nicht unterstützt!", "video_ext_desc": "Durch Kommas getrennte Liste von Dateierweiterungen, die als Videos identifiziert werden.", "video_ext_head": "Videodateiformate", - "video_head": "Video" + "video_head": "Video", + "plugins_path": { + "heading": "Plugins Pfad", + "description": "Speicherort der Plugin-Konfigurationsdateien" + } }, "library": { "exclusions": "Ausnahmen", @@ -392,7 +413,9 @@ }, "plugins": { "hooks": "Einbindungen", - "triggers_on": "Auslösen bei" + "triggers_on": "Auslösen bei", + "installed_plugins": "Installierte Plugins", + "available_plugins": "Verfügbare Plugins" }, "scraping": { "entity_metadata": "{entityType} Metadaten", @@ -403,7 +426,9 @@ "scrapers": "Scraper", "search_by_name": "Suche nach Name", "supported_types": "Unterstützte Typen", - "supported_urls": "Unterstützte Adressen" + "supported_urls": "Unterstützte Adressen", + "available_scrapers": "Verfügbare Scraper", + "installed_scrapers": "Installierte Scraper" }, "stashbox": { "add_instance": "Stash-Box-Instanz hinzufügen", @@ -471,7 +496,12 @@ "source": "Quelle", "source_options": "{source} Optionen", "sources": "Quellen", - "strategy": "Strategie" + "strategy": "Strategie", + "skip_multiple_matches": "Überspringe Treffer, die mehr als ein Ergebnis haben", + "skip_multiple_matches_tooltip": "Wenn dies nicht aktiviert ist und mehr als ein Ergebnis zurückgegeben wird, wird ein passendes zufällig ausgewählt", + "skip_single_name_performers": "Darsteller mit nur einem - nicht eindeutigem - Namen überspringen", + "skip_single_name_performers_tooltip": "Wenn dies nicht aktiviert ist, werden Darsteller, die oft generisch sind, wie Samantha oder Olga, abgeglichen", + "tag_skipped_matches": "Übersprungenes Tag passt zu" }, "import_from_exported_json": "Import aus exportiertem JSON im Metadatenverzeichnis. Löscht die vorhandene Datenbank.", "incremental_import": "Inkrementeller Import aus einer Export-ZIP-Datei.", @@ -495,7 +525,19 @@ "scanning_paths": "Scannen der folgenden Pfade" }, "scan_for_content_desc": "Suchen nach neuen Inhalten und füge sie der Datenbank hinzu.", - "set_name_date_details_from_metadata_if_present": "Name, Datum und Details aus eingebetteten Metadaten festlegen" + "set_name_date_details_from_metadata_if_present": "Name, Datum und Details aus eingebetteten Metadaten festlegen", + "clean_generated": { + "blob_files": "Blob Dateien", + "description": "Entfernt generierte Dateien ohne korrespondierenden Datenbankeintrag.", + "image_thumbnails": "Bild Miniaturansichten", + "image_thumbnails_desc": "Bild Miniaturansichten und Clips", + "markers": "Marker Vorschauen", + "previews": "Szenen Vorschauen", + "previews_desc": "Szenen Vorschauen und Miniaturansichten", + "transcodes": "Transkodierte Szenen", + "sprites": "Sprites der Szenen" + }, + "generate_sprites_during_scan_tooltip": "Die Anzahl an Bilder, die unter dem Video Player, zur einfacheren Navigation, angezeigt werden." }, "tools": { "scene_duplicate_checker": "Duplikatsprüfung für Szenen", @@ -726,7 +768,6 @@ "galleries": "{count, plural, one {Galerie} other {Galerien}}", "images": "{count, plural, one {Bild} other {Bilder}}", "markers": "{count, plural, one {Markierung} other {Markierungen}}", - "movies": "{count, plural, one {Film} other {Filme}}", "performers": "{count, plural, one {Darsteller} other {Darsteller}}", "scenes": "{count, plural, one {Szene} other {Szenen}}", "studios": "{count, plural, one {Studio} other {Studios}}", @@ -1022,9 +1063,6 @@ }, "megabits_per_second": "{value} Megabit pro Sekunde", "metadata": "Metadaten", - "movie": "Film", - "movie_scene_number": "Filmszenennummer", - "movies": "Filme", "name": "Name", "new": "Neu", "none": "Keiner", diff --git a/ui/v2.5/src/locales/es-ES.json b/ui/v2.5/src/locales/es-ES.json index d64b9a60099..e30ac04f14d 100644 --- a/ui/v2.5/src/locales/es-ES.json +++ b/ui/v2.5/src/locales/es-ES.json @@ -789,11 +789,11 @@ "galleries": "{count, plural, one {Galería} other {Galerías}}", "images": "{count, plural, one {Imagen} other {Imágenes}}", "markers": "{count, plural, one {Marcador} other {Marcadores}}", - "movies": "{count, plural, one {Película} other {Películas}}", "performers": "{count, plural, one {Actriz/Actor} other {Actrices/Actores}}", "scenes": "{count, plural, one {Escena} other {Escenas}}", "studios": "{count, plural, one {Estudio} other {Estudios}}", - "tags": "{count, plural, one {Etiqueta} other {Etiquetas}}" + "tags": "{count, plural, one {Etiqueta} other {Etiquetas}}", + "groups": "{count, plural, one {Grupo} other {Grupos}}" }, "country": "País", "cover_image": "Carátula", @@ -1075,9 +1075,6 @@ }, "megabits_per_second": "{value} megabits por segundo (mbps)", "metadata": "Metadatos", - "movie": "Película", - "movie_scene_number": "Número de escena", - "movies": "Películas", "name": "Nombre", "new": "Añadir", "none": "Ninguno/a", @@ -1088,7 +1085,8 @@ "first": "Primera", "last": "Última", "next": "Siguiente", - "previous": "Anterior" + "previous": "Anterior", + "current_total": "{current} de {total}" }, "parent_of": "Matriz de {children}", "parent_studios": "Estudio matriz", @@ -1327,7 +1325,9 @@ "header": "Error", "lazy_component_error_help": "Si ractualizaste Stash recientemente, por favor recarga la página o limpia la caché de tu navegador.", "something_went_wrong": "Algo salió mal.", - "loading_type": "Error cargando {type}" + "loading_type": "Error cargando {type}", + "invalid_javascript_string": "Código javascript no válido: {error}", + "invalid_json_string": "Cadena JSON no válida: {error}" }, "file_count": "Conteo de archivos", "hasChapters": "Tiene capítulos", @@ -1465,5 +1465,11 @@ "zip_file_count": "Números de archivos zip", "unknown_date": "Fecha desconocida", "stash_id_endpoint": "Punto final de identificador (ID) de Stash", - "studio_and_parent": "Estudio y ancestro" + "studio_and_parent": "Estudio y ancestro", + "group": "Grupo", + "group_count": "Recuento de grupos", + "group_scene_number": "Número de escena", + "groups": "Grupos", + "studio_count": "Recuento de estudios", + "studio_tags": "Etiquetas de estudio" } diff --git a/ui/v2.5/src/locales/et-EE.json b/ui/v2.5/src/locales/et-EE.json index 0dda1abdca7..af04aad9de4 100644 --- a/ui/v2.5/src/locales/et-EE.json +++ b/ui/v2.5/src/locales/et-EE.json @@ -126,7 +126,10 @@ "assign_stashid_to_parent_studio": "Määra eksisteerivale vanemstuudiole Stash ID ja uuenda metaandmeid", "create_parent_studio": "Loo vanemstuudio", "encoding_image": "Pildi kodeerimine…", - "optimise_database": "Optimiseeri Andmebaasi" + "optimise_database": "Optimiseeri Andmebaasi", + "clean_generated": "Puhasta genereeritud faile", + "choose_date": "Vali kuupäev", + "copy_to_clipboard": "Kopeeri lõikelauale" }, "actions_name": "Tegevused", "age": "Vanus", @@ -244,7 +247,9 @@ "successfully_cancelled_temporary_behaviour": "Edukalt tühistatud ajutine käitumine", "until_restart": "restardini", "video_sort_order": "Videote Sorteerimise Vaikeväärtus", - "video_sort_order_desc": "Viis, kuidas vaikimisi videoid sorteerida." + "video_sort_order_desc": "Viis, kuidas vaikimisi videoid sorteerida.", + "server_port": "Serveri port", + "server_port_desc": "Port DLNA serveri jaoks. Vajab peale muutmist DLNA taaskäivitust." }, "general": { "auth": { @@ -756,7 +761,6 @@ "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}}", @@ -1062,9 +1066,6 @@ }, "megabits_per_second": "{value} mbps", "metadata": "Metaandmed", - "movie": "Film", - "movie_scene_number": "Filmi Stseeninumber", - "movies": "Filmid", "name": "Nimi", "new": "Uus", "none": "Puudub", diff --git a/ui/v2.5/src/locales/fi-FI.json b/ui/v2.5/src/locales/fi-FI.json index ccc68878749..f432c723ac3 100644 --- a/ui/v2.5/src/locales/fi-FI.json +++ b/ui/v2.5/src/locales/fi-FI.json @@ -129,7 +129,11 @@ "enable": "Ota käyttöön", "remove_date": "Poista päivä", "encoding_image": "Enkoodataan kuvaa…", - "clear_date_data": "Tyhjennä päivämäärä" + "clear_date_data": "Tyhjennä päivämäärä", + "add_o": "Lisää O", + "add_manual_date": "Lisää päivämäärä käsin", + "add_play": "Lisää toistokerta", + "view_history": "Näytä historia" }, "actions_name": "Toiminnot", "age": "Ikä", @@ -178,7 +182,8 @@ "show_male_desc": "Valitse käytetäänkö miesesiintyjiä automaattisessa tunnisteiden asettamisessa.", "show_male_label": "Näytä miesesiintyjät", "source": "Lähde", - "mark_organized_label": "Merkitse järjestetyksi kun tallennetaan" + "mark_organized_label": "Merkitse järjestetyksi kun tallennetaan", + "mark_organized_desc": "Merkitsee kohtauksen järjestellyksi hetki kun Tallenna -painiketta on painettu." }, "noun_query": "Haku", "results": { @@ -250,7 +255,9 @@ "server_display_name_desc": "DLNA -palvelimen nimi. Oletuksena {server_name}, jos tyhjä.", "until_restart": "uudelleenkäynnistykseen asti", "video_sort_order": "Videoiden oletusjärjestys", - "video_sort_order_desc": "Videoiden oletusjärjestys." + "video_sort_order_desc": "Videoiden oletusjärjestys.", + "server_port": "Palvelimen portti", + "server_port_desc": "Portti, jota DLNA palvelin kuuntelee. DLNA palvelin pitää käynnistää uudelleen muuttamisen jälkeen." }, "general": { "auth": { @@ -339,6 +346,9 @@ "ffmpeg": { "hardware_acceleration": { "heading": "FFmpeg laitteistokoodaus" + }, + "download_ffmpeg": { + "heading": "Lataa FFmpeg" } } }, @@ -554,7 +564,7 @@ } }, "preview_type": { - "description": "Seinän kohteiden asetukset", + "description": "Oletuksena on videoesikatselut (mp4). Jos haluat vähentää suorittimen käyttöä kun selaillaan kirjastoa, käytä animoituja kuvia (webp). Ne pitää kuitenkin generoida erikseen videoesikatseluiden lisäksi ja ovat kooltaan isompia.", "heading": "Esikatselun tyyppi", "options": { "animated": "Animoitu kuva", @@ -625,8 +635,7 @@ "galleries": "{count, plural, one {Galleria} other {Galleriaa}}", "images": "{count, plural, one {Kuva} other {Kuvaa}}", "markers": "{count, plural, one {Merkki} other {Merkkiä}}", - "movies": "{count, plural, one {Elokuva} other {Elokuvat}}", - "performers": "{count, plural, one {Esiintyjä} other {Esiintyjää}}", + "performers": "{count, plural, one {Esiintyjä} other {Esiintyjät}}", "scenes": "{count, plural, one {Kohtaus} other {Kohtausta}}", "studios": "{count, plural, one {Studio} other {Studiota}}", "tags": "{count, plural, one {Tunniste} other {Tunnistetta}}" @@ -876,9 +885,6 @@ }, "megabits_per_second": "{value} megabittiä sekunnissa", "metadata": "Metadata", - "movie": "Elokuva", - "movie_scene_number": "Kohtauksen numero", - "movies": "Elokuvat", "name": "Nimi", "new": "Uusi", "none": "Ei mitään", @@ -1067,7 +1073,9 @@ "started_auto_tagging": "Aloiteettin automaattinen tunnisteiden asetus", "started_generating": "Aloitettiin generointi", "started_importing": "Aloitettiin tuonti", - "updated_entity": "{entity} päivitetty" + "updated_entity": "{entity} päivitetty", + "merged_scenes": "Yhdistetyt kohtaukset", + "removed_entity": "Poistettu {count, plural, one {{singularEntity}} other {{pluralEntity}}}" }, "total": "Yhteensä", "true": "On", @@ -1082,5 +1090,26 @@ "audio_codec": "Audiokodekki", "play_count": "Toistomäärä", "play_duration": "Toistettu aika", - "primary_file": "Ensisijainen tiedosto" + "primary_file": "Ensisijainen tiedosto", + "studio_tagger": { + "network_error": "Verkkovirhe", + "name_already_exists": "Nimi on jo olemassa", + "failed_to_save_studio": "Ei voitu tallentaa studiota \"{studio}\"", + "current_page": "Nykyinen sivu", + "query_all_studios_in_the_database": "Kaikki studiot tietokannassa", + "no_results_found": "Ei tuloksia.", + "studio_names_separated_by_comma": "Studion nimet eriteltynä pilkuilla", + "studio_successfully_tagged": "Studion tunnisteiden asettaminen onnistui", + "studio_selection": "Studion valinta", + "update_studios": "Päivitä studiot", + "update_studio": "Päivitä studio", + "untagged_studios": "Studiot ilman tunnisteita" + }, + "studio_tags": "Studion tunnisteet", + "tag_sub_tag_tooltip": "On alitunnisteita", + "tag_parent_tooltip": "On ylätunniste", + "time": "Aika", + "urls": "URLit", + "unknown_date": "Tuntematon päivä", + "type": "Tyyppi" } diff --git a/ui/v2.5/src/locales/fr-FR.json b/ui/v2.5/src/locales/fr-FR.json index fa9717350b8..c9f0eb2cd2d 100644 --- a/ui/v2.5/src/locales/fr-FR.json +++ b/ui/v2.5/src/locales/fr-FR.json @@ -796,11 +796,11 @@ "galleries": "{count, plural, one {Galerie} other {Galeries}}", "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} other {Studios}}", - "tags": "{count, plural, one {Étiquette} other {Étiquettes}}" + "tags": "{count, plural, one {Étiquette} other {Étiquettes}}", + "groups": "{count, plural, one {Groupe} other {Groupes}}" }, "country": "Pays", "cover_image": "Vignette", @@ -988,7 +988,7 @@ "duplicated_phash": "Dupliqué (Empreinte)", "duration": "Durée", "effect_filters": { - "aspect": "Aspect", + "aspect": "Rapport d'image", "blue": "Bleu", "blur": "Flou", "brightness": "Luminosité", @@ -1014,7 +1014,9 @@ "lazy_component_error_help": "Si vous avez récemment mis à jour Stash, merci de recharger la page ou de vider le cache de votre navigateur.", "loading_type": "Erreur de chargement de {type}", "something_went_wrong": "Quelque chose n'a pas fonctionné.", - "header": "Erreur" + "header": "Erreur", + "invalid_javascript_string": "Code javascript invalide : {error}", + "invalid_json_string": "Chaine JSON invalide : {error}" }, "ethnicity": "Ethnicité", "existing_value": "valeur existante", @@ -1034,7 +1036,7 @@ "filters": "Filtres", "folder": "Répertoire", "framerate": "Fréquence", - "frames_per_second": "{value} fps", + "frames_per_second": "{value} ips", "front_page": { "types": { "premade_filter": "Filtre prédéfini", @@ -1108,9 +1110,6 @@ }, "megabits_per_second": "{value} mbps", "metadata": "Métadonnées", - "movie": "Film", - "movie_scene_number": "Numéro de scène", - "movies": "Films", "name": "Nom", "new": "Nouveau", "none": "Aucun", @@ -1121,7 +1120,8 @@ "first": "Première", "last": "Dernière", "next": "Suivante", - "previous": "Précédente" + "previous": "Précédente", + "current_total": "{current} sur {total}" }, "parent_of": "Parent de {children}", "parent_studio": "Studio parent", @@ -1389,9 +1389,9 @@ "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", - "started_generating": "Démarrage de la génération", - "started_importing": "Démarrage de l'importation", + "started_auto_tagging": "Début de l'étiquetage automatique", + "started_generating": "Début de la génération", + "started_importing": "Début de l'importation", "updated_entity": "{entity} mis·e à jour" }, "total": "Total", @@ -1465,5 +1465,11 @@ "playdate_recorded_no": "Aucune date de lecture enregistrée", "plays": "{value} lectures", "unknown_date": "Date inconnue", - "history": "Historique" + "history": "Historique", + "group_count": "Nombre de groupes", + "group_scene_number": "Numéro de scène", + "groups": "Groupes", + "group": "Groupe", + "studio_count": "Nombre de studios", + "studio_tags": "Étiquettes du studio" } diff --git a/ui/v2.5/src/locales/hi.json b/ui/v2.5/src/locales/hi.json new file mode 100644 index 00000000000..4f18b8fb7fc --- /dev/null +++ b/ui/v2.5/src/locales/hi.json @@ -0,0 +1,70 @@ +{ + "actions": { + "copy_to_clipboard": "क्लिपबोर्ड पर कॉपी करें", + "clear_front_image": "सामने की इमेज हटाएं", + "add_to_entity": "{entityType} में जोड़ें", + "add_manual_date": "तारीख जोड़ें", + "add_o": "O जोड़ें", + "add_play": "प्ले जोड़ें", + "allow": "स्वीकार करें", + "apply": "लगाएं", + "auto_tag": "स्वचालित टैग", + "backup": "बैकअप", + "browse_for_image": "इमेज के लिए ब्राउज़ करें…", + "cancel": "रद्द करें", + "anonymise": "अज्ञात करें", + "choose_date": "तारीख चुनें", + "clean": "साफ़ करें", + "clear": "हटाएं", + "clear_back_image": "पीछे की इमेज हटाएं", + "clear_date_data": "तारीख डेटा को हटाएं", + "clean_generated": "उत्पन्न हुई फ़ाइलें साफ़ करें", + "clear_image": "इमेज हटाएं", + "confirm": "पुष्टि करें", + "continue": "जारी रखें", + "create": "बनाएं", + "create_chapters": "चैप्टर बनाएं", + "create_parent_studio": "पैरेंट स्टूडियो बनाएं", + "created_entity": "बनाया गया {entity_type}: {entity_name}", + "customise": "कस्टमाइज करें", + "delete_entity": "{entityType} मिटाएं", + "disable": "अक्षम करें", + "delete_generated_supporting_files": "उत्पन्न की गई सहायक फ़ाइलें मिटाएं", + "download": "डाउनलोड करें", + "download_backup": "बैकअप डाउनलोड करें", + "edit_entity": "{entityType} सुधारें", + "enable": "सक्षम करें", + "export": "निर्यात करें", + "export_all": "सभी निर्यात करें…", + "logout": "लॉग आउट", + "merge": "मिलाएं", + "merge_from": "इससे मिलाएं", + "merge_into": "इसमें मिलाएं", + "previous_action": "पीछे जाएं", + "reload": "पुनः लोड करें", + "remove": "निकालें", + "assign_stashid_to_parent_studio": "मौजूदा पैरेंट स्टूडियो को स्टैश आईडी निर्दिष्ट करें और मेटाडेटा अपडेट करें", + "close": "बंद करें", + "edit": "सुधारें", + "encoding_image": "इमेज को एन्कोड किया जा रहा है…", + "hide": "छिपाएं", + "next_action": "अगला", + "add": "जोड़ें", + "add_directory": "फ़ोल्डर जोड़ें", + "delete": "मिटाएं", + "delete_file": "फ़ाइल मिटाएं", + "delete_file_and_funscript": "फ़ाइल मिटाएं (और फ़नस्क्रिप्ट)", + "download_anonymised": "अज्ञात रूप से डाउनलोड करें", + "import": "आयात करें…", + "add_entity": "{entityType} जोड़ें", + "allow_temporarily": "कुछ समय के लिये स्वीकार करें", + "create_entity": "{entityType} बनाएं", + "create_marker": "मार्कर बनाएं", + "disallow": "अस्वीकार करें", + "find": "खोजें", + "ignore": "नजरअंदाज करें", + "generate": "उत्पन्न करें", + "preview": "पूर्वदर्शन", + "refresh": "ताजा करें" + } +} diff --git a/ui/v2.5/src/locales/hu-HU.json b/ui/v2.5/src/locales/hu-HU.json index e75bcac2805..d4909ace6f2 100644 --- a/ui/v2.5/src/locales/hu-HU.json +++ b/ui/v2.5/src/locales/hu-HU.json @@ -489,8 +489,6 @@ } }, "metadata": "Metaadatok", - "movie": "Film", - "movies": "Filmek", "name": "Név", "new": "Új", "none": "Nincs", diff --git a/ui/v2.5/src/locales/id-ID.json b/ui/v2.5/src/locales/id-ID.json index a2d6291a6fb..a7327508a7c 100644 --- a/ui/v2.5/src/locales/id-ID.json +++ b/ui/v2.5/src/locales/id-ID.json @@ -655,8 +655,6 @@ "generic": "Memuat…" }, "tag": "Tag", - "movie": "Film", - "movies": "Film", "o_counter": "Penghitung Crot", "operations": "Operasi", "organized": "Terorganisir", diff --git a/ui/v2.5/src/locales/it-IT.json b/ui/v2.5/src/locales/it-IT.json index 839a172fdb2..98269f6d839 100644 --- a/ui/v2.5/src/locales/it-IT.json +++ b/ui/v2.5/src/locales/it-IT.json @@ -660,7 +660,6 @@ "galleries": "{count, plural, one {Galleria} other {Gallerie}}", "images": "{count, plural, one {Immagine} other {Immagini}}", "markers": "{count, plural, one {Marcatore} other {Marcatori}}", - "movies": "{count, plural, one {Film} other {Film}}", "performers": "{count, plural, one {Attore/Attrice} other {Attori/Attrici}}", "scenes": "{count, plural, one {Scena} other {Scene}}", "studios": "{count, plural, one {Studio} other {Studi}}", @@ -935,9 +934,6 @@ }, "megabits_per_second": "{value} megabits per secondo", "metadata": "Metadati", - "movie": "Film", - "movie_scene_number": "Numero Scena", - "movies": "Film", "name": "Nome", "new": "Nuovo", "none": "Nessuno/a", diff --git a/ui/v2.5/src/locales/ja-JP.json b/ui/v2.5/src/locales/ja-JP.json index 66b75790709..73593a3ba5d 100644 --- a/ui/v2.5/src/locales/ja-JP.json +++ b/ui/v2.5/src/locales/ja-JP.json @@ -766,7 +766,6 @@ "galleries": "{count, plural, one {Gallery} other {Galleries}}", "images": "{count, plural, one {Image} other {Images}}", "markers": "{count, plural, one {Marker} other {Markers}}", - "movies": "{count, plural, one {Movie} other {Movies}}", "performers": "{count, plural, one {Performer} other {Performers}}", "scenes": "{count, plural, one {Scene} other {Scenes}}", "studios": "{count, plural, one {Studio} other {Studios}}", @@ -1034,9 +1033,6 @@ }, "megabits_per_second": "{value}Mbps", "metadata": "メタデータ", - "movie": "映画", - "movie_scene_number": "映画シーン数", - "movies": "映画", "name": "名前", "new": "新規作成", "none": "なし", diff --git a/ui/v2.5/src/locales/ko-KR.json b/ui/v2.5/src/locales/ko-KR.json index 70ba5295b5e..3433d62df5a 100644 --- a/ui/v2.5/src/locales/ko-KR.json +++ b/ui/v2.5/src/locales/ko-KR.json @@ -795,7 +795,6 @@ "galleries": "{count, plural, one {갤러리} other {갤러리들}}", "images": "{count, plural, one {이미지} other {이미지들}}", "markers": "{count, plural, one {마커} other {마커들}}", - "movies": "{count, plural, one {영화} other {영화들}}", "performers": "{count, plural, one {배우} other {배우들}}", "scenes": "{count, plural, one {영상} other {영상들}}", "studios": "{count, plural, one {스튜디오} other {스튜디오들}}", @@ -1106,9 +1105,6 @@ }, "megabits_per_second": "{value} mbps", "metadata": "메타데이터", - "movie": "영화", - "movie_scene_number": "영상 번호", - "movies": "영화", "name": "이름", "new": "새로 만들기", "none": "없음", diff --git a/ui/v2.5/src/locales/lv.json b/ui/v2.5/src/locales/lv.json new file mode 100644 index 00000000000..ff9c5366c48 --- /dev/null +++ b/ui/v2.5/src/locales/lv.json @@ -0,0 +1,73 @@ +{ + "true": "Patiess", + "total": "Kopā", + "toast": { + "updated_entity": "Atjaunināta {entītija}", + "started_generating": "Sākta ģenerēšana", + "started_auto_tagging": "Sākta automātiskā marķēšana", + "saved_entity": "Saglabāta {entity}", + "rescanning_entity": "Notiek {count, plural, one {{singularEntity}} cita {{pluralEntity}}} atkārtota skenēšana …", + "started_importing": "Sākta importēšana" + }, + "view_all": "Skatīt visu", + "videos": "Video", + "video_codec": "Video Kodekss", + "validation": { + "unique": "${path} jābūt unikālai", + "required": "${path} ir nepieciešams laiks", + "blank": "${path} nedrīkst būt tukša", + "date_invalid_form": "${path} jābūt GGGG-MM-DD formā" + }, + "urls": "Saites", + "url": "Saite", + "actions": { + "cancel": "Atcelt", + "delete": "Dzēst", + "add": "Pievienot", + "add_play": "Pievienot atskaņošanu", + "delete_generated_supporting_files": "Dzēst ģenerētos atbalsta failus", + "clean_generated": "Iztīrīt izveidotos failus", + "copy_to_clipboard": "Kopēt starpliktuvē", + "add_directory": "Pievienot direktoriju", + "add_entity": "Pievienot {EntityType}", + "clear_image": "Notīrīt bildi", + "create": "Izveidot", + "assign_stashid_to_parent_studio": "Piešķirt pagaidu ID esošajai vecākstudijai un atjaunināt metadatus", + "delete_entity": "Dzēst {EntityType}", + "create_parent_studio": "Izveidot pamatstudiju", + "clear_date_data": "Notīrīt visus datus", + "add_manual_date": "Pievienot manuālo datumu", + "add_o": "Pievienot O", + "add_to_entity": "Pievienot {EntityType}", + "allow": "Atļaut", + "allow_temporarily": "Atļaut īslaicīgi", + "anonymise": "Anonimizēt", + "apply": "Lietot", + "auto_tag": "Automātiskā atzīme", + "backup": "Dublēšana", + "browse_for_image": "Meklēt attēlu …", + "choose_date": "Izvēlieties datumu", + "clean": "Tīrīt", + "clear": "Notīrīt", + "clear_back_image": "Notīrīt aizmugurējo bildi", + "clear_front_image": "Notīrīt priekšējo bildi", + "close": "Aizvērt", + "confirm": "Apstiprināt", + "continue": "Turpināt", + "create_chapters": "Izveidot nodaļu", + "create_entity": "Izveidot {EntityType}", + "create_marker": "Izveidot marķieri", + "created_entity": "Izveidots {entity_type}: {entity_name}", + "customise": "Pielāgot", + "delete_file": "Dzēst failu", + "delete_file_and_funscript": "Dzēst failu (un funskriptu)" + }, + "unknown_date": "Nezināms datums", + "twitter": "Twitter", + "updated_at": "Atjaunināts plkst.", + "type": "Tips", + "zip_file_count": "Zip Failu Skaits", + "weight_kg": "Svars (kg)", + "weight": "Svars", + "years_old": "Gadus vecs" +} diff --git a/ui/v2.5/src/locales/nb_NO.json b/ui/v2.5/src/locales/nb_NO.json index 0967ef424bc..cfc26530200 100644 --- a/ui/v2.5/src/locales/nb_NO.json +++ b/ui/v2.5/src/locales/nb_NO.json @@ -1 +1,9 @@ -{} +{ + "actions": { + "add": "Legg til", + "anonymise": "Anonymiser", + "confirm": "Bekreft", + "continue": "Fortsett", + "close": "Lukk" + } +} diff --git a/ui/v2.5/src/locales/nl-NL.json b/ui/v2.5/src/locales/nl-NL.json index 4cd691c5566..be241fc3f4e 100644 --- a/ui/v2.5/src/locales/nl-NL.json +++ b/ui/v2.5/src/locales/nl-NL.json @@ -550,7 +550,6 @@ "galleries": "{count, plural, one {Galerij} other {Galerijen}}", "images": "{count, plural, one {Afbeelding} other {Afbeeldingen}}", "markers": "{count, plural, one {Marker} other {Markers}}", - "movies": "{count, plural, one {Film} other {Films}}", "performers": "{count, plural, one {Performer} other {Performers}}", "scenes": "{count, plural, one {Scene} other {Scenes}}", "studios": "{count, plural, one {Studio} other {Studios}}", @@ -797,9 +796,6 @@ }, "megabits_per_second": "{value} megabits per seconde", "metadata": "Metadata", - "movie": "Film", - "movie_scene_number": "Film Scene Nummer", - "movies": "Films", "name": "Naam", "new": "Nieuw", "none": "Geen", diff --git a/ui/v2.5/src/locales/nn.json b/ui/v2.5/src/locales/nn.json new file mode 100644 index 00000000000..0ad8388e3d7 --- /dev/null +++ b/ui/v2.5/src/locales/nn.json @@ -0,0 +1,174 @@ +{ + "actions": { + "add_entity": "Legg til {entityType}", + "add_manual_date": "Legg til dato manuelt", + "add_o": "Legg til O", + "add_play": "Legg til avspeling", + "add_to_entity": "", + "allow": "Tillat", + "allow_temporarily": "Tillat mellombels", + "apply": "Bruk", + "auto_tag": "Set merkelapp automatisk", + "browse_for_image": "Bla gjennom etter bilete …", + "cancel": "Avbryt", + "choose_date": "Vel ein dato", + "clean": "Rydd opp", + "clean_generated": "Rydd opp i genererte filer", + "close": "Lukk", + "confirm": "Stadfest", + "copy_to_clipboard": "Kopier til utklippstavla", + "clear_date_data": "Tøm dato-data", + "continue": "Hald fram", + "add": "Legg til", + "backup": "Ta reservekopi", + "create": "Opprett", + "create_chapters": "Opprett kapittel", + "create_entity": "Opprett {entityType}", + "create_marker": "Opprett merke", + "create_parent_studio": "Opprett forelderstudio", + "created_entity": "Oppretta {entity_type}: {entity_name}", + "customise": "Tilpass", + "delete": "Slett", + "delete_entity": "Slett {entityType}", + "delete_file": "Slett fil", + "disable": "Slå av", + "remove_date": "Fjern dato", + "open_random": "Opna tilfeldig", + "play_random": "Spel av tilfeldig", + "submit_update": "Send inn oppdatering", + "view_random": "Vis tilfeldig", + "add_directory": "Legg til mappe" + }, + "countables": { + "groups": "{count, plural, one {Gruppe} other {Grupper}}", + "files": "{count, plural, one {Fil} other {Filer}}", + "images": "{count, plural, one {Bilete} other {Bilete}}", + "performers": "{count, plural, one {Utøvar} other {Utøvarar}}", + "tags": "{count, plural, one {Merkelapp} other {Merkelappar}}", + "markers": "{count, plural, one {Merke} other {Merke}}", + "scenes": "{count, plural, one {Scene} other {Scener}}", + "studios": "{count, plural, one {Studio} other {Studio}}" + }, + "dialogs": { + "delete_object_desc": "Er du sikker på at du vil sletta {count, plural, one {denne {singularEntity}} other {desse {pluralEntity}}}?", + "performers_found": "Fann {count} utøvarar", + "delete_entity_title": "{count, plural, one {Slett {singularEntity}} other {Slett {pluralEntity}}}", + "scenes_found": "Fann {count} scener", + "dont_show_until_updated": "Ikkje vis før neste oppdatering" + }, + "date": "Dato", + "bitrate": "Bitrate", + "performer_tagger": { + "number_of_performers_will_be_processed": "{performer_count} utøvarar vert handsama", + "batch_update_performers": "Oppdater fleire utøvarar samtidig", + "update_performer": "Oppdater utøvar", + "update_performers": "Oppdater utøvarar" + }, + "studio_tagger": { + "number_of_studios_will_be_processed": "{studio_count} studio vert handsama", + "batch_update_studios": "Oppdater fleire studio samtidig", + "update_studios": "Oppdater studio" + }, + "config": { + "tasks": { + "cleanup_desc": "Sjå etter manglande filer og fjern dei frå databasen. Dette kan ikkje angrast.", + "clean_generated": { + "blob_files": "Blob-filer", + "description": "Fjernar genererte filer som ikkje har ei tilhøyrande databaseoppføring.", + "image_thumbnails": "Miniatyrbilete", + "markers": "Førehandsvisingar av merke", + "previews": "Førehandsvisingar av scener", + "previews_desc": "Førehandsvisingar og miniatyrbilete av scener", + "image_thumbnails_desc": "Miniatyrbilete og -klipp", + "transcodes": "Omkodingar av scener" + } + }, + "ui": { + "editing": { + "rating_system": { + "type": { + "options": { + "stars": "Stjerner", + "decimal": "Desimal" + } + } + } + } + }, + "about": { + "release_date": "Utgjevingsdato:" + } + }, + "dupe_check": { + "duration_diff": "Maksgrense for lengdeskilnad", + "duration_options": { + "any": "Vilkårleg", + "equal": "Er like" + } + }, + "birthdate": "Fødselsdato", + "component_tagger": { + "config": { + "query_mode_path": "Sti" + }, + "results": { + "duration_off": "Lengdeskilnad er på {number} s", + "duration_unknown": "Ukjend lengd", + "fp_matches": "Lengd samsvarer", + "fp_matches_multi": "Lengd samsvarer med {matchCount}/{durationsLength} fingeravtrykk", + "phash_matches": "{count} PHashes samsvarer" + }, + "verb_submit_fp": "Send inn {fpCount, plural, one{# fingeravtrykk} other{# fingeravtrykk}}" + }, + "country": "Land", + "date_format": "ÅÅÅÅ-MM-DD", + "datetime_format": "ÅÅÅÅ-MM-DD TT:MM", + "death_date": "Dødsdato", + "duration": "Lengd", + "file_count": "Tal på filer", + "filesize": "Filstorleik", + "framerate": "Biletrate", + "interactive": "", + "last_o_at": "Siste O", + "gallery_count": "Tal på galleri", + "group_count": "Tal på grupper", + "marker_count": "Tal på merke", + "media_info": { + "play_duration": "Avspelingslengd", + "play_count": "Tal på avspelingar", + "o_count": "Tal på O" + }, + "package_manager": { + "check_for_updates": "Sjå etter oppdateringar" + }, + "parent_tag_count": "Tal på overordna merkelappar", + "pagination": { + "last": "Siste" + }, + "o_count": "Tal på O", + "o_counter": "O-teljar", + "organized": "Organisert", + "playdate_recorded_no": "Ingen avspelingsdato er registrert", + "play_duration": "Avspelingslengd", + "performer_count": "Tal på utøvarar", + "play_count": "Tal på avspelingar", + "rating": "Karakter", + "stats": { + "total_o_count": "Tal på O" + }, + "toast": { + "generating_screenshot": "Genererer skjermbilete …", + "updated_entity": "Oppdaterte {entity}" + }, + "sub_tag_count": "Tal på underordna merkelappar", + "tag_count": "Tal på merkelappar", + "odate_recorded_no": "Ingen O-dato er registrert", + "unknown_date": "Ukjend dato", + "updated_at": "Oppdatert den", + "validation": { + "date_invalid_form": "${path} må vera i formatet ÅÅÅÅ-MM-DD" + }, + "zip_file_count": "Tal på zippa filer", + "image_count": "Tal på bilete", + "scene_count": "Tal på scener" +} diff --git a/ui/v2.5/src/locales/pl-PL.json b/ui/v2.5/src/locales/pl-PL.json index aa57fb01467..1ca3b12f502 100644 --- a/ui/v2.5/src/locales/pl-PL.json +++ b/ui/v2.5/src/locales/pl-PL.json @@ -728,7 +728,6 @@ "galleries": "{count, plural, one {Galeria} few {Galerie} other {Galerii}}", "images": "{count, plural, one {Obraz} few {Obrazy} other {Obrazów}}", "markers": "{count, plural, one {Znacznik} few {Znaczniki} other {Znaczników}}", - "movies": "{count, plural, one {Film} few {Filmy} other {Filmów}}", "performers": "{count, plural, one {Aktor} few {Aktorów} other {Aktorów}}", "scenes": "{count, plural, one {Scena} few {Sceny} other {Scen}}", "studios": "{count, plural, one {Studio} few {Studia} other {Studiów}}", @@ -1021,9 +1020,6 @@ }, "megabits_per_second": "{value} megabitów na sekundę", "metadata": "Metadane", - "movie": "Film", - "movie_scene_number": "Numer sceny filmu", - "movies": "Filmy", "name": "Nazwa", "new": "Dodaj", "none": "Brak", diff --git a/ui/v2.5/src/locales/pt-BR.json b/ui/v2.5/src/locales/pt-BR.json index f3af7da5ffc..b759122d77b 100644 --- a/ui/v2.5/src/locales/pt-BR.json +++ b/ui/v2.5/src/locales/pt-BR.json @@ -122,7 +122,9 @@ "temp_enable": "Habilitar temporariamente…", "unset": "Desaplicar", "use_default": "Usar padrão", - "view_random": "Mostrar aleatoriamente" + "view_random": "Mostrar aleatoriamente", + "remove_date": "Remover data", + "view_history": "Visualizar Histórico" }, "actions_name": "Ações", "age": "Idade", @@ -246,7 +248,8 @@ "successfully_cancelled_temporary_behaviour": "Comportamento temporário cancelado com sucesso", "until_restart": "até reiniciar", "video_sort_order": "Ordem de classificação de vídeo padrão", - "video_sort_order_desc": "Ordem para classificar os vídeos por padrão." + "video_sort_order_desc": "Ordem para classificar os vídeos por padrão.", + "server_port": "Porta do Servidor" }, "general": { "auth": { @@ -311,6 +314,9 @@ "input_args": { "desc": "Avançado: Argumentos adicionais para passar para o ffmpeg antes do campo de entrada ao transcodificar vídeo ao vivo." } + }, + "download_ffmpeg": { + "heading": "Baixar o FFmpeg" } }, "gallery_ext_desc": "Lista delimitada por vírgulas de extensões de arquivo que serão identificadas como arquivos ZIP da galeria.", @@ -598,7 +604,8 @@ "heading": "Atraso do slideshow (segundos)" }, "title": "Interface de usuário" - } + }, + "advanced_mode": "Modo Avançado" }, "configuration": "Configuração", "countables": { @@ -606,7 +613,6 @@ "galleries": "{count, plural, one {Galeria} other {Galerias}}", "images": "{count, plural, one {Imagem} other {Imagens}}", "markers": "{count, plural, one {Marcador} other {Marcadores}}", - "movies": "{count, plural, one {Filme} other {Filmes}}", "performers": "{count, plural, one {Artista} other {Artistas}}", "scenes": "{count, plural, one {Cena} other {Cenas}}", "studios": "{count, plural, one {Estúdio} other {Estúdios}}", @@ -856,9 +862,6 @@ }, "megabits_per_second": "{value} megabits por segundo", "metadata": "Metadados", - "movie": "Filme", - "movie_scene_number": "Número da cena do filme", - "movies": "Filmes", "name": "Nome", "new": "Novo", "none": "Nenhum", diff --git a/ui/v2.5/src/locales/ro-RO.json b/ui/v2.5/src/locales/ro-RO.json index 61ffa9d176e..0d4983551d9 100644 --- a/ui/v2.5/src/locales/ro-RO.json +++ b/ui/v2.5/src/locales/ro-RO.json @@ -332,8 +332,6 @@ }, "megabits_per_second": "{value} megabiți pe secundă", "metadata": "Metadate", - "movie": "Film", - "movies": "Filme", "name": "Nume", "new": "Nou", "o_counter": "O-Contor", diff --git a/ui/v2.5/src/locales/ru-RU.json b/ui/v2.5/src/locales/ru-RU.json index 6df3cfec18e..5a8fb7f4bb7 100644 --- a/ui/v2.5/src/locales/ru-RU.json +++ b/ui/v2.5/src/locales/ru-RU.json @@ -789,7 +789,6 @@ "galleries": "{count, plural, one {Галерея} other {Галереи}}", "images": "{count, plural, one {Изображение} other {Изображения}}", "markers": "{count, plural, one {Маркер} other {Маркеры}}", - "movies": "{count, plural, one {Фильм} other {Фильмы}}", "performers": "{count, plural, one {Актер} other {Актеры}}", "scenes": "{count, plural, one {Сцена} other {Сцены}}", "studios": "{count, plural, one {Студия} other {Студии}}", @@ -1089,9 +1088,6 @@ }, "megabits_per_second": "{value} мегабит в секунду", "metadata": "Метаданные", - "movie": "Фильм", - "movie_scene_number": "Номер сцены в фильме", - "movies": "Фильмы", "name": "Имя", "new": "Новый", "none": "Отсутствует", diff --git a/ui/v2.5/src/locales/sv-SE.json b/ui/v2.5/src/locales/sv-SE.json index b7075092251..1a280843146 100644 --- a/ui/v2.5/src/locales/sv-SE.json +++ b/ui/v2.5/src/locales/sv-SE.json @@ -796,11 +796,11 @@ "galleries": "{count, plural, one {Galleri} other {Gallerier}}", "images": "{count, plural, one {Bild} other {Bilder}}", "markers": "{count, plural, one {Markör} other {Markörer}}", - "movies": "{count, plural, one {Film} other {Filmer}}", "performers": "{count, plural, one {Stjärna} other {Stjärnor}}", "scenes": "{count, plural, one {Scen} other {Scener}}", "studios": "{count, plural, one {Studio} other {Studior}}", - "tags": "{count, plural, one {Tagg} other {Taggar}}" + "tags": "{count, plural, one {Tagg} other {Taggar}}", + "groups": "{count, plural, one {Grupp} other {Grupper}}" }, "country": "Land", "cover_image": "Omslagsbild", @@ -1014,7 +1014,9 @@ "lazy_component_error_help": "Om du nyligen uppdaterade Stash ladda om sidan eller rensa din webbläsares cache.", "loading_type": "Fel vid laddning av {type}", "something_went_wrong": "Något gick fel.", - "header": "Fel" + "header": "Fel", + "invalid_javascript_string": "Ogiltig javascriptkod: {error}", + "invalid_json_string": "Ogiltig JSON-sträng: {error}" }, "ethnicity": "Etnicitet", "existing_value": "existerande värde", @@ -1108,9 +1110,6 @@ }, "megabits_per_second": "{value} mbps", "metadata": "Metadata", - "movie": "Film", - "movie_scene_number": "Scennummer", - "movies": "Filmer", "name": "Namn", "new": "Ny", "none": "Ingen", @@ -1121,7 +1120,8 @@ "first": "Första", "last": "Sista", "next": "Nästa", - "previous": "Föregående" + "previous": "Föregående", + "current_total": "{current} av {total}" }, "parent_of": "Överordnad till {children}", "parent_studio": "Överordnad Studio", @@ -1465,5 +1465,11 @@ "websocket_connection_failed": "Kan ej etablera en websocket-uppkoppling: se webbläsarkonsolen för mer info", "websocket_connection_reestablished": "Websocket-uppkoppling återetablerad" }, - "o_count": "Antal O" + "o_count": "Antal O", + "group": "Grupp", + "group_count": "Antal Grupper", + "group_scene_number": "Scennummer", + "groups": "Grupper", + "studio_count": "Antal Studior", + "studio_tags": "Studiotaggar" } diff --git a/ui/v2.5/src/locales/th-TH.json b/ui/v2.5/src/locales/th-TH.json index 1716dd4372f..0376e880d2d 100644 --- a/ui/v2.5/src/locales/th-TH.json +++ b/ui/v2.5/src/locales/th-TH.json @@ -1270,7 +1270,6 @@ "image_count": "จำนวนรูปภาพ", "image_index": "รูปภาพที่ #", "megabits_per_second": "{value} mbps", - "movie": "หนัง", "o_count": "จำนวน O", "orientation": "ทิศทาง", "parent_of": "บริษัทแม่ของ {children}", @@ -1301,8 +1300,6 @@ "instagram": "อินสตาแกรม", "interactive": "อินเตอร์แอ็คทีฟ", "performer": "นักแสดง", - "movie_scene_number": "ลำดับซีน", - "movies": "หนัง", "name": "ชื่อเรื่อง", "new": "เพิ่ม", "none": "ไม่มี", @@ -1402,7 +1399,6 @@ "files": "{count, plural, one {ไฟล์} other {ไฟล์}}", "galleries": "{count, plural, one {แกลเลอรี} other {แกลเลอรี}}", "markers": "{count, plural, one {มาร์คเกอร์} other {มาร์คเกอร์}}", - "movies": "{count, plural, one {หนัง} other {หนัง}}", "performers": "{count, plural, one {นักแสดง} other {นักแสดง}}" }, "sub_tags": "แท็กย่อย", diff --git a/ui/v2.5/src/locales/tr-TR.json b/ui/v2.5/src/locales/tr-TR.json index c5b7970cf78..35465e1b0d5 100644 --- a/ui/v2.5/src/locales/tr-TR.json +++ b/ui/v2.5/src/locales/tr-TR.json @@ -508,7 +508,6 @@ "galleries": "{count, plural, one {Galeri} other {Galeriler}}", "images": "{count, plural, one {Resim} other {Resimler}}", "markers": "{count, plural, one {Yer İmi} other {Yer İmleri}}", - "movies": "{count, plural, one {Film} other {Filmler}}", "performers": "{count, plural, one {Oyuncu} other {Oyuncular}}", "scenes": "{count, plural, one {Sahne} other {Sahneler}}", "studios": "{count, plural, one {Stüdyo} other {Stüdyolar}}", @@ -725,9 +724,6 @@ }, "megabits_per_second": "Saniyede {value} megabit", "metadata": "Üst Veri", - "movie": "Film", - "movie_scene_number": "Filmdeki Sahne Sırası", - "movies": "Filmler", "name": "İsim", "new": "Yeni", "none": "Hiçbiri", diff --git a/ui/v2.5/src/locales/zh-CN.json b/ui/v2.5/src/locales/zh-CN.json index 97613a7e90b..fb221a3c6c8 100644 --- a/ui/v2.5/src/locales/zh-CN.json +++ b/ui/v2.5/src/locales/zh-CN.json @@ -39,7 +39,7 @@ "edit_entity": "编辑{entityType}", "export": "导出", "export_all": "导出所有…", - "find": "搜索", + "find": "查找", "finish": "完成", "from_file": "来自文件…", "from_url": "来自网址…", @@ -129,7 +129,7 @@ "enable": "启用", "clear_date_data": "清除日期数据", "choose_date": "选择一个日期", - "add_play": "添加播放", + "add_play": "添加播放记录", "add_manual_date": "添加手动日期", "copy_to_clipboard": "复制到剪切板", "remove_date": "去除日期", @@ -243,7 +243,7 @@ "dlna": { "allow_temp_ip": "允许 {tempIP}", "allowed_ip_addresses": "已经允许的 IP 地址", - "allowed_ip_temporarily": "暂时允许 IP 地址", + "allowed_ip_temporarily": "暂时允许的 IP 地址", "default_ip_whitelist": "默认 IP 白名单", "default_ip_whitelist_desc": "默认IP地址允许连接到DLNA,使用 {wildcard} 以允许所有IP地址连接。", "disabled_dlna_temporarily": "暂时禁止 DLNA", @@ -431,7 +431,7 @@ "installed_scrapers": "已安装刮削器" }, "stashbox": { - "add_instance": "新增 Stash-Box 入口", + "add_instance": "新增 Stash-Box 实例", "api_key": "API 密钥", "description": "Stash-box 根据指纹和文件名自动标记短片和演员。\n入口和 API 密钥可以在您的帐户页面上的 stash-box 实例中找到。 添加多个实例时必须设置名称。", "endpoint": "入口", @@ -471,7 +471,7 @@ "generate_phashes_during_scan": "生成感知识别码", "generate_phashes_during_scan_tooltip": "为了防止重复和短片甄别.", "generate_previews_during_scan": "生成动态图片预览", - "generate_previews_during_scan_tooltip": "生成webp动画预览,仅适用于预览类型设为动图的情况. 浏览时将更少CPU占用,但生成的是额外的且更大的文件。", + "generate_previews_during_scan_tooltip": "生成webp动画预览,仅适用于短片/标记墙预览类型设为动图的情况. 浏览时将更少CPU占用,但生成的是额外的且更大的文件。", "generate_sprites_during_scan": "生成时间轴预览小图", "generate_thumbnails_during_scan": "生成图片的缩略图", "generate_video_covers_during_scan": "生成短片封面", @@ -795,11 +795,11 @@ "galleries": "{count, plural, one {图库} other {图库}}", "images": "{count, plural, one {图片} other {图片}}", "markers": "{count, plural, one {标记} other {标记}}", - "movies": "{count, plural, one {电影} other {电影}}", "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 {标签}}", + "groups": "{count, plural, one {群组} other {群组}}" }, "country": "国家", "cover_image": "封面图片", @@ -1108,9 +1108,6 @@ }, "megabits_per_second": "{value} Mbps", "metadata": "元数据", - "movie": "电影", - "movie_scene_number": "短片编码", - "movies": "电影", "name": "名称", "new": "新增", "none": "空", @@ -1138,7 +1135,7 @@ "performer_image": "演员图片", "performer_tagger": { "add_new_performers": "添加演员", - "any_names_entered_will_be_queried": "任何输入的名字将会从远程的Stash-Box端点查询并且加入(如果找到)。只有完全符合的名字才会视为匹配。", + "any_names_entered_will_be_queried": "任何输入的名字将会从远程的Stash-Box实例查询并且加入(如果找到)。只有完全符合的名字才会视为匹配。", "batch_add_performers": "批量添加演员", "batch_update_performers": "批量更新演员", "config": { @@ -1159,7 +1156,7 @@ "performer_names_separated_by_comma": "演员名(逗号分隔)", "performer_selection": "选择演员", "performer_successfully_tagged": "演员成功标签:", - "query_all_performers_in_the_database": "查找所有在数据库的演员", + "query_all_performers_in_the_database": "查找所有在此数据库的演员", "refresh_tagged_performers": "更新已标签的演员", "refreshing_will_update_the_data": "重新载入会更新所有已从stash-box端点标签的演员的数据。", "status_tagging_job_queued": "状态:标签工作入列", @@ -1261,7 +1258,7 @@ "where_can_stash_store_its_generated_content": "哪里可以存放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会在扫描时使用这些目录去寻找视频和图片。", + "where_is_your_porn_located_description": "添加含有你收藏的视频和图片的目录。Stash会在扫描时使用这些目录去寻找视频和图片。", "path_to_blobs_directory_empty_for_default": "blobs目录的路径(默认为空)", "store_blobs_in_database": "将 blobs存储到数据库" }, @@ -1397,7 +1394,7 @@ "to_use_the_studio_tagger": "使用工作室标记器需要配置一个stash-box实例。", "untagged_studios": "未设置标签的工作室", "number_of_studios_will_be_processed": "{studio_count} 个工作室将被修改", - "query_all_studios_in_the_database": "数据库中所有工作室", + "query_all_studios_in_the_database": "此数据库中所有工作室", "status_tagging_studios": "状态:正在给工作室设置标签", "refresh_tagged_studios": "刷新已标记的工作室", "studio_already_tagged": "已标记工作室", @@ -1469,7 +1466,10 @@ "websocket_connection_failed": "无法建立websocket连接:有关详细信息,请参阅浏览器控制台" }, "o_count": "高潮次数", - "movie_count": "影片数量", "studio_tags": "工作室标签", - "studio_count": "工作室计数" + "studio_count": "工作室计数", + "group": "群组", + "group_count": "群组总计", + "group_scene_number": "短片序号", + "groups": "群组" } diff --git a/ui/v2.5/src/locales/zh-TW.json b/ui/v2.5/src/locales/zh-TW.json index 44aaa3d0107..67318061daa 100644 --- a/ui/v2.5/src/locales/zh-TW.json +++ b/ui/v2.5/src/locales/zh-TW.json @@ -742,7 +742,6 @@ "galleries": "圖庫", "images": "圖片", "markers": "章節標記", - "movies": "電影", "performers": "演員", "scenes": "短片", "studios": "工作室", @@ -1017,9 +1016,6 @@ }, "megabits_per_second": "{value} megabits/秒", "metadata": "Metadata", - "movie": "電影", - "movie_scene_number": "電影短片編號", - "movies": "電影", "name": "名稱", "new": "新增", "none": "無", From c47aafff668288a07c22ef82261cfb8fc6fc17d6 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Mon, 12 Aug 2024 14:10:10 +1000 Subject: [PATCH 066/103] Filter issue fixes (#5126) * Fix filter reading from URL when not active * Use alternative clone mechanism. Fixes weird filter hook behaviour * Separate search term input component --- .../src/components/List/FilterProvider.tsx | 2 +- ui/v2.5/src/components/List/ListFilter.tsx | 146 +++++++++--------- ui/v2.5/src/components/List/styles.scss | 4 + ui/v2.5/src/components/List/util.ts | 13 +- .../src/components/Shared/ClearableInput.tsx | 5 +- ui/v2.5/src/components/Shared/styles.scss | 2 + .../models/list-filter/criteria/criterion.ts | 80 ++++------ .../src/models/list-filter/criteria/gender.ts | 4 +- .../models/list-filter/criteria/performers.ts | 11 +- .../src/models/list-filter/criteria/phash.ts | 7 +- .../src/models/list-filter/criteria/rating.ts | 7 +- .../models/list-filter/criteria/stash-ids.ts | 7 +- 12 files changed, 134 insertions(+), 154 deletions(-) diff --git a/ui/v2.5/src/components/List/FilterProvider.tsx b/ui/v2.5/src/components/List/FilterProvider.tsx index 9f0abc4e006..1027de10219 100644 --- a/ui/v2.5/src/components/List/FilterProvider.tsx +++ b/ui/v2.5/src/components/List/FilterProvider.tsx @@ -65,7 +65,7 @@ export const SetFilterURL = (props: { const { setFilter } = useFilterURL(filter, setFilterOrig, { defaultFilter, - setURL, + active: setURL, }); return ( diff --git a/ui/v2.5/src/components/List/ListFilter.tsx b/ui/v2.5/src/components/List/ListFilter.tsx index 23244cbf023..bff14336cfd 100644 --- a/ui/v2.5/src/components/List/ListFilter.tsx +++ b/ui/v2.5/src/components/List/ListFilter.tsx @@ -1,6 +1,5 @@ import cloneDeep from "lodash-es/cloneDeep"; import React, { useCallback, useEffect, useRef, useState } from "react"; -import cx from "classnames"; import Mousetrap from "mousetrap"; import { SortDirectionEnum } from "src/core/generated-graphql"; import { @@ -11,7 +10,6 @@ import { OverlayTrigger, Tooltip, InputGroup, - FormControl, Popover, Overlay, } from "react-bootstrap"; @@ -26,11 +24,83 @@ import { faCaretUp, faCheck, faRandom, - faTimes, } from "@fortawesome/free-solid-svg-icons"; import { FilterButton } from "./Filters/FilterButton"; import { useDebounce } from "src/hooks/debounce"; import { View } from "./views"; +import { ClearableInput } from "../Shared/ClearableInput"; + +export function useDebouncedSearchInput( + filter: ListFilterModel, + setFilter: (filter: ListFilterModel) => void +) { + const callback = useCallback( + (value: string) => { + const newFilter = filter.clone(); + newFilter.searchTerm = value; + newFilter.currentPage = 1; + setFilter(newFilter); + }, + [filter, setFilter] + ); + + const onClear = useCallback(() => callback(""), [callback]); + + const searchCallback = useDebounce(callback, 500); + + return { searchCallback, onClear }; +} + +export const SearchTermInput: React.FC<{ + filter: ListFilterModel; + onFilterUpdate: (newFilter: ListFilterModel) => void; +}> = ({ filter, onFilterUpdate }) => { + const intl = useIntl(); + const [localInput, setLocalInput] = useState(filter.searchTerm); + + const focus = useFocus(); + const [, setQueryFocus] = focus; + + useEffect(() => { + setLocalInput(filter.searchTerm); + }, [filter.searchTerm]); + + const { searchCallback, onClear } = useDebouncedSearchInput( + filter, + onFilterUpdate + ); + + useEffect(() => { + Mousetrap.bind("/", (e) => { + setQueryFocus(); + e.preventDefault(); + }); + + return () => { + Mousetrap.unbind("/"); + }; + }); + + function onSetQuery(value: string) { + setLocalInput(value); + + if (!value) { + onClear(); + } + + searchCallback(value); + } + + return ( + + ); +}; interface IListFilterProps { onFilterUpdate: (newFilter: ListFilterModel) => void; @@ -48,44 +118,17 @@ export const ListFilter: React.FC = ({ view, }) => { const [customPageSizeShowing, setCustomPageSizeShowing] = useState(false); - const [queryRef, setQueryFocus] = useFocus(); - const [queryClearShowing, setQueryClearShowing] = useState( - !!filter.searchTerm - ); const perPageSelect = useRef(null); const [perPageInput, perPageFocus] = useFocus(); const filterOptions = filter.options; - const searchQueryUpdated = useCallback( - (value: string) => { - const newFilter = cloneDeep(filter); - newFilter.searchTerm = value; - newFilter.currentPage = 1; - onFilterUpdate(newFilter); - }, - [filter, onFilterUpdate] - ); - - const searchCallback = useDebounce((value: string) => { - const newFilter = cloneDeep(filter); - newFilter.searchTerm = value; - newFilter.currentPage = 1; - onFilterUpdate(newFilter); - }, 500); - const intl = useIntl(); useEffect(() => { - Mousetrap.bind("/", (e) => { - setQueryFocus(); - e.preventDefault(); - }); - Mousetrap.bind("r", () => onReshuffleRandomSort()); return () => { - Mousetrap.unbind("/"); Mousetrap.unbind("r"); }; }); @@ -96,14 +139,6 @@ export const ListFilter: React.FC = ({ } }, [customPageSizeShowing, perPageFocus]); - // clear search input when filter is cleared - useEffect(() => { - if (!filter.searchTerm) { - if (queryRef.current) queryRef.current.value = ""; - setQueryClearShowing(false); - } - }, [filter.searchTerm, queryRef]); - function onChangePageSize(val: string) { if (val === "custom") { // added timeout since Firefox seems to trigger the rootClose immediately @@ -125,18 +160,6 @@ export const ListFilter: React.FC = ({ onFilterUpdate(newFilter); } - function onChangeQuery(event: React.FormEvent) { - searchCallback(event.currentTarget.value); - setQueryClearShowing(!!event.currentTarget.value); - } - - function onClearQuery() { - if (queryRef.current) queryRef.current.value = ""; - searchQueryUpdated(""); - setQueryFocus(); - setQueryClearShowing(false); - } - function onChangeSortDirection() { const newFilter = cloneDeep(filter); if (filter.sortDirection === SortDirectionEnum.Asc) { @@ -209,27 +232,8 @@ export const ListFilter: React.FC = ({ return ( <> -
    -
    - - -
    +
    +
    diff --git a/ui/v2.5/src/components/List/styles.scss b/ui/v2.5/src/components/List/styles.scss index eeaa8527a65..edfb9d2a791 100644 --- a/ui/v2.5/src/components/List/styles.scss +++ b/ui/v2.5/src/components/List/styles.scss @@ -571,3 +571,7 @@ input[type="range"].zoom-slider { border-left: none; } } + +.search-term-input { + margin-right: 0.5rem; +} diff --git a/ui/v2.5/src/components/List/util.ts b/ui/v2.5/src/components/List/util.ts index 0cda1b4cb3a..69d3528bd4b 100644 --- a/ui/v2.5/src/components/List/util.ts +++ b/ui/v2.5/src/components/List/util.ts @@ -13,10 +13,10 @@ export function useFilterURL( setFilter: React.Dispatch>, options?: { defaultFilter?: ListFilterModel; - setURL?: boolean; + active?: boolean; } ) { - const { defaultFilter, setURL = true } = options ?? {}; + const { defaultFilter, active = true } = options ?? {}; const history = useHistory(); const location = useLocation(); @@ -28,7 +28,7 @@ export function useFilterURL( ) => { const newFilter = isFunction(value) ? value(filter) : value; - if (setURL) { + if (active) { const newParams = newFilter.makeQueryParameters(); history.replace({ ...history.location, search: newParams }); } else { @@ -36,12 +36,15 @@ export function useFilterURL( setFilter(newFilter); } }, - [history, setURL, setFilter, filter] + [history, active, setFilter, filter] ); // This hook runs on every page location change (ie navigation), // and updates the filter accordingly. useEffect(() => { + // don't apply if active is false + if (!active) return; + // re-init to load default filter on empty new query params if (!location.search) { if (defaultFilter) updateFilter(defaultFilter.clone()); @@ -58,7 +61,7 @@ export function useFilterURL( return prevFilter; } }); - }, [location.search, defaultFilter, setFilter, updateFilter]); + }, [active, location.search, defaultFilter, setFilter, updateFilter]); return { setFilter: updateFilter }; } diff --git a/ui/v2.5/src/components/Shared/ClearableInput.tsx b/ui/v2.5/src/components/Shared/ClearableInput.tsx index a9be946d867..28508e7baae 100644 --- a/ui/v2.5/src/components/Shared/ClearableInput.tsx +++ b/ui/v2.5/src/components/Shared/ClearableInput.tsx @@ -4,8 +4,10 @@ import { faTimes } from "@fortawesome/free-solid-svg-icons"; import { useIntl } from "react-intl"; import { Icon } from "./Icon"; import useFocus from "src/utils/focus"; +import cx from "classnames"; interface IClearableInput { + className?: string; value: string; setValue: (value: string) => void; focus?: ReturnType; @@ -13,6 +15,7 @@ interface IClearableInput { } export const ClearableInput: React.FC = ({ + className, value, setValue, focus, @@ -37,7 +40,7 @@ export const ClearableInput: React.FC = ({ } return ( -
    +
    { this.value = value; } - public abstract clone(): Criterion; + public clone() { + const ret = Object.assign(Object.create(Object.getPrototypeOf(this)), this); + ret.cloneValues(); + return ret; + } + + protected cloneValues() {} public static getModifierLabel(intl: IntlShape, modifier: CriterionModifier) { const modifierMessageID = modifierMessageIDs[modifier]; @@ -257,13 +263,8 @@ export class ILabeledIdCriterion extends Criterion { super(type, value); } - public clone(): Criterion { - const newCriterion = new ILabeledIdCriterion( - this.criterionOption, - this.value.map((v) => ({ ...v })) - ); - newCriterion.modifier = this.modifier; - return newCriterion; + public cloneValues() { + this.value = this.value.map((v) => ({ ...v })); } protected getLabelValue(_intl: IntlShape): string { @@ -301,17 +302,12 @@ export class IHierarchicalLabeledIdCriterion extends Criterion { - const newCriterion = new IHierarchicalLabeledIdCriterion( - this.criterionOption, - { - ...this.value, - items: this.value.items.map((v) => ({ ...v })), - excluded: this.value.excluded.map((v) => ({ ...v })), - } - ); - newCriterion.modifier = this.modifier; - return newCriterion; + public cloneValues() { + this.value = { + ...this.value, + items: this.value.items.map((v) => ({ ...v })), + excluded: this.value.excluded.map((v) => ({ ...v })), + }; } override get modifier(): CriterionModifier { @@ -512,13 +508,6 @@ export class StringCriterion extends Criterion { super(type, ""); } - public clone() { - const newCriterion = new StringCriterion(this.criterionOption); - newCriterion.modifier = this.modifier; - newCriterion.value = this.value; - return newCriterion; - } - protected getLabelValue(_intl: IntlShape) { return this.value; } @@ -532,18 +521,13 @@ export class StringCriterion extends Criterion { } } -export class MultiStringCriterion extends Criterion { +export abstract class MultiStringCriterion extends Criterion { constructor(type: CriterionOption, value: string[] = []) { super(type, value); } - public clone(): Criterion { - const newCriterion = new MultiStringCriterion( - this.criterionOption, - this.value.slice() - ); - newCriterion.modifier = this.modifier; - return newCriterion; + public cloneValues() { + this.value = this.value.slice(); } protected getLabelValue(_intl: IntlShape) { @@ -718,11 +702,8 @@ export class NumberCriterion extends Criterion { super(type, { value: undefined, value2: undefined }); } - public clone() { - const newCriterion = new NumberCriterion(this.criterionOption); - newCriterion.modifier = this.modifier; - newCriterion.value = { ...this.value }; - return newCriterion; + public cloneValues() { + this.value = { ...this.value }; } public get value(): INumberValue { @@ -803,11 +784,8 @@ export class DurationCriterion extends Criterion { super(type, { value: undefined, value2: undefined }); } - public clone() { - const newCriterion = new DurationCriterion(this.criterionOption); - newCriterion.modifier = this.modifier; - newCriterion.value = { ...this.value }; - return newCriterion; + public cloneValues() { + this.value = { ...this.value }; } public toCriterionInput(): IntCriterionInput { @@ -887,11 +865,8 @@ export class DateCriterion extends Criterion { super(type, { value: "", value2: undefined }); } - public clone() { - const newCriterion = new DateCriterion(this.criterionOption); - newCriterion.modifier = this.modifier; - newCriterion.value = { ...this.value }; - return newCriterion; + public cloneValues() { + this.value = { ...this.value }; } public encodeValue() { @@ -993,11 +968,8 @@ export class TimestampCriterion extends Criterion { super(type, { value: "", value2: undefined }); } - public clone() { - const newCriterion = new TimestampCriterion(this.criterionOption); - newCriterion.modifier = this.modifier; - newCriterion.value = { ...this.value }; - return newCriterion; + public cloneValues() { + this.value = { ...this.value }; } public encodeValue() { diff --git a/ui/v2.5/src/models/list-filter/criteria/gender.ts b/ui/v2.5/src/models/list-filter/criteria/gender.ts index 27e7bf17092..31e5a38acca 100644 --- a/ui/v2.5/src/models/list-filter/criteria/gender.ts +++ b/ui/v2.5/src/models/list-filter/criteria/gender.ts @@ -25,8 +25,8 @@ export const GenderCriterionOption = new CriterionOption({ }); export class GenderCriterion extends MultiStringCriterion { - constructor() { - super(GenderCriterionOption); + constructor(value: string[] = []) { + super(GenderCriterionOption, value); } public toCriterionInput(): GenderCriterionInput { diff --git a/ui/v2.5/src/models/list-filter/criteria/performers.ts b/ui/v2.5/src/models/list-filter/criteria/performers.ts index a4ce1a1bfdc..e5d8178e079 100644 --- a/ui/v2.5/src/models/list-filter/criteria/performers.ts +++ b/ui/v2.5/src/models/list-filter/criteria/performers.ts @@ -33,11 +33,12 @@ export class PerformersCriterion extends Criterion { super(PerformersCriterionOption, { items: [], excluded: [] }); } - public clone() { - const newCriterion = new PerformersCriterion(); - newCriterion.modifier = this.modifier; - newCriterion.value = { ...this.value }; - return newCriterion; + public cloneValues() { + this.value = { + ...this.value, + items: this.value.items.map((v) => ({ ...v })), + excluded: this.value.excluded.map((v) => ({ ...v })), + }; } override get modifier(): CriterionModifier { diff --git a/ui/v2.5/src/models/list-filter/criteria/phash.ts b/ui/v2.5/src/models/list-filter/criteria/phash.ts index ea794c30ea4..e1119bb656b 100644 --- a/ui/v2.5/src/models/list-filter/criteria/phash.ts +++ b/ui/v2.5/src/models/list-filter/criteria/phash.ts @@ -29,11 +29,8 @@ export class PhashCriterion extends Criterion { super(PhashCriterionOption, { value: "", distance: 0 }); } - public clone() { - const newCriterion = new PhashCriterion(); - newCriterion.modifier = this.modifier; - newCriterion.value = { ...this.value }; - return newCriterion; + public cloneValues() { + this.value = { ...this.value }; } protected getLabelValue() { 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 59760a53d57..ef18a7f2bf5 100644 --- a/ui/v2.5/src/models/list-filter/criteria/rating.ts +++ b/ui/v2.5/src/models/list-filter/criteria/rating.ts @@ -45,11 +45,8 @@ export class RatingCriterion extends Criterion { this.ratingSystem = ratingSystem; } - public clone() { - const newCriterion = new RatingCriterion(this.ratingSystem); - newCriterion.modifier = this.modifier; - newCriterion.value = { ...this.value }; - return newCriterion; + public cloneValues() { + this.value = { ...this.value }; } public get value(): INumberValue { 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 index 0ea7b0655fc..94d53ed9236 100644 --- a/ui/v2.5/src/models/list-filter/criteria/stash-ids.ts +++ b/ui/v2.5/src/models/list-filter/criteria/stash-ids.ts @@ -27,11 +27,8 @@ export class StashIDCriterion extends Criterion { }); } - public clone() { - const newCriterion = new StashIDCriterion(); - newCriterion.modifier = this.modifier; - newCriterion.value = { ...this.value }; - return newCriterion; + public cloneValues() { + this.value = { ...this.value }; } public get value(): IStashIDValue { From fb77e18182e00eb1691bb53e09d1b41588430c66 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Tue, 13 Aug 2024 09:07:36 +1000 Subject: [PATCH 067/103] Fix view history imported from o-history json (#5127) * Fix view history imported from o-history json * Add scene import unit tests --- pkg/scene/import.go | 2 +- pkg/scene/import_test.go | 148 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 149 insertions(+), 1 deletion(-) diff --git a/pkg/scene/import.go b/pkg/scene/import.go index 884d6d7e091..b36e1bd68ab 100644 --- a/pkg/scene/import.go +++ b/pkg/scene/import.go @@ -150,7 +150,7 @@ func (i *Importer) populateViewHistory() { } func (i *Importer) populateOHistory() { - i.viewHistory = getHistory( + i.oHistory = getHistory( i.Input.OHistory, i.Input.OCounter, i.Input.CreatedAt, // no last o count date diff --git a/pkg/scene/import_test.go b/pkg/scene/import_test.go index 26844f68773..0e37dce16db 100644 --- a/pkg/scene/import_test.go +++ b/pkg/scene/import_test.go @@ -4,10 +4,13 @@ import ( "context" "errors" "testing" + "time" "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/models/json" "github.com/stashapp/stash/pkg/models/jsonschema" "github.com/stashapp/stash/pkg/models/mocks" + "github.com/stashapp/stash/pkg/sliceutil" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" ) @@ -40,6 +43,151 @@ var ( var testCtx = context.Background() func TestImporterPreImport(t *testing.T) { + var ( + title = "title" + code = "code" + details = "details" + director = "director" + endpoint1 = "endpoint1" + stashID1 = "stashID1" + endpoint2 = "endpoint2" + stashID2 = "stashID2" + url1 = "url1" + url2 = "url2" + rating = 3 + organized = true + + createdAt = time.Now().Add(-time.Hour) + updatedAt = time.Now().Add(-time.Minute) + + resumeTime = 1.234 + playDuration = 2.345 + ) + tests := []struct { + name string + input jsonschema.Scene + output models.Scene + }{ + { + "basic", + jsonschema.Scene{ + Title: title, + Code: code, + Details: details, + Director: director, + StashIDs: []models.StashID{ + {Endpoint: endpoint1, StashID: stashID1}, + {Endpoint: endpoint2, StashID: stashID2}, + }, + URLs: []string{url1, url2}, + Rating: rating, + Organized: organized, + CreatedAt: json.JSONTime{Time: createdAt}, + UpdatedAt: json.JSONTime{Time: updatedAt}, + ResumeTime: resumeTime, + PlayDuration: playDuration, + }, + models.Scene{ + Title: title, + Code: code, + Details: details, + Director: director, + StashIDs: models.NewRelatedStashIDs([]models.StashID{ + {Endpoint: endpoint1, StashID: stashID1}, + {Endpoint: endpoint2, StashID: stashID2}, + }), + URLs: models.NewRelatedStrings([]string{url1, url2}), + Rating: &rating, + Organized: organized, + CreatedAt: createdAt.Truncate(0), + UpdatedAt: updatedAt.Truncate(0), + ResumeTime: resumeTime, + PlayDuration: playDuration, + + Files: models.NewRelatedVideoFiles([]*models.VideoFile{}), + GalleryIDs: models.NewRelatedIDs([]int{}), + TagIDs: models.NewRelatedIDs([]int{}), + PerformerIDs: models.NewRelatedIDs([]int{}), + Groups: models.NewRelatedGroups([]models.GroupsScenes{}), + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + i := Importer{ + Input: tt.input, + } + + if err := i.PreImport(testCtx); err != nil { + t.Errorf("PreImport() error = %v", err) + return + } + + assert.Equal(t, tt.output, i.scene) + }) + } +} + +func truncateTimes(t []time.Time) []time.Time { + return sliceutil.Map(t, func(t time.Time) time.Time { return t.Truncate(0) }) +} + +func TestImporterPreImportHistory(t *testing.T) { + var ( + playTime1 = time.Now().Add(-time.Hour * 2) + playTime2 = time.Now().Add(-time.Minute * 2) + oTime1 = time.Now().Add(-time.Hour * 3) + oTime2 = time.Now().Add(-time.Minute * 3) + ) + tests := []struct { + name string + input jsonschema.Scene + expectedPlayHistory []time.Time + expectedOHistory []time.Time + }{ + { + "basic", + jsonschema.Scene{ + PlayHistory: []json.JSONTime{ + {Time: playTime1}, + {Time: playTime2}, + }, + OHistory: []json.JSONTime{ + {Time: oTime1}, + {Time: oTime2}, + }, + }, + []time.Time{playTime1, playTime2}, + []time.Time{oTime1, oTime2}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + i := Importer{ + Input: tt.input, + } + + if err := i.PreImport(testCtx); err != nil { + t.Errorf("PreImport() error = %v", err) + return + } + + // convert histories to unix timestamps for comparison + eph := truncateTimes(tt.expectedPlayHistory) + vh := truncateTimes(i.viewHistory) + + eoh := truncateTimes(tt.expectedOHistory) + oh := truncateTimes(i.oHistory) + + assert.Equal(t, eph, vh, "view history mismatch") + assert.Equal(t, eoh, oh, "o history mismatch") + }) + } +} + +func TestImporterPreImportCoverImage(t *testing.T) { i := Importer{ Input: jsonschema.Scene{ Cover: invalidImage, From ecb53cee5571e912ab5de51334b2fdb252afbb3c Mon Sep 17 00:00:00 2001 From: hwill83 Date: Mon, 12 Aug 2024 16:08:04 -0700 Subject: [PATCH 068/103] Fix broken link in development documentation. (#5128) The existing link 404s. --- docs/DEVELOPMENT.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 732004ca175..4a1cf30df04 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -4,7 +4,7 @@ * [Go](https://golang.org/dl/) * [GolangCI](https://golangci-lint.run/) - A meta-linter which runs several linters in parallel - * To install, follow the [local installation instructions](https://golangci-lint.run/usage/install/#local-installation) + * To install, follow the [local installation instructions](https://golangci-lint.run/welcome/install/#local-installation) * [Yarn](https://yarnpkg.com/en/docs/install) - Yarn package manager ## Environment From a94bf29b347254b601d3ac91eaeead2369a9a130 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Tue, 13 Aug 2024 16:13:15 +1000 Subject: [PATCH 069/103] Add missing performer sort options to whitelist (#5129) Adds career length, measurements and weight. --- pkg/sqlite/performer.go | 3 +++ ui/v2.5/src/models/list-filter/performers.ts | 3 +++ 2 files changed, 6 insertions(+) diff --git a/pkg/sqlite/performer.go b/pkg/sqlite/performer.go index 0c2f1d78fb4..7ff6f5401a0 100644 --- a/pkg/sqlite/performer.go +++ b/pkg/sqlite/performer.go @@ -710,6 +710,7 @@ func (qb *PerformerStore) sortByLastPlayedAt(direction string) string { var performerSortOptions = sortOptions{ "birthdate", + "career_length", "created_at", "galleries_count", "height", @@ -717,6 +718,7 @@ var performerSortOptions = sortOptions{ "images_count", "last_o_at", "last_played_at", + "measurements", "name", "o_counter", "penis_length", @@ -726,6 +728,7 @@ var performerSortOptions = sortOptions{ "scenes_count", "tag_count", "updated_at", + "weight", } func (qb *PerformerStore) getPerformerSort(findFilter *models.FindFilterType) (string, error) { diff --git a/ui/v2.5/src/models/list-filter/performers.ts b/ui/v2.5/src/models/list-filter/performers.ts index 93d5731e943..81a732dea84 100644 --- a/ui/v2.5/src/models/list-filter/performers.ts +++ b/ui/v2.5/src/models/list-filter/performers.ts @@ -30,6 +30,9 @@ const sortByOptions = [ "play_count", "last_played_at", "last_o_at", + "career_length", + "weight", + "measurements", ] .map(ListFilterOptions.createSortBy) .concat([ From 49060e6686e1b89dc4c3ca9fd4806f3a66cf55dc Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Tue, 20 Aug 2024 12:36:45 +1000 Subject: [PATCH 070/103] UI nested instead (#5125) * Support multiple calls to PluginApi.patch.instead for a component. Allow calling the original/chained function from the hook function. * Add example of new usage of instead * Update documentation --- .../react-component/src/testReact.scss | 13 +++++- .../react-component/src/testReact.tsx | 8 ++++ ui/v2.5/src/docs/en/Manual/UIPluginApi.md | 4 +- ui/v2.5/src/patch.tsx | 42 ++++++++++++++++--- 4 files changed, 59 insertions(+), 8 deletions(-) diff --git a/pkg/plugin/examples/react-component/src/testReact.scss b/pkg/plugin/examples/react-component/src/testReact.scss index 2ca6631b876..695473795a8 100644 --- a/pkg/plugin/examples/react-component/src/testReact.scss +++ b/pkg/plugin/examples/react-component/src/testReact.scss @@ -33,4 +33,15 @@ .scene-performer-popover .image-thumbnail { margin: 1em; } - \ No newline at end of file + +.example-react-component-custom-overlay { + display: block; + font-weight: 900; + height: 100%; + opacity: 0.25; + position: absolute; + text-align: center; + top: 0; + width: 100%; + z-index: 8; +} \ No newline at end of file diff --git a/pkg/plugin/examples/react-component/src/testReact.tsx b/pkg/plugin/examples/react-component/src/testReact.tsx index 127920eff9b..c29f9c3dd16 100644 --- a/pkg/plugin/examples/react-component/src/testReact.tsx +++ b/pkg/plugin/examples/react-component/src/testReact.tsx @@ -132,10 +132,18 @@ interface IPluginApi { ); } + function Overlays() { + return Custom overlay; + } + PluginApi.patch.instead("SceneCard.Details", function (props: any, _: any, original: any) { return ; }); + PluginApi.patch.instead("SceneCard.Overlays", function (props: any, _: any, original: (props: any) => any) { + return <>{original({...props})}; + }); + const TestPage: React.FC = () => { const componentsLoading = PluginApi.hooks.useLoadComponents([PluginApi.loadableComponents.SceneCard]); diff --git a/ui/v2.5/src/docs/en/Manual/UIPluginApi.md b/ui/v2.5/src/docs/en/Manual/UIPluginApi.md index fd96cc52dc0..1357e86cc28 100644 --- a/ui/v2.5/src/docs/en/Manual/UIPluginApi.md +++ b/ui/v2.5/src/docs/en/Manual/UIPluginApi.md @@ -117,12 +117,12 @@ Returns `void`. #### `PluginApi.patch.instead` -Registers a replacement function for a component. The provided function will be called with the arguments passed to the original render function, plus the original render function as the last argument. An error will be thrown if the component already has a replacement function registered. +Registers a replacement function for a component. The provided function will be called with the arguments passed to the original render function, plus the next render function as the last argument. Replacement functions will be called in the order that they are registered. If a replacement function does not call the next render function then the following replacement functions will not be called or applied. | Parameter | Type | Description | |-----------|------|-------------| | `component` | `string` | The name of the component to patch. | -| `fn` | `Function` | The replacement function. It accepts the same arguments as the original render function, plus the original render function, and is expected to return the replacement component. | +| `fn` | `Function` | The replacement function. It accepts the same arguments as the original render function, plus the next render function, and is expected to return the replacement component. | Returns `void`. diff --git a/ui/v2.5/src/patch.tsx b/ui/v2.5/src/patch.tsx index 83b9ef4fea0..7f329b89aca 100644 --- a/ui/v2.5/src/patch.tsx +++ b/ui/v2.5/src/patch.tsx @@ -10,7 +10,7 @@ export const components: Record = { }; const beforeFns: Record = {}; -const insteadFns: Record = {}; +const insteadFns: Record = {}; const afterFns: Record = {}; // patch functions @@ -23,11 +23,14 @@ export function before(component: string, fn: Function) { beforeFns[component].push(fn); } +// registers a patch to a function. Instead functions receive the original arguments, +// plus the next function to call. In order for all instead functions to be called, +// it is expected that the provided next() function will be called. export function instead(component: string, fn: Function) { - if (insteadFns[component]) { - throw new Error("instead has already been called for " + component); + if (!insteadFns[component]) { + insteadFns[component] = []; } - insteadFns[component] = fn; + insteadFns[component].push(fn); } export function after(component: string, fn: Function) { @@ -51,6 +54,35 @@ export function RegisterComponent( return fn; } +/* eslint-disable @typescript-eslint/no-explicit-any */ +function runInstead( + fns: Function[], + targetFn: Function, + thisArg: any, + argArray: any[] +) { + if (!fns.length) { + return targetFn.apply(thisArg, argArray); + } + + let i = 1; + function next(): any { + if (i >= fns.length) { + return targetFn; + } + + const thisTarget = fns[i++]; + return new Proxy(thisTarget, { + apply: function (target, ctx, args) { + return target.apply(ctx, args.concat(next())); + }, + }); + } + + return fns[0].apply(thisArg, argArray.concat(next())); +} +/* eslint-enable @typescript-eslint/no-explicit-any */ + // patches a function to implement the before/instead/after functionality export function PatchFunction(name: string, fn: T) { return new Proxy(fn, { @@ -61,7 +93,7 @@ export function PatchFunction(name: string, fn: T) { args = beforeFn.apply(ctx, args); } if (insteadFns[name]) { - result = insteadFns[name].apply(ctx, args.concat(target)); + result = runInstead(insteadFns[name], target, ctx, args); } else { result = target.apply(ctx, args); } From 7788a6fd07a8b8d9897740bb5913878c3aa3ad65 Mon Sep 17 00:00:00 2001 From: Gykes Date: Mon, 19 Aug 2024 21:25:06 -0700 Subject: [PATCH 071/103] PatchComponentRedo (#5136) * PatchComponent update specifically for SettingsInterfacePanel * Fix unrelated lint issues --------- Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com> --- internal/dlna/cds.go | 2 +- pkg/plugin/raw.go | 2 +- pkg/scraper/stashbox/stash_box.go | 2 +- .../SettingsInterfacePanel/CheckboxGroup.tsx | 89 +- .../SettingsInterfacePanel.tsx | 1597 +++++++++-------- 5 files changed, 850 insertions(+), 842 deletions(-) diff --git a/internal/dlna/cds.go b/internal/dlna/cds.go index b6a4014b140..531fc1cb55c 100644 --- a/internal/dlna/cds.go +++ b/internal/dlna/cds.go @@ -192,7 +192,7 @@ func (me *contentDirectoryService) Handle(action string, argsXML []byte, r *http obj, err := me.objectFromID(browse.ObjectID) if err != nil { - return nil, upnp.Errorf(upnpav.NoSuchObjectErrorCode, err.Error()) + return nil, upnp.Errorf(upnpav.NoSuchObjectErrorCode, "cannot find object with id %q: %v", browse.ObjectID, err.Error()) } switch browse.BrowseFlag { diff --git a/pkg/plugin/raw.go b/pkg/plugin/raw.go index 6b78451effe..3ed33f37ba0 100644 --- a/pkg/plugin/raw.go +++ b/pkg/plugin/raw.go @@ -76,7 +76,7 @@ func (t *rawPluginTask) Start() error { if err != nil { logger.Warnf("error marshalling raw command input") } - if k, err := io.WriteString(stdin, string(inBytes)); err != nil { + if k, err := stdin.Write(inBytes); err != nil { logger.Warnf("error writing input to plugins stdin (wrote %v bytes out of %v): %v", k, len(string(inBytes)), err) } }() diff --git a/pkg/scraper/stashbox/stash_box.go b/pkg/scraper/stashbox/stash_box.go index 350bac5c4a6..1432eef1729 100644 --- a/pkg/scraper/stashbox/stash_box.go +++ b/pkg/scraper/stashbox/stash_box.go @@ -1338,7 +1338,7 @@ func (c *Client) submitDraft(ctx context.Context, query string, input interface{ return fmt.Errorf("failed to decode data %s: %w", string(responseBytes), err) } - if respGQL.Errors != nil && len(respGQL.Errors) > 0 { + if len(respGQL.Errors) > 0 { // try to parse standard graphql error errors := &client.GqlErrorList{} if e := json.Unmarshal(responseBytes, errors); e != nil { diff --git a/ui/v2.5/src/components/Settings/SettingsInterfacePanel/CheckboxGroup.tsx b/ui/v2.5/src/components/Settings/SettingsInterfacePanel/CheckboxGroup.tsx index 6dd0d52e53d..83c506182c5 100644 --- a/ui/v2.5/src/components/Settings/SettingsInterfacePanel/CheckboxGroup.tsx +++ b/ui/v2.5/src/components/Settings/SettingsInterfacePanel/CheckboxGroup.tsx @@ -1,5 +1,6 @@ import React from "react"; import { BooleanSetting } from "../Inputs"; +import { PatchComponent } from "src/patch"; interface IItem { id: string; @@ -13,49 +14,47 @@ interface ICheckboxGroupProps { onChange?: (ids: string[]) => void; } -export const CheckboxGroup: React.FC = ({ - groupId, - items, - checkedIds = [], - onChange, -}) => { - function generateId(itemId: string) { - return `${groupId}-${itemId}`; - } +export const CheckboxGroup: React.FC = PatchComponent( + "CheckboxGroup", + ({ groupId, items, checkedIds = [], onChange }) => { + function generateId(itemId: string) { + return `${groupId}-${itemId}`; + } - return ( - <> - {items.map(({ id, headingID }) => ( - { - if (v) { - onChange?.( - items - .map((item) => item.id) - .filter( - (itemId) => - generateId(itemId) === generateId(id) || - checkedIds.includes(itemId) - ) - ); - } else { - onChange?.( - items - .map((item) => item.id) - .filter( - (itemId) => - generateId(itemId) !== generateId(id) && - checkedIds.includes(itemId) - ) - ); - } - }} - /> - ))} - - ); -}; + return ( + <> + {items.map(({ id, headingID }) => ( + { + if (v) { + onChange?.( + items + .map((item) => item.id) + .filter( + (itemId) => + generateId(itemId) === generateId(id) || + checkedIds.includes(itemId) + ) + ); + } else { + onChange?.( + items + .map((item) => item.id) + .filter( + (itemId) => + generateId(itemId) !== generateId(id) && + checkedIds.includes(itemId) + ) + ); + } + }} + /> + ))} + + ); + } +); diff --git a/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx b/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx index 2533dce0da2..c174847c982 100644 --- a/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx +++ b/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx @@ -43,6 +43,7 @@ import { defaultImageWallMargin, } from "src/utils/imageWall"; import { defaultMaxOptionsShown } from "src/core/config"; +import { PatchComponent } from "src/patch"; const allMenuItems = [ { id: "scenes", headingID: "scenes" }, @@ -55,673 +56,500 @@ const allMenuItems = [ { id: "tags", headingID: "tags" }, ]; -export const SettingsInterfacePanel: React.FC = () => { - const intl = useIntl(); - - const { - interface: iface, - saveInterface, - ui, - saveUI, - loading, - error, - } = useSettings(); - - // convert old movies menu item to groups - const massageMenuItems = useCallback((menuItems: string[]) => { - return menuItems.map((item) => { - if (item === "movies") { - return "groups"; - } - return item; - }); - }, []); - - const massagedMenuItems = useMemo(() => { - if (!iface.menuItems) return iface.menuItems; - - return massageMenuItems(iface.menuItems); - }, [iface.menuItems, massageMenuItems]); - - const { - interactive, - state: interactiveState, - error: interactiveError, - serverOffset: interactiveServerOffset, - initialised: interactiveInitialised, - initialise: initialiseInteractive, - sync: interactiveSync, - } = React.useContext(InteractiveContext); - - const [, setInterfaceLocalForage] = useInterfaceLocalForage(); - - function saveLightboxSettings(v: Partial) { - // save in local forage as well for consistency - setInterfaceLocalForage((prev) => { - return { +export const SettingsInterfacePanel: React.FC = PatchComponent( + "SettingsInterfacePanel", + function SettingsInterfacePanel() { + const intl = useIntl(); + + const { + interface: iface, + saveInterface, + ui, + saveUI, + loading, + error, + } = useSettings(); + // convert old movies menu item to groups + const massageMenuItems = useCallback((menuItems: string[]) => { + return menuItems.map((item) => { + if (item === "movies") { + return "groups"; + } + return item; + }); + }, []); + + const massagedMenuItems = useMemo(() => { + if (!iface.menuItems) return iface.menuItems; + + return massageMenuItems(iface.menuItems); + }, [iface.menuItems, massageMenuItems]); + + const { + interactive, + state: interactiveState, + error: interactiveError, + serverOffset: interactiveServerOffset, + initialised: interactiveInitialised, + initialise: initialiseInteractive, + sync: interactiveSync, + } = React.useContext(InteractiveContext); + + const [, setInterfaceLocalForage] = useInterfaceLocalForage(); + + function saveLightboxSettings(v: Partial) { + // save in local forage as well for consistency + setInterfaceLocalForage((prev) => ({ ...prev, imageLightbox: { ...prev.imageLightbox, ...v, }, - }; - }); - - saveInterface({ - imageLightbox: { - ...iface.imageLightbox, - ...v, - }, - }); - } + })); - function saveImageWallMargin(m: number) { - saveUI({ - imageWallOptions: { - ...(ui.imageWallOptions ?? defaultImageWallOptions), - margin: m, - }, - }); - } + saveInterface({ + imageLightbox: { + ...iface.imageLightbox, + ...v, + }, + }); + } - function saveImageWallDirection(d: ImageWallDirection) { - saveUI({ - imageWallOptions: { - ...(ui.imageWallOptions ?? defaultImageWallOptions), - direction: d, - }, - }); - } + function saveImageWallMargin(m: number) { + saveUI({ + imageWallOptions: { + ...(ui.imageWallOptions ?? defaultImageWallOptions), + margin: m, + }, + }); + } - function saveRatingSystemType(t: RatingSystemType) { - saveUI({ - ratingSystemOptions: { - ...ui.ratingSystemOptions, - type: t, - }, - }); - } + function saveImageWallDirection(d: ImageWallDirection) { + saveUI({ + imageWallOptions: { + ...(ui.imageWallOptions ?? defaultImageWallOptions), + direction: d, + }, + }); + } - function saveRatingSystemStarPrecision(p: RatingStarPrecision) { - saveUI({ - ratingSystemOptions: { - ...(ui.ratingSystemOptions ?? defaultRatingSystemOptions), - starPrecision: p, - }, - }); - } + function saveRatingSystemType(t: RatingSystemType) { + saveUI({ + ratingSystemOptions: { + ...ui.ratingSystemOptions, + type: t, + }, + }); + } - function validateLocaleString(v: string) { - if (!v) return; - try { - JSON.parse(v); - } catch (e) { - throw new Error( - intl.formatMessage( - { id: "errors.invalid_json_string" }, - { - error: (e as SyntaxError).message, - } - ) - ); + function saveRatingSystemStarPrecision(p: RatingStarPrecision) { + saveUI({ + ratingSystemOptions: { + ...(ui.ratingSystemOptions ?? defaultRatingSystemOptions), + starPrecision: p, + }, + }); } - } - function validateJavascriptString(v: string) { - if (!v) return; - try { - // creates a function from the string to validate it but does not execute it - // eslint-disable-next-line @typescript-eslint/no-implied-eval - new Function(v); - } catch (e) { - throw new Error( - intl.formatMessage( - { id: "errors.invalid_javascript_string" }, - { - error: (e as SyntaxError).message, - } - ) - ); + function validateLocaleString(v: string) { + if (!v) return; + try { + JSON.parse(v); + } catch (e) { + throw new Error( + intl.formatMessage( + { id: "errors.invalid_json_string" }, + { + error: (e as SyntaxError).message, + } + ) + ); + } } - } - if (error) return

    {error.message}

    ; - if (loading) return ; - - // https://en.wikipedia.org/wiki/List_of_language_names - return ( - <> - - saveInterface({ language: v })} - > - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    -
    -
    -

    - {intl.formatMessage({ - id: "config.ui.menu_items.heading", - })} -

    -
    - {intl.formatMessage({ id: "config.ui.menu_items.description" })} + function validateJavascriptString(v: string) { + if (!v) return; + try { + // creates a function from the string to validate it but does not execute it + // eslint-disable-next-line @typescript-eslint/no-implied-eval + new Function(v); + } catch (e) { + throw new Error( + intl.formatMessage( + { id: "errors.invalid_javascript_string" }, + { + error: (e as SyntaxError).message, + } + ) + ); + } + } + + if (error) return

    {error.message}

    ; + if (loading) return ; + + // https://en.wikipedia.org/wiki/List_of_language_names + + return ( + <> + + saveInterface({ language: v })} + > + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    +
    +
    +

    + {intl.formatMessage({ + id: "config.ui.menu_items.heading", + })} +

    +
    + {intl.formatMessage({ + id: "config.ui.menu_items.description", + })} +
    +
    -
    -
    - saveInterface({ menuItems: massageMenuItems(v) })} - /> -
    - - saveUI({ abbreviateCounters: v })} - /> - - - - saveInterface({ noBrowser: v })} - /> - saveInterface({ notificationsEnabled: v })} - /> - - - - saveInterface({ wallShowTitle: v })} - /> - saveInterface({ soundOnPreview: v })} - /> - - saveInterface({ wallPlayback: v })} - > - - - - - - - - saveInterface({ showStudioAsText: v })} - /> - - - - saveUI({ enableChromecast: v })} - /> - saveUI({ disableMobileMediaAutoRotateEnabled: v })} - /> - saveInterface({ showScrubber: v })} - /> - saveUI({ alwaysStartFromBeginning: v })} - /> - saveUI({ trackActivity: v })} - /> - saveUI({ vrTag: 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 })} - /> - saveInterface({ autostartVideo: v })} - /> - saveInterface({ autostartVideoOnPlaySelected: v })} - /> - - saveInterface({ continuePlaylistDefault: v })} - /> - - - id="max-loop-duration" - headingID="config.ui.max_loop_duration.heading" - subHeadingID="config.ui.max_loop_duration.description" - value={iface.maximumLoopDuration ?? undefined} - onChange={(v) => saveInterface({ maximumLoopDuration: v })} - renderField={(value, setValue) => ( - setValue(duration ?? 0)} + + saveInterface({ menuItems: massageMenuItems(v) }) + } /> - )} - renderValue={(v) => { - return {TextUtils.secondsToTimestamp(v ?? 0)}; - }} - /> - - saveUI({ showAbLoopControls: v })} - /> - - - saveUI({ showTagCardOnHover: v })} - /> - saveUI({ showChildTagContent: v })} - /> - - - saveUI({ showChildStudioContent: v })} - /> - - - - saveImageWallMargin(v)} - /> - - saveImageWallDirection(v as ImageWallDirection)} - > - {Array.from(imageWallDirectionIntlMap.entries()).map((v) => ( -
    + + saveUI({ abbreviateCounters: v })} + /> +
    + + + saveInterface({ noBrowser: v })} + /> + saveInterface({ notificationsEnabled: v })} + /> + + + + saveInterface({ wallShowTitle: v })} + /> + saveInterface({ soundOnPreview: v })} + /> + + saveInterface({ wallPlayback: v })} + > + - ))} - - - - - saveLightboxSettings({ slideshowDelay: v })} - /> - - - saveLightboxSettings({ - displayMode: v as GQL.ImageLightboxDisplayMode, - }) - } - > - {Array.from(imageLightboxDisplayModeIntlMap.entries()).map((v) => ( - - ))} - - - saveLightboxSettings({ scaleUp: v })} - /> - - saveLightboxSettings({ resetZoomOnNav: v })} - /> - - - saveLightboxSettings({ - scrollMode: v as GQL.ImageLightboxScrollMode, - }) - } - > - {Array.from(imageLightboxScrollModeIntlMap.entries()).map((v) => ( - - ))} - - - - saveLightboxSettings({ scrollAttemptsBeforeChange: v }) - } - /> - - - -
    -
    -
    -

    - {intl.formatMessage({ - id: "config.ui.detail.enable_background_image.heading", - })} -

    -
    - {intl.formatMessage({ - id: "config.ui.detail.enable_background_image.description", - })} -
    -
    -
    -
    + + + + saveUI({ enableMovieBackgroundImage: v })} + id="show-text-studios" + headingID="config.ui.scene_list.options.show_studio_as_text" + checked={iface.showStudioAsText ?? undefined} + onChange={(v) => saveInterface({ showStudioAsText: v })} /> + + + saveUI({ enablePerformerBackgroundImage: v })} + id="enable-chromecast" + headingID="config.ui.scene_player.options.enable_chromecast" + checked={ui.enableChromecast ?? undefined} + onChange={(v) => saveUI({ enableChromecast: v })} /> saveUI({ enableStudioBackgroundImage: v })} + id="disable-mobile-media-auto-rotate" + headingID="config.ui.scene_player.options.disable_mobile_media_auto_rotate" + checked={ui.disableMobileMediaAutoRotateEnabled ?? undefined} + onChange={(v) => saveUI({ disableMobileMediaAutoRotateEnabled: v })} /> saveUI({ enableTagBackgroundImage: v })} - /> -
    - saveUI({ showAllDetails: v })} - /> - saveUI({ compactExpandedDetails: v })} - /> - - - -
    -
    -
    -

    - {intl.formatMessage({ - id: "config.ui.editing.disable_dropdown_create.heading", - })} -

    -
    - {intl.formatMessage({ - id: "config.ui.editing.disable_dropdown_create.description", - })} -
    -
    -
    -
    + id="show-scrubber" + headingID="config.ui.scene_player.options.show_scrubber" + checked={iface.showScrubber ?? undefined} + onChange={(v) => saveInterface({ showScrubber: v })} + /> - saveInterface({ - disableDropdownCreate: { - ...iface.disableDropdownCreate, - performer: v, - }, - }) - } + id="always-start-from-beginning" + headingID="config.ui.scene_player.options.always_start_from_beginning" + checked={ui.alwaysStartFromBeginning ?? undefined} + onChange={(v) => saveUI({ alwaysStartFromBeginning: v })} /> - saveInterface({ - disableDropdownCreate: { - ...iface.disableDropdownCreate, - studio: v, - }, - }) - } + id="track-activity" + headingID="config.ui.scene_player.options.track_activity" + checked={ui.trackActivity ?? true} + onChange={(v) => saveUI({ trackActivity: v })} + /> + saveUI({ vrTag: 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 })} /> - saveInterface({ - disableDropdownCreate: { - ...iface.disableDropdownCreate, - tag: v, - }, - }) - } + id="auto-start-video" + headingID="config.ui.scene_player.options.auto_start_video" + checked={iface.autostartVideo ?? undefined} + onChange={(v) => saveInterface({ autostartVideo: v })} /> saveInterface({ autostartVideoOnPlaySelected: v })} + /> + + saveInterface({ continuePlaylistDefault: v })} + /> + + + id="max-loop-duration" + headingID="config.ui.max_loop_duration.heading" + subHeadingID="config.ui.max_loop_duration.description" + value={iface.maximumLoopDuration ?? undefined} + onChange={(v) => saveInterface({ maximumLoopDuration: v })} + renderField={(value, setValue) => ( + setValue(duration ?? 0)} + /> + )} + renderValue={(v) => { + return {TextUtils.secondsToTimestamp(v ?? 0)}; + }} + /> + + saveUI({ showAbLoopControls: v })} + /> + + + saveUI({ showTagCardOnHover: v })} + /> + saveUI({ showChildTagContent: v })} + /> + + + saveUI({ showChildStudioContent: v })} + /> + + + + saveImageWallMargin(v)} + /> + + saveImageWallDirection(v as ImageWallDirection)} + > + {Array.from(imageWallDirectionIntlMap.entries()).map((v) => ( + + ))} + + + + + saveLightboxSettings({ slideshowDelay: v })} + /> + + - saveInterface({ - disableDropdownCreate: { - ...iface.disableDropdownCreate, - movie: v, - }, + saveLightboxSettings({ + displayMode: v as GQL.ImageLightboxDisplayMode, }) } + > + {Array.from(imageLightboxDisplayModeIntlMap.entries()).map((v) => ( + + ))} + + + saveLightboxSettings({ scaleUp: v })} /> -
    - saveUI({ maxOptionsShown: v })} - /> - saveRatingSystemType(v as RatingSystemType)} - > - {Array.from(ratingSystemIntlMap.entries()).map((v) => ( - - ))} - - {(ui.ratingSystemOptions?.type ?? defaultRatingSystemType) === - RatingSystemType.Stars && ( + + saveLightboxSettings({ resetZoomOnNav: v })} + /> + - saveRatingSystemStarPrecision(v as RatingStarPrecision) + saveLightboxSettings({ + scrollMode: v as GQL.ImageLightboxScrollMode, + }) } > - {Array.from(ratingStarPrecisionIntlMap.entries()).map((v) => ( + {Array.from(imageLightboxScrollModeIntlMap.entries()).map((v) => ( ))} - )} - - - - saveInterface({ cssEnabled: v })} - /> - - - id="custom-css" - headingID="config.ui.custom_css.heading" - subHeadingID="config.ui.custom_css.description" - value={iface.css ?? undefined} - onChange={(v) => saveInterface({ css: v })} - renderField={(value, setValue) => ( - ) => - setValue(e.currentTarget.value) + + + saveLightboxSettings({ scrollAttemptsBeforeChange: v }) + } + /> + + + +
    +
    +
    +

    + {intl.formatMessage({ + id: "config.ui.detail.enable_background_image.heading", + })} +

    +
    + {intl.formatMessage({ + id: "config.ui.detail.enable_background_image.description", + })} +
    +
    +
    +
    + saveUI({ enableMovieBackgroundImage: v })} + /> + saveUI({ enablePerformerBackgroundImage: v })} + /> + saveUI({ enableStudioBackgroundImage: v })} + /> + saveUI({ enableTagBackgroundImage: v })} + /> +
    + saveUI({ showAllDetails: v })} + /> + saveUI({ compactExpandedDetails: v })} + /> + + + +
    +
    +
    +

    + {intl.formatMessage({ + id: "config.ui.editing.disable_dropdown_create.heading", + })} +

    +
    + {intl.formatMessage({ + id: "config.ui.editing.disable_dropdown_create.description", + })} +
    +
    +
    +
    + + saveInterface({ + disableDropdownCreate: { + ...iface.disableDropdownCreate, + performer: v, + }, + }) } - rows={16} - className="text-input code" /> + + saveInterface({ + disableDropdownCreate: { + ...iface.disableDropdownCreate, + studio: v, + }, + }) + } + /> + + saveInterface({ + disableDropdownCreate: { + ...iface.disableDropdownCreate, + tag: v, + }, + }) + } + /> + + saveInterface({ + disableDropdownCreate: { + ...iface.disableDropdownCreate, + movie: v, + }, + }) + } + /> +
    + saveUI({ maxOptionsShown: 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) => ( + + ))} + )} - renderValue={() => { - return <>; - }} - /> - - - 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 })} - validateChange={validateJavascriptString} - renderField={(value, setValue, err) => ( - <> - ) => - setValue(e.currentTarget.value) - } - rows={16} - className="text-input code" - isInvalid={!!err} - /> - - {err} - - - )} - renderValue={() => { - return <>; - }} - /> - - - saveInterface({ customLocalesEnabled: v })} - /> - - - id="custom-locales" - headingID="config.ui.custom_locales.heading" - subHeadingID="config.ui.custom_locales.description" - value={iface.customLocales ?? undefined} - onChange={(v) => saveInterface({ customLocales: v })} - validateChange={validateLocaleString} - renderField={(value, setValue, err) => ( - <> + + + + saveInterface({ cssEnabled: v })} + /> + + + id="custom-css" + headingID="config.ui.custom_css.heading" + subHeadingID="config.ui.custom_css.description" + value={iface.css ?? undefined} + onChange={(v) => saveInterface({ css: v })} + renderField={(value, setValue) => ( { } rows={16} className="text-input code" - isInvalid={!!err} /> - - {err} - - - )} - renderValue={() => { - return <>; - }} - /> - - - - saveInterface({ handyKey: v })} - /> - {interactive.handyKey && ( - <> -
    -
    -

    - {intl.formatMessage({ - id: "config.ui.handy_connection.status.heading", - })} -

    + )} + renderValue={() => { + return <>; + }} + /> + + + saveInterface({ javascriptEnabled: v })} + /> -
    - - {interactiveError && : {interactiveError}} -
    -
    -
    - {!interactiveInitialised && ( - - )} -
    -
    -
    -
    -

    - {intl.formatMessage({ - id: "config.ui.handy_connection.server_offset.heading", - })} -

    - -
    - {interactiveServerOffset.toFixed()}ms + + +
    + + {interactiveError && : {interactiveError}} +
    +
    +
    + {!interactiveInitialised && ( + + )}
    -
    - {interactiveInitialised && ( - - )} + + +
    + {interactiveServerOffset.toFixed()}ms +
    +
    +
    + {interactiveInitialised && ( + + )} +
    -
    - - )} - - saveInterface({ funscriptOffset: v })} - /> - - saveInterface({ useStashHostedFunscript: v })} - /> -
    - - ); -}; + + )} + + saveInterface({ funscriptOffset: v })} + /> + + saveInterface({ useStashHostedFunscript: v })} + /> + + + ); + } +); From 427c18be7de58d4d63edc69669b75bc08436f1d0 Mon Sep 17 00:00:00 2001 From: Gykes Date: Tue, 20 Aug 2024 16:22:59 -0700 Subject: [PATCH 072/103] QOL Move Refresh Scrapers to Top (#5142) QOL change to move the "Refresh Scrapers" button within the "Scrape with..." dropdown to the top. --- ui/v2.5/src/components/Shared/ScraperMenu.tsx | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/ui/v2.5/src/components/Shared/ScraperMenu.tsx b/ui/v2.5/src/components/Shared/ScraperMenu.tsx index 2152854b022..f73103e57b7 100644 --- a/ui/v2.5/src/components/Shared/ScraperMenu.tsx +++ b/ui/v2.5/src/components/Shared/ScraperMenu.tsx @@ -54,6 +54,15 @@ export const ScraperMenu: React.FC<{ {toggle} + onReloadScrapers()}> + + + + + + + + {(stashBoxes?.length ?? 0) + scrapers.length > minFilteredScrapers && ( )} + {filteredStashboxes.map((s, index) => ( ))} - onReloadScrapers()}> - - - - - - - ); From d8ee57cd50c5a391398ecf057cf7377b23407f17 Mon Sep 17 00:00:00 2001 From: DogmaDragon <103123951+DogmaDragon@users.noreply.github.com> Date: Fri, 23 Aug 2024 02:31:33 +0300 Subject: [PATCH 073/103] [Docs] add note about caption functionality [skip ci] --- ui/v2.5/src/docs/en/Manual/Captions.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ui/v2.5/src/docs/en/Manual/Captions.md b/ui/v2.5/src/docs/en/Manual/Captions.md index 5f2d919020f..e52fc54bbca 100644 --- a/ui/v2.5/src/docs/en/Manual/Captions.md +++ b/ui/v2.5/src/docs/en/Manual/Captions.md @@ -12,3 +12,5 @@ These files need to be named as follows: Where `{language_code}` is defined by the [ISO-6399-1](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes) (2 letters) standard and `ext` is the file extension. Captions files without a language code will be labeled as Unknown in the video player but will work fine. Scenes with captions can be filtered with the `captions` criterion. + +**Note:** If the caption file was added after the scene was initially added during scan you will need to run a Selective Scan task for it to show up. From e49beb139c4391ee5ec944837aa26551349e0294 Mon Sep 17 00:00:00 2001 From: Ian McKenzie <13459320+ikmckenz@users.noreply.github.com> Date: Mon, 26 Aug 2024 16:03:22 -0700 Subject: [PATCH 074/103] Truncate scenes_o_dates and scenes_view_dates as part of anonymize (#5166) --- pkg/sqlite/anonymise.go | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/pkg/sqlite/anonymise.go b/pkg/sqlite/anonymise.go index 1f595128c68..c66dc3aaced 100644 --- a/pkg/sqlite/anonymise.go +++ b/pkg/sqlite/anonymise.go @@ -47,6 +47,8 @@ func (db *Anonymiser) Anonymise(ctx context.Context) error { return utils.Do([]func() error{ func() error { return db.deleteBlobs() }, func() error { return db.deleteStashIDs() }, + func() error { return db.clearOHistory() }, + func() error { return db.clearWatchHistory() }, func() error { return db.anonymiseFolders(ctx) }, func() error { return db.anonymiseFiles(ctx) }, func() error { return db.anonymiseFingerprints(ctx) }, @@ -101,6 +103,18 @@ func (db *Anonymiser) deleteStashIDs() error { }) } +func (db *Anonymiser) clearOHistory() error { + return utils.Do([]func() error{ + func() error { return db.truncateTable("scenes_o_dates") }, + }) +} + +func (db *Anonymiser) clearWatchHistory() error { + return utils.Do([]func() error{ + func() error { return db.truncateTable("scenes_view_dates") }, + }) +} + func (db *Anonymiser) anonymiseFolders(ctx context.Context) error { logger.Infof("Anonymising folders") return txn.WithTxn(ctx, db, func(ctx context.Context) error { From 62ff6f3c7f2a2da5cb43740820a5535c77769ce3 Mon Sep 17 00:00:00 2001 From: Ian McKenzie <13459320+ikmckenz@users.noreply.github.com> Date: Mon, 26 Aug 2024 20:01:12 -0700 Subject: [PATCH 075/103] Use existing consts for table names in anonymise.go where available (#5167) --- pkg/sqlite/anonymise.go | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/pkg/sqlite/anonymise.go b/pkg/sqlite/anonymise.go index c66dc3aaced..78c5f4ab1a6 100644 --- a/pkg/sqlite/anonymise.go +++ b/pkg/sqlite/anonymise.go @@ -84,14 +84,14 @@ func (db *Anonymiser) truncateTable(tableName string) error { func (db *Anonymiser) deleteBlobs() error { return utils.Do([]func() error{ - func() error { return db.truncateColumn("tags", "image_blob") }, - func() error { return db.truncateColumn("studios", "image_blob") }, - func() error { return db.truncateColumn("performers", "image_blob") }, - func() error { return db.truncateColumn("scenes", "cover_blob") }, - func() error { return db.truncateColumn("groups", "front_image_blob") }, - func() error { return db.truncateColumn("groups", "back_image_blob") }, - - func() error { return db.truncateTable("blobs") }, + func() error { return db.truncateColumn(tagTable, tagImageBlobColumn) }, + func() error { return db.truncateColumn(studioTable, studioImageBlobColumn) }, + func() error { return db.truncateColumn(performerTable, performerImageBlobColumn) }, + func() error { return db.truncateColumn(sceneTable, sceneCoverBlobColumn) }, + func() error { return db.truncateColumn(groupTable, groupFrontImageBlobColumn) }, + func() error { return db.truncateColumn(groupTable, groupBackImageBlobColumn) }, + + func() error { return db.truncateTable(blobTable) }, }) } @@ -105,13 +105,13 @@ func (db *Anonymiser) deleteStashIDs() error { func (db *Anonymiser) clearOHistory() error { return utils.Do([]func() error{ - func() error { return db.truncateTable("scenes_o_dates") }, + func() error { return db.truncateTable(scenesODatesTable) }, }) } func (db *Anonymiser) clearWatchHistory() error { return utils.Do([]func() error{ - func() error { return db.truncateTable("scenes_view_dates") }, + func() error { return db.truncateTable(scenesViewDatesTable) }, }) } From 3089e1ad69a23830ed645d48a5875fb18fbaee19 Mon Sep 17 00:00:00 2001 From: dogwithakeyboard <128322708+dogwithakeyboard@users.noreply.github.com> Date: Tue, 27 Aug 2024 08:23:34 +0100 Subject: [PATCH 076/103] Markers scene filter (#5097) * Add scene filter to markers * Fix labels for scenes --------- Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com> --- graphql/schema/types/filters.graphql | 2 ++ pkg/models/scene_marker.go | 2 ++ pkg/sqlite/scene_marker_filter.go | 16 ++++++++++ .../List/Filters/LabeledIdFilter.tsx | 8 +++-- .../src/models/list-filter/criteria/scenes.ts | 29 ++++++++++++++++++- .../src/models/list-filter/scene-markers.ts | 2 ++ 6 files changed, 56 insertions(+), 3 deletions(-) diff --git a/graphql/schema/types/filters.graphql b/graphql/schema/types/filters.graphql index d1b16976905..1ca8c1fb08d 100644 --- a/graphql/schema/types/filters.graphql +++ b/graphql/schema/types/filters.graphql @@ -191,6 +191,8 @@ input SceneMarkerFilterType { scene_tags: HierarchicalMultiCriterionInput "Filter to only include scene markers with these performers" performers: MultiCriterionInput + "Filter to only include scene markers from these scenes" + scenes: MultiCriterionInput "Filter by creation time" created_at: TimestampCriterionInput "Filter by last update time" diff --git a/pkg/models/scene_marker.go b/pkg/models/scene_marker.go index 59186ca5957..8c4598a6df4 100644 --- a/pkg/models/scene_marker.go +++ b/pkg/models/scene_marker.go @@ -9,6 +9,8 @@ type SceneMarkerFilterType struct { SceneTags *HierarchicalMultiCriterionInput `json:"scene_tags"` // Filter to only include scene markers with these performers Performers *MultiCriterionInput `json:"performers"` + // Filter to only include scene markers from these scenes + Scenes *MultiCriterionInput `json:"scenes"` // Filter by created at CreatedAt *TimestampCriterionInput `json:"created_at"` // Filter by updated at diff --git a/pkg/sqlite/scene_marker_filter.go b/pkg/sqlite/scene_marker_filter.go index 94147ed80af..d5e044e85a7 100644 --- a/pkg/sqlite/scene_marker_filter.go +++ b/pkg/sqlite/scene_marker_filter.go @@ -40,6 +40,7 @@ func (qb *sceneMarkerFilterHandler) criterionHandler() criterionHandler { qb.tagsCriterionHandler(sceneMarkerFilter.Tags), qb.sceneTagsCriterionHandler(sceneMarkerFilter.SceneTags), qb.performersCriterionHandler(sceneMarkerFilter.Performers), + qb.scenesCriterionHandler(sceneMarkerFilter.Scenes), ×tampCriterionHandler{sceneMarkerFilter.CreatedAt, "scene_markers.created_at", nil}, ×tampCriterionHandler{sceneMarkerFilter.UpdatedAt, "scene_markers.updated_at", nil}, &dateCriterionHandler{sceneMarkerFilter.SceneDate, "scenes.date", qb.joinScenes}, @@ -187,3 +188,18 @@ func (qb *sceneMarkerFilterHandler) performersCriterionHandler(performers *model handler(ctx, f) } } + +func (qb *sceneMarkerFilterHandler) scenesCriterionHandler(scenes *models.MultiCriterionInput) criterionHandlerFunc { + addJoinsFunc := func(f *filterBuilder) { + f.addLeftJoin(sceneTable, "markers_scenes", "markers_scenes.id = scene_markers.scene_id") + } + h := multiCriterionHandlerBuilder{ + primaryTable: sceneMarkerTable, + foreignTable: "markers_scenes", + joinTable: "", + primaryFK: sceneIDColumn, + foreignFK: sceneIDColumn, + addJoinsFunc: addJoinsFunc, + } + return h.handler(scenes) +} diff --git a/ui/v2.5/src/components/List/Filters/LabeledIdFilter.tsx b/ui/v2.5/src/components/List/Filters/LabeledIdFilter.tsx index 792c4a7e712..888cfe40168 100644 --- a/ui/v2.5/src/components/List/Filters/LabeledIdFilter.tsx +++ b/ui/v2.5/src/components/List/Filters/LabeledIdFilter.tsx @@ -1,6 +1,7 @@ import React from "react"; import { Form } from "react-bootstrap"; import { FilterSelect, SelectObject } from "src/components/Shared/Select"; +import { objectTitle } from "src/core/files"; import { galleryTitle } from "src/core/galleries"; import { Criterion } from "src/models/list-filter/criteria/criterion"; import { ILabeledId } from "src/models/list-filter/types"; @@ -31,8 +32,11 @@ export const LabeledIdFilter: React.FC = ({ } function getLabel(i: SelectObject) { - if (inputType === "galleries") { - return galleryTitle(i); + switch (inputType) { + case "galleries": + return galleryTitle(i); + case "scenes": + return objectTitle(i); } return i.name ?? i.title ?? ""; diff --git a/ui/v2.5/src/models/list-filter/criteria/scenes.ts b/ui/v2.5/src/models/list-filter/criteria/scenes.ts index 365cc7f86cb..3468382799b 100644 --- a/ui/v2.5/src/models/list-filter/criteria/scenes.ts +++ b/ui/v2.5/src/models/list-filter/criteria/scenes.ts @@ -1,4 +1,9 @@ -import { ILabeledIdCriterion, ILabeledIdCriterionOption } from "./criterion"; +import { + CriterionOption, + ILabeledIdCriterion, + ILabeledIdCriterionOption, +} from "./criterion"; +import { CriterionModifier } from "src/core/generated-graphql"; const inputType = "scenes"; @@ -15,3 +20,25 @@ export class ScenesCriterion extends ILabeledIdCriterion { super(ScenesCriterionOption); } } + +const modifierOptions = [ + CriterionModifier.Includes, + CriterionModifier.Excludes, +]; + +const defaultModifier = CriterionModifier.Includes; + +export const MarkersScenesCriterionOption = new CriterionOption({ + messageID: "scenes", + type: "scenes", + modifierOptions, + defaultModifier, + inputType, + makeCriterion: () => new MarkersScenesCriterion(), +}); + +export class MarkersScenesCriterion extends ILabeledIdCriterion { + constructor() { + super(MarkersScenesCriterionOption); + } +} 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 3de42b2a1e9..7f6e555ccf3 100644 --- a/ui/v2.5/src/models/list-filter/scene-markers.ts +++ b/ui/v2.5/src/models/list-filter/scene-markers.ts @@ -1,4 +1,5 @@ import { PerformersCriterionOption } from "./criteria/performers"; +import { MarkersScenesCriterionOption } from "./criteria/scenes"; import { SceneTagsCriterionOption, TagsCriterionOption } from "./criteria/tags"; import { ListFilterOptions } from "./filter-options"; import { DisplayMode } from "./types"; @@ -18,6 +19,7 @@ const sortByOptions = [ const displayModeOptions = [DisplayMode.Wall]; const criterionOptions = [ TagsCriterionOption, + MarkersScenesCriterionOption, SceneTagsCriterionOption, PerformersCriterionOption, createMandatoryTimestampCriterionOption("created_at"), From ce47efc41596b914424a4b0c63298600d0077574 Mon Sep 17 00:00:00 2001 From: NodudeWasTaken <75137537+NodudeWasTaken@users.noreply.github.com> Date: Tue, 27 Aug 2024 10:03:48 +0200 Subject: [PATCH 077/103] Add video codec profiles (#5154) --- pkg/ffmpeg/codec.go | 29 ++++++++++------ pkg/ffmpeg/codec_hardware.go | 54 ++++++++++++++++++----------- pkg/ffmpeg/stream_transcode.go | 32 +++++++++++------ pkg/ffmpeg/transcoder/screenshot.go | 6 ++-- pkg/ffmpeg/transcoder/splice.go | 8 ++--- 5 files changed, 80 insertions(+), 49 deletions(-) diff --git a/pkg/ffmpeg/codec.go b/pkg/ffmpeg/codec.go index 1195fdc3dd3..45fff9ffbe3 100644 --- a/pkg/ffmpeg/codec.go +++ b/pkg/ffmpeg/codec.go @@ -1,25 +1,32 @@ package ffmpeg -type VideoCodec string +type VideoCodec struct { + Name string // The full name of the codec including profile/quality + CodeName string // The core codec name without profile/quality suffix +} + +func makeVideoCodec(name string, codename string) VideoCodec { + return VideoCodec{name, codename} +} func (c VideoCodec) Args() []string { - if c == "" { + if c.CodeName == "" { return nil } - return []string{"-c:v", string(c)} + return []string{"-c:v", string(c.CodeName)} } var ( // Software codec's - VideoCodecLibX264 VideoCodec = "libx264" - VideoCodecLibWebP VideoCodec = "libwebp" - VideoCodecBMP VideoCodec = "bmp" - VideoCodecMJpeg VideoCodec = "mjpeg" - VideoCodecVP9 VideoCodec = "libvpx-vp9" - VideoCodecVPX VideoCodec = "libvpx" - VideoCodecLibX265 VideoCodec = "libx265" - VideoCodecCopy VideoCodec = "copy" + VideoCodecLibX264 = makeVideoCodec("x264", "libx264") + VideoCodecLibWebP = makeVideoCodec("WebP", "libwebp") + VideoCodecBMP = makeVideoCodec("BMP", "bmp") + VideoCodecMJpeg = makeVideoCodec("Jpeg", "mjpeg") + VideoCodecVP9 = makeVideoCodec("VPX-VP9", "libvpx-vp9") + VideoCodecVPX = makeVideoCodec("VPX-VP8", "libvpx") + VideoCodecLibX265 = makeVideoCodec("x265", "libx265") + VideoCodecCopy = makeVideoCodec("Copy", "copy") ) type AudioCodec string diff --git a/pkg/ffmpeg/codec_hardware.go b/pkg/ffmpeg/codec_hardware.go index e4797a84adc..73d825706a1 100644 --- a/pkg/ffmpeg/codec_hardware.go +++ b/pkg/ffmpeg/codec_hardware.go @@ -15,16 +15,18 @@ import ( var ( // Hardware codec's - VideoCodecN264 VideoCodec = "h264_nvenc" - VideoCodecI264 VideoCodec = "h264_qsv" - VideoCodecA264 VideoCodec = "h264_amf" - VideoCodecM264 VideoCodec = "h264_videotoolbox" - VideoCodecV264 VideoCodec = "h264_vaapi" - VideoCodecR264 VideoCodec = "h264_v4l2m2m" - VideoCodecO264 VideoCodec = "h264_omx" - VideoCodecIVP9 VideoCodec = "vp9_qsv" - VideoCodecVVP9 VideoCodec = "vp9_vaapi" - VideoCodecVVPX VideoCodec = "vp8_vaapi" + VideoCodecN264 = makeVideoCodec("H264 NVENC", "h264_nvenc") + VideoCodecN264H = makeVideoCodec("H264 NVENC HQ profile", "h264_nvenc") + VideoCodecI264 = makeVideoCodec("H264 Intel Quick Sync Video (QSV)", "h264_qsv") + VideoCodecI264C = makeVideoCodec("H264 Intel Quick Sync Video (QSV) Compatibility profile", "h264_qsv") + VideoCodecA264 = makeVideoCodec("H264 Advanced Media Framework (AMF)", "h264_amf") + VideoCodecM264 = makeVideoCodec("H264 VideoToolbox", "h264_videotoolbox") + VideoCodecV264 = makeVideoCodec("H264 VAAPI", "h264_vaapi") + VideoCodecR264 = makeVideoCodec("H264 V4L2M2M", "h264_v4l2m2m") + VideoCodecO264 = makeVideoCodec("H264 OMX", "h264_omx") + VideoCodecIVP9 = makeVideoCodec("VP9 Intel Quick Sync Video (QSV)", "vp9_qsv") + VideoCodecVVP9 = makeVideoCodec("VP9 VAAPI", "vp9_vaapi") + VideoCodecVVPX = makeVideoCodec("VP8 VAAPI", "vp8_vaapi") ) const minHeight int = 480 @@ -33,9 +35,12 @@ const minHeight int = 480 func (f *FFMpeg) InitHWSupport(ctx context.Context) { var hwCodecSupport []VideoCodec + // Note that the first compatible codec is returned, so order is important for _, codec := range []VideoCodec{ + VideoCodecN264H, VideoCodecN264, VideoCodecI264, + VideoCodecI264C, VideoCodecV264, VideoCodecR264, VideoCodecIVP9, @@ -79,7 +84,7 @@ func (f *FFMpeg) InitHWSupport(ctx context.Context) { outstr := fmt.Sprintf("[InitHWSupport] Supported HW codecs [%d]:\n", len(hwCodecSupport)) for _, codec := range hwCodecSupport { - outstr += fmt.Sprintf("\t%s\n", codec) + outstr += fmt.Sprintf("\t%s - %s\n", codec.Name, codec.CodeName) } logger.Info(outstr) @@ -128,7 +133,8 @@ func (f *FFMpeg) hwCanFullHWTranscode(ctx context.Context, codec VideoCodec, vf // Prepend input for hardware encoding only func (f *FFMpeg) hwDeviceInit(args Args, toCodec VideoCodec, fullhw bool) Args { switch toCodec { - case VideoCodecN264: + case VideoCodecN264, + VideoCodecN264H: args = append(args, "-hwaccel_device") args = append(args, "0") if fullhw { @@ -150,6 +156,7 @@ func (f *FFMpeg) hwDeviceInit(args Args, toCodec VideoCodec, fullhw bool) Args { args = append(args, "vaapi") } case VideoCodecI264, + VideoCodecI264C, VideoCodecIVP9: if fullhw { args = append(args, "-hwaccel") @@ -187,12 +194,13 @@ func (f *FFMpeg) hwFilterInit(toCodec VideoCodec, fullhw bool) VideoFilter { videoFilter = videoFilter.Append("format=nv12") videoFilter = videoFilter.Append("hwupload") } - case VideoCodecN264: + case VideoCodecN264, VideoCodecN264H: if !fullhw { - videoFilter = videoFilter.Append("format=yuv420p") + videoFilter = videoFilter.Append("format=nv12") videoFilter = videoFilter.Append("hwupload_cuda") } case VideoCodecI264, + VideoCodecI264C, VideoCodecIVP9: if !fullhw { videoFilter = videoFilter.Append("hwupload=extra_hw_frames=64") @@ -268,7 +276,7 @@ func (f *FFMpeg) hwCodecFilter(args VideoFilter, codec VideoCodec, vf *models.Vi // Apply format switching if applicable func (f *FFMpeg) hwApplyFullHWFilter(args VideoFilter, codec VideoCodec, fullhw bool) VideoFilter { switch codec { - case VideoCodecN264: + case VideoCodecN264, VideoCodecN264H: if fullhw && f.version.Gteq(FFMpegVersion{major: 5}) { // Added in FFMpeg 5 args = args.Append("scale_cuda=format=yuv420p") } @@ -276,7 +284,7 @@ func (f *FFMpeg) hwApplyFullHWFilter(args VideoFilter, codec VideoCodec, fullhw if fullhw && f.version.Gteq(FFMpegVersion{major: 3, minor: 1}) { // Added in FFMpeg 3.1 args = args.Append("scale_vaapi=format=nv12") } - case VideoCodecI264, VideoCodecIVP9: + case VideoCodecI264, VideoCodecI264C, VideoCodecIVP9: if fullhw && f.version.Gteq(FFMpegVersion{major: 3, minor: 3}) { // Added in FFMpeg 3.3 args = args.Append("scale_qsv=format=nv12") } @@ -290,7 +298,7 @@ func (f *FFMpeg) hwApplyScaleTemplate(sargs string, codec VideoCodec, match []in var template string switch codec { - case VideoCodecN264: + case VideoCodecN264, VideoCodecN264H: template = "scale_cuda=$value" if fullhw && f.version.Gteq(FFMpegVersion{major: 5}) { // Added in FFMpeg 5 template += ":format=yuv420p" @@ -300,7 +308,7 @@ func (f *FFMpeg) hwApplyScaleTemplate(sargs string, codec VideoCodec, match []in if fullhw && f.version.Gteq(FFMpegVersion{major: 3, minor: 1}) { // Added in FFMpeg 3.1 template += ":format=nv12" } - case VideoCodecI264, VideoCodecIVP9: + case VideoCodecI264, VideoCodecI264C, VideoCodecIVP9: template = "scale_qsv=$value" if fullhw && f.version.Gteq(FFMpegVersion{major: 3, minor: 3}) { // Added in FFMpeg 3.3 template += ":format=nv12" @@ -312,7 +320,7 @@ func (f *FFMpeg) hwApplyScaleTemplate(sargs string, codec VideoCodec, match []in } // BUG: [scale_qsv]: Size values less than -1 are not acceptable. - isIntel := codec == VideoCodecI264 || codec == VideoCodecIVP9 + isIntel := codec == VideoCodecI264 || codec == VideoCodecI264C || codec == VideoCodecIVP9 // BUG: scale_vt doesn't call ff_scale_adjust_dimensions, thus cant accept negative size values isApple := codec == VideoCodecM264 return VideoFilter(templateReplaceScale(sargs, template, match, vf, isIntel || isApple)) @@ -322,7 +330,9 @@ func (f *FFMpeg) hwApplyScaleTemplate(sargs string, codec VideoCodec, match []in func (f *FFMpeg) hwCodecMaxRes(codec VideoCodec) (int, int) { switch codec { case VideoCodecN264, - VideoCodecI264: + VideoCodecN264H, + VideoCodecI264, + VideoCodecI264C: return 4096, 4096 } @@ -345,7 +355,9 @@ func (f *FFMpeg) hwCodecHLSCompatible() *VideoCodec { for _, element := range f.hwCodecSupport { switch element { case VideoCodecN264, + VideoCodecN264H, VideoCodecI264, + VideoCodecI264C, VideoCodecV264, VideoCodecR264, VideoCodecM264: // Note that the Apple encoder sucks at startup, thus HLS quality is crap @@ -360,7 +372,9 @@ func (f *FFMpeg) hwCodecMP4Compatible() *VideoCodec { for _, element := range f.hwCodecSupport { switch element { case VideoCodecN264, + VideoCodecN264H, VideoCodecI264, + VideoCodecI264C, VideoCodecM264: return &element } diff --git a/pkg/ffmpeg/stream_transcode.go b/pkg/ffmpeg/stream_transcode.go index 71465247055..e0a30cdd9e5 100644 --- a/pkg/ffmpeg/stream_transcode.go +++ b/pkg/ffmpeg/stream_transcode.go @@ -45,12 +45,31 @@ func CodecInit(codec VideoCodec) (args Args) { "-rc", "vbr", "-cq", "15", ) - case VideoCodecI264: + case VideoCodecN264H: + args = append(args, + "-profile", "p7", + "-tune", "hq", + "-profile", "high", + "-rc", "vbr", + "-rc-lookahead", "60", + "-surfaces", "64", + "-spatial-aq", "1", + "-aq-strength", "15", + "-cq", "15", + "-coder", "cabac", + "-b_ref_mode", "middle", + ) + case VideoCodecI264, VideoCodecIVP9: args = append(args, "-global_quality", "20", "-preset", "faster", ) - case VideoCodecV264: + case VideoCodecI264C: + args = append(args, + "-q", "20", + "-preset", "faster", + ) + case VideoCodecV264, VideoCodecVVP9: args = append(args, "-qp", "20", ) @@ -67,15 +86,6 @@ func CodecInit(codec VideoCodec) (args Args) { "-preset", "superfast", "-crf", "25", ) - case VideoCodecIVP9: - args = append(args, - "-global_quality", "20", - "-preset", "faster", - ) - case VideoCodecVVP9: - args = append(args, - "-qp", "20", - ) } return args diff --git a/pkg/ffmpeg/transcoder/screenshot.go b/pkg/ffmpeg/transcoder/screenshot.go index a1ddef6b6ae..c3343d594b9 100644 --- a/pkg/ffmpeg/transcoder/screenshot.go +++ b/pkg/ffmpeg/transcoder/screenshot.go @@ -24,13 +24,13 @@ func (o *ScreenshotOptions) setDefaults() { } type ScreenshotOutputType struct { - codec ffmpeg.VideoCodec + codec *ffmpeg.VideoCodec format ffmpeg.Format } func (t ScreenshotOutputType) Args() []string { var ret []string - if t.codec != "" { + if t.codec != nil { ret = append(ret, t.codec.Args()...) } if t.format != "" { @@ -45,7 +45,7 @@ var ( format: "image2", } ScreenshotOutputTypeBMP = ScreenshotOutputType{ - codec: ffmpeg.VideoCodecBMP, + codec: &ffmpeg.VideoCodecBMP, format: "rawvideo", } ) diff --git a/pkg/ffmpeg/transcoder/splice.go b/pkg/ffmpeg/transcoder/splice.go index 7ae7e6c944c..45d71332b8d 100644 --- a/pkg/ffmpeg/transcoder/splice.go +++ b/pkg/ffmpeg/transcoder/splice.go @@ -11,7 +11,7 @@ type SpliceOptions struct { OutputPath string Format ffmpeg.Format - VideoCodec ffmpeg.VideoCodec + VideoCodec *ffmpeg.VideoCodec VideoArgs ffmpeg.Args AudioCodec ffmpeg.AudioCodec @@ -45,11 +45,11 @@ func Splice(concatFile string, options SpliceOptions) ffmpeg.Args { args = args.Overwrite() // if video codec is not provided, then use copy - if options.VideoCodec == "" { - options.VideoCodec = ffmpeg.VideoCodecCopy + if options.VideoCodec == nil { + options.VideoCodec = &ffmpeg.VideoCodecCopy } - args = args.VideoCodec(options.VideoCodec) + args = args.VideoCodec(*options.VideoCodec) args = args.AppendArgs(options.VideoArgs) // if audio codec is not provided, then use copy From 996dfb1c2f85481a66929e700e1931991b51a6e4 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Wed, 28 Aug 2024 08:59:41 +1000 Subject: [PATCH 078/103] Gallery scrubber (#5133) --- graphql/schema/types/gallery.graphql | 7 ++ internal/api/context_keys.go | 4 +- internal/api/resolver_model_gallery.go | 27 ++++ internal/api/routes_gallery.go | 116 ++++++++++++++++++ internal/api/routes_image.go | 6 +- internal/api/server.go | 11 ++ internal/api/urlbuilders/gallery.go | 23 ++++ pkg/models/mocks/ImageReaderWriter.go | 23 ++++ pkg/models/repository_image.go | 1 + pkg/sqlite/image.go | 31 ++++- ui/v2.5/graphql/data/gallery-slim.graphql | 3 + ui/v2.5/graphql/data/gallery.graphql | 7 ++ ui/v2.5/graphql/queries/gallery.graphql | 8 ++ .../src/components/Galleries/Galleries.tsx | 39 +++++- .../src/components/Galleries/GalleryCard.tsx | 55 +++++++-- .../Galleries/GalleryPreviewScrubber.tsx | 54 ++++++++ ui/v2.5/src/components/Galleries/styles.scss | 8 ++ .../src/components/Scenes/PreviewScrubber.tsx | 84 +------------ .../src/components/Shared/HoverScrubber.tsx | 84 +++++++++++++ ui/v2.5/src/core/StashService.ts | 4 + ui/v2.5/src/index.scss | 8 +- 21 files changed, 501 insertions(+), 102 deletions(-) create mode 100644 internal/api/routes_gallery.go create mode 100644 internal/api/urlbuilders/gallery.go create mode 100644 ui/v2.5/src/components/Galleries/GalleryPreviewScrubber.tsx create mode 100644 ui/v2.5/src/components/Shared/HoverScrubber.tsx diff --git a/graphql/schema/types/gallery.graphql b/graphql/schema/types/gallery.graphql index 47f6c7d7eee..3cf3216b9ae 100644 --- a/graphql/schema/types/gallery.graphql +++ b/graphql/schema/types/gallery.graphql @@ -1,3 +1,7 @@ +type GalleryPathsType { + preview: String! # Resolver +} + "Gallery type" type Gallery { id: ID! @@ -25,6 +29,9 @@ type Gallery { performers: [Performer!]! cover: Image + + paths: GalleryPathsType! # Resolver + image(index: Int!): Image! } input GalleryCreateInput { diff --git a/internal/api/context_keys.go b/internal/api/context_keys.go index df61139f859..b3a7d135bbf 100644 --- a/internal/api/context_keys.go +++ b/internal/api/context_keys.go @@ -5,8 +5,8 @@ package api type key int const ( - // galleryKey key = 0 - performerKey key = iota + 1 + galleryKey key = 0 + performerKey sceneKey studioKey groupKey diff --git a/internal/api/resolver_model_gallery.go b/internal/api/resolver_model_gallery.go index 3057843e012..7877e819dc2 100644 --- a/internal/api/resolver_model_gallery.go +++ b/internal/api/resolver_model_gallery.go @@ -2,8 +2,10 @@ package api import ( "context" + "fmt" "github.com/stashapp/stash/internal/api/loaders" + "github.com/stashapp/stash/internal/api/urlbuilders" "github.com/stashapp/stash/internal/manager/config" "github.com/stashapp/stash/pkg/image" @@ -189,3 +191,28 @@ func (r *galleryResolver) Urls(ctx context.Context, obj *models.Gallery) ([]stri return obj.URLs.List(), nil } + +func (r *galleryResolver) Paths(ctx context.Context, obj *models.Gallery) (*GalleryPathsType, error) { + baseURL, _ := ctx.Value(BaseURLCtxKey).(string) + builder := urlbuilders.NewGalleryURLBuilder(baseURL, obj) + previewPath := builder.GetPreviewURL() + + return &GalleryPathsType{ + Preview: previewPath, + }, nil +} + +func (r *galleryResolver) Image(ctx context.Context, obj *models.Gallery, index int) (ret *models.Image, err error) { + if index < 0 { + return nil, fmt.Errorf("index must >= 0") + } + + if err := r.withReadTxn(ctx, func(ctx context.Context) error { + ret, err = r.repository.Image.FindByGalleryIDIndex(ctx, obj.ID, uint(index)) + return err + }); err != nil { + return nil, err + } + + return +} diff --git a/internal/api/routes_gallery.go b/internal/api/routes_gallery.go new file mode 100644 index 00000000000..fcadae5f938 --- /dev/null +++ b/internal/api/routes_gallery.go @@ -0,0 +1,116 @@ +package api + +import ( + "context" + "errors" + "net/http" + "strconv" + + "github.com/go-chi/chi/v5" + + "github.com/stashapp/stash/pkg/logger" + "github.com/stashapp/stash/pkg/models" +) + +type GalleryFinder interface { + models.GalleryGetter + FindByChecksum(ctx context.Context, checksum string) ([]*models.Gallery, error) +} + +type ImageByIndexer interface { + FindByGalleryIDIndex(ctx context.Context, galleryID int, index uint) (*models.Image, error) +} + +type galleryRoutes struct { + routes + imageRoutes imageRoutes + galleryFinder GalleryFinder + imageFinder ImageByIndexer + fileGetter models.FileGetter +} + +func (rs galleryRoutes) Routes() chi.Router { + r := chi.NewRouter() + + r.Route("/{galleryId}", func(r chi.Router) { + r.Use(rs.GalleryCtx) + + r.Get("/preview/{imageIndex}", rs.Preview) + }) + + return r +} + +func (rs galleryRoutes) Preview(w http.ResponseWriter, r *http.Request) { + g := r.Context().Value(galleryKey).(*models.Gallery) + indexQueryParam := chi.URLParam(r, "imageIndex") + var i *models.Image + + index, err := strconv.Atoi(indexQueryParam) + if err != nil || index < 0 { + http.Error(w, "bad index", 400) + return + } + + _ = rs.withReadTxn(r, func(ctx context.Context) error { + qb := rs.imageFinder + i, _ = qb.FindByGalleryIDIndex(ctx, g.ID, uint(index)) + // TODO - handle errors? + + // serveThumbnail needs files populated + if err := i.LoadPrimaryFile(ctx, rs.fileGetter); err != nil { + if !errors.Is(err, context.Canceled) { + logger.Errorf("error loading primary file for image %d: %v", i.ID, err) + } + // set image to nil so that it doesn't try to use the primary file + i = nil + } + + return nil + }) + if i == nil { + http.Error(w, http.StatusText(404), 404) + return + } + + rs.imageRoutes.serveThumbnail(w, r, i) +} + +func (rs galleryRoutes) GalleryCtx(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + galleryIdentifierQueryParam := chi.URLParam(r, "galleryId") + galleryID, _ := strconv.Atoi(galleryIdentifierQueryParam) + + var gallery *models.Gallery + _ = rs.withReadTxn(r, func(ctx context.Context) error { + qb := rs.galleryFinder + if galleryID == 0 { + galleries, _ := qb.FindByChecksum(ctx, galleryIdentifierQueryParam) + if len(galleries) > 0 { + gallery = galleries[0] + } + } else { + gallery, _ = qb.Find(ctx, galleryID) + } + + if gallery != nil { + if err := gallery.LoadPrimaryFile(ctx, rs.fileGetter); err != nil { + if !errors.Is(err, context.Canceled) { + logger.Errorf("error loading primary file for gallery %d: %v", galleryID, err) + } + // set image to nil so that it doesn't try to use the primary file + gallery = nil + } + } + + return nil + }) + if gallery == nil { + http.Error(w, http.StatusText(404), 404) + return + } + + ctx := context.WithValue(r.Context(), galleryKey, gallery) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} diff --git a/internal/api/routes_image.go b/internal/api/routes_image.go index 270b4de7fe2..89e6d2db4d0 100644 --- a/internal/api/routes_image.go +++ b/internal/api/routes_image.go @@ -46,8 +46,12 @@ func (rs imageRoutes) Routes() chi.Router { } func (rs imageRoutes) Thumbnail(w http.ResponseWriter, r *http.Request) { - mgr := manager.GetInstance() img := r.Context().Value(imageKey).(*models.Image) + rs.serveThumbnail(w, r, img) +} + +func (rs imageRoutes) serveThumbnail(w http.ResponseWriter, r *http.Request, img *models.Image) { + mgr := manager.GetInstance() filepath := mgr.Paths.Generated.GetThumbnailPath(img.Checksum, models.DefaultGthumbWidth) // if the thumbnail doesn't exist, encode on the fly diff --git a/internal/api/server.go b/internal/api/server.go index 679bd3f1c85..1ddf1baef44 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -207,6 +207,7 @@ func Initialize() (*Server, error) { r.Mount("/performer", server.getPerformerRoutes()) r.Mount("/scene", server.getSceneRoutes()) + r.Mount("/gallery", server.getGalleryRoutes()) r.Mount("/image", server.getImageRoutes()) r.Mount("/studio", server.getStudioRoutes()) r.Mount("/group", server.getGroupRoutes()) @@ -326,6 +327,16 @@ func (s *Server) getSceneRoutes() chi.Router { }.Routes() } +func (s *Server) getGalleryRoutes() chi.Router { + repo := s.manager.Repository + return galleryRoutes{ + routes: routes{txnManager: repo.TxnManager}, + imageFinder: repo.Image, + galleryFinder: repo.Gallery, + fileGetter: repo.File, + }.Routes() +} + func (s *Server) getImageRoutes() chi.Router { repo := s.manager.Repository return imageRoutes{ diff --git a/internal/api/urlbuilders/gallery.go b/internal/api/urlbuilders/gallery.go new file mode 100644 index 00000000000..8aeff1e04fd --- /dev/null +++ b/internal/api/urlbuilders/gallery.go @@ -0,0 +1,23 @@ +package urlbuilders + +import ( + "strconv" + + "github.com/stashapp/stash/pkg/models" +) + +type GalleryURLBuilder struct { + BaseURL string + GalleryID string +} + +func NewGalleryURLBuilder(baseURL string, gallery *models.Gallery) GalleryURLBuilder { + return GalleryURLBuilder{ + BaseURL: baseURL, + GalleryID: strconv.Itoa(gallery.ID), + } +} + +func (b GalleryURLBuilder) GetPreviewURL() string { + return b.BaseURL + "/gallery/" + b.GalleryID + "/preview" +} diff --git a/pkg/models/mocks/ImageReaderWriter.go b/pkg/models/mocks/ImageReaderWriter.go index 5a525857bfe..4cdd0d8ee20 100644 --- a/pkg/models/mocks/ImageReaderWriter.go +++ b/pkg/models/mocks/ImageReaderWriter.go @@ -301,6 +301,29 @@ func (_m *ImageReaderWriter) FindByGalleryID(ctx context.Context, galleryID int) return r0, r1 } +// FindByGalleryIDIndex provides a mock function with given fields: ctx, galleryID, index +func (_m *ImageReaderWriter) FindByGalleryIDIndex(ctx context.Context, galleryID int, index uint) (*models.Image, error) { + ret := _m.Called(ctx, galleryID, index) + + var r0 *models.Image + if rf, ok := ret.Get(0).(func(context.Context, int, uint) *models.Image); ok { + r0 = rf(ctx, galleryID, index) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*models.Image) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, int, uint) error); ok { + r1 = rf(ctx, galleryID, index) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // FindByZipFileID provides a mock function with given fields: ctx, zipFileID func (_m *ImageReaderWriter) FindByZipFileID(ctx context.Context, zipFileID models.FileID) ([]*models.Image, error) { ret := _m.Called(ctx, zipFileID) diff --git a/pkg/models/repository_image.go b/pkg/models/repository_image.go index ead05105b45..fd58ed762ff 100644 --- a/pkg/models/repository_image.go +++ b/pkg/models/repository_image.go @@ -18,6 +18,7 @@ type ImageFinder interface { FindByFolderID(ctx context.Context, fileID FolderID) ([]*Image, error) FindByZipFileID(ctx context.Context, zipFileID FileID) ([]*Image, error) FindByGalleryID(ctx context.Context, galleryID int) ([]*Image, error) + FindByGalleryIDIndex(ctx context.Context, galleryID int, index uint) (*Image, error) } // ImageQueryer provides methods to query images. diff --git a/pkg/sqlite/image.go b/pkg/sqlite/image.go index dc4ed920fe5..3d1882a1e9b 100644 --- a/pkg/sqlite/image.go +++ b/pkg/sqlite/image.go @@ -568,8 +568,6 @@ func (qb *ImageStore) FindByChecksum(ctx context.Context, checksum string) ([]*m func (qb *ImageStore) FindByGalleryID(ctx context.Context, galleryID int) ([]*models.Image, error) { table := qb.table() - fileTable := fileTableMgr.table - folderTable := folderTableMgr.table sq := dialect.From(table). InnerJoin( @@ -584,7 +582,7 @@ func (qb *ImageStore) FindByGalleryID(ctx context.Context, galleryID int) ([]*mo table.Col(idColumn).Eq( sq, ), - ).Order(folderTable.Col("path").Asc(), fileTable.Col("basename").Asc()) + ).Order(goqu.L("COALESCE(folders.path, '') || COALESCE(files.basename, '') COLLATE NATURAL_CI").Asc()) ret, err := qb.getMany(ctx, q) if err != nil { @@ -594,6 +592,33 @@ func (qb *ImageStore) FindByGalleryID(ctx context.Context, galleryID int) ([]*mo return ret, nil } +func (qb *ImageStore) FindByGalleryIDIndex(ctx context.Context, galleryID int, index uint) (*models.Image, error) { + table := qb.table() + fileTable := fileTableMgr.table + folderTable := folderTableMgr.table + + q := qb.selectDataset(). + InnerJoin( + galleriesImagesJoinTable, + goqu.On(table.Col(idColumn).Eq(galleriesImagesJoinTable.Col(imageIDColumn))), + ). + Where(galleriesImagesJoinTable.Col(galleryIDColumn).Eq(galleryID)). + Prepared(true). + Order(folderTable.Col("path").Asc(), fileTable.Col("basename").Asc()). + Limit(1).Offset(index) + + ret, err := qb.getMany(ctx, q) + if err != nil { + return nil, fmt.Errorf("getting images for gallery %d: %w", galleryID, err) + } + + if len(ret) == 0 { + return nil, nil + } + + return ret[0], nil +} + func (qb *ImageStore) CountByGalleryID(ctx context.Context, galleryID int) (int, error) { joinTable := goqu.T(galleriesImagesTable) diff --git a/ui/v2.5/graphql/data/gallery-slim.graphql b/ui/v2.5/graphql/data/gallery-slim.graphql index fd2688777e7..51036d0e30c 100644 --- a/ui/v2.5/graphql/data/gallery-slim.graphql +++ b/ui/v2.5/graphql/data/gallery-slim.graphql @@ -48,4 +48,7 @@ fragment SlimGalleryData on Gallery { scenes { ...SlimSceneData } + paths { + preview + } } diff --git a/ui/v2.5/graphql/data/gallery.graphql b/ui/v2.5/graphql/data/gallery.graphql index 5a5db3c1ace..9eb570f8e29 100644 --- a/ui/v2.5/graphql/data/gallery.graphql +++ b/ui/v2.5/graphql/data/gallery.graphql @@ -11,6 +11,10 @@ fragment GalleryData on Gallery { rating100 organized + paths { + preview + } + files { ...GalleryFileData } @@ -52,6 +56,9 @@ fragment SelectGalleryData on Gallery { thumbnail } } + paths { + preview + } files { path } diff --git a/ui/v2.5/graphql/queries/gallery.graphql b/ui/v2.5/graphql/queries/gallery.graphql index 6c33b9910d9..5c9f786e77f 100644 --- a/ui/v2.5/graphql/queries/gallery.graphql +++ b/ui/v2.5/graphql/queries/gallery.graphql @@ -28,3 +28,11 @@ query FindGalleriesForSelect( } } } + +query FindGalleryImageID($id: ID!, $index: Int!) { + findGallery(id: $id) { + image(index: $index) { + id + } + } +} diff --git a/ui/v2.5/src/components/Galleries/Galleries.tsx b/ui/v2.5/src/components/Galleries/Galleries.tsx index cc2e84ff76c..db3db8dddc0 100644 --- a/ui/v2.5/src/components/Galleries/Galleries.tsx +++ b/ui/v2.5/src/components/Galleries/Galleries.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { Route, Switch } from "react-router-dom"; +import { Redirect, Route, RouteComponentProps, Switch } from "react-router-dom"; import { Helmet } from "react-helmet"; import { useTitleProps } from "src/hooks/title"; import Gallery from "./GalleryDetails/Gallery"; @@ -7,6 +7,38 @@ import GalleryCreate from "./GalleryDetails/GalleryCreate"; import { GalleryList } from "./GalleryList"; import { useScrollToTopOnMount } from "src/hooks/scrollToTop"; import { View } from "../List/views"; +import { LoadingIndicator } from "../Shared/LoadingIndicator"; +import { ErrorMessage } from "../Shared/ErrorMessage"; +import { useFindGalleryImageID } from "src/core/StashService"; + +interface IGalleryImageParams { + id: string; + index: string; +} + +const GalleryImage: React.FC> = ({ + match, +}) => { + const { id, index: indexStr } = match.params; + + let index = parseInt(indexStr); + if (isNaN(index)) { + index = 0; + } + + const { data, loading, error } = useFindGalleryImageID(id, index); + + if (isNaN(index)) { + return ; + } + + if (loading) return ; + if (error) return ; + if (!data?.findGallery) + return ; + + return ; +}; const Galleries: React.FC = () => { useScrollToTopOnMount(); @@ -22,6 +54,11 @@ const GalleryRoutes: React.FC = () => { + diff --git a/ui/v2.5/src/components/Galleries/GalleryCard.tsx b/ui/v2.5/src/components/Galleries/GalleryCard.tsx index 0c7491dc6f5..26542f4aff0 100644 --- a/ui/v2.5/src/components/Galleries/GalleryCard.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryCard.tsx @@ -14,6 +14,45 @@ import { faBox, faPlayCircle, faTag } from "@fortawesome/free-solid-svg-icons"; import { galleryTitle } from "src/core/galleries"; import ScreenUtils from "src/utils/screen"; import { StudioOverlay } from "../Shared/GridCard/StudioOverlay"; +import { GalleryPreviewScrubber } from "./GalleryPreviewScrubber"; +import cx from "classnames"; +import { useHistory } from "react-router-dom"; + +interface IScenePreviewProps { + isPortrait?: boolean; + gallery: GQL.SlimGalleryDataFragment; + onScrubberClick?: (index: number) => void; +} + +export const GalleryPreview: React.FC = ({ + gallery, + isPortrait = false, + onScrubberClick, +}) => { + const [imgSrc, setImgSrc] = useState( + gallery.cover?.paths.thumbnail ?? undefined + ); + + return ( +
    + {!!imgSrc && ( + {gallery.title + )} + +
    + ); +}; interface IProps { gallery: GQL.SlimGalleryDataFragment; @@ -25,6 +64,7 @@ interface IProps { } export const GalleryCard: React.FC = (props) => { + const history = useHistory(); const [cardWidth, setCardWidth] = useState(); useEffect(() => { @@ -167,14 +207,13 @@ export const GalleryCard: React.FC = (props) => { linkClassName="gallery-card-header" image={ <> - {props.gallery.cover ? ( - {props.gallery.title - ) : undefined} + { + console.log(i); + history.push(`/galleries/${props.gallery.id}/images/${i}`); + }} + /> } diff --git a/ui/v2.5/src/components/Galleries/GalleryPreviewScrubber.tsx b/ui/v2.5/src/components/Galleries/GalleryPreviewScrubber.tsx new file mode 100644 index 00000000000..6bc10274ada --- /dev/null +++ b/ui/v2.5/src/components/Galleries/GalleryPreviewScrubber.tsx @@ -0,0 +1,54 @@ +import React, { useEffect, useState } from "react"; +import { useThrottle } from "src/hooks/throttle"; +import { HoverScrubber } from "../Shared/HoverScrubber"; +import cx from "classnames"; + +export const GalleryPreviewScrubber: React.FC<{ + className?: string; + previewPath: string; + defaultPath: string; + imageCount: number; + onClick?: (imageIndex: number) => void; + onPathChanged: React.Dispatch>; +}> = ({ + className, + previewPath, + defaultPath, + imageCount, + onClick, + onPathChanged, +}) => { + const [activeIndex, setActiveIndex] = useState(); + const debounceSetActiveIndex = useThrottle(setActiveIndex, 50); + + function onScrubberClick() { + if (activeIndex === undefined || !onClick) { + return; + } + + onClick(activeIndex); + } + + useEffect(() => { + function getPath() { + if (activeIndex === undefined) { + return defaultPath; + } + + return `${previewPath}/${activeIndex}`; + } + + onPathChanged(getPath()); + }, [activeIndex, defaultPath, previewPath, onPathChanged]); + + return ( +
    + debounceSetActiveIndex(i)} + onClick={() => onScrubberClick()} + /> +
    + ); +}; diff --git a/ui/v2.5/src/components/Galleries/styles.scss b/ui/v2.5/src/components/Galleries/styles.scss index f9fdaffcd01..4d5b6bcae89 100644 --- a/ui/v2.5/src/components/Galleries/styles.scss +++ b/ui/v2.5/src/components/Galleries/styles.scss @@ -102,6 +102,14 @@ color: $text-color; } + &-cover { + position: relative; + } + + .preview-scrubber { + top: 0; + } + &-image { object-fit: contain; } diff --git a/ui/v2.5/src/components/Scenes/PreviewScrubber.tsx b/ui/v2.5/src/components/Scenes/PreviewScrubber.tsx index aec85eda6e2..cdfc3a6e9bd 100644 --- a/ui/v2.5/src/components/Scenes/PreviewScrubber.tsx +++ b/ui/v2.5/src/components/Scenes/PreviewScrubber.tsx @@ -8,89 +8,7 @@ import React, { import { useSpriteInfo } from "src/hooks/sprite"; import { useThrottle } from "src/hooks/throttle"; import TextUtils from "src/utils/text"; -import cx from "classnames"; - -interface IHoverScrubber { - totalSprites: number; - activeIndex: number | undefined; - setActiveIndex: (index: number | undefined) => void; - onClick?: () => void; -} - -const HoverScrubber: React.FC = ({ - totalSprites, - activeIndex, - setActiveIndex, - onClick, -}) => { - function getActiveIndex(e: React.MouseEvent) { - const { width } = e.currentTarget.getBoundingClientRect(); - const x = e.nativeEvent.offsetX; - - const i = Math.floor((x / width) * totalSprites); - - // clamp to [0, totalSprites) - if (i < 0) return 0; - if (i >= totalSprites) return totalSprites - 1; - return i; - } - - function onMouseMove(e: React.MouseEvent) { - const relatedTarget = e.currentTarget; - - if (relatedTarget !== e.target) return; - - setActiveIndex(getActiveIndex(e)); - } - - function onMouseLeave() { - setActiveIndex(undefined); - } - - function onScrubberClick(e: React.MouseEvent) { - if (!onClick) return; - - const relatedTarget = e.currentTarget; - - if (relatedTarget !== e.target) return; - - e.preventDefault(); - onClick(); - } - - const indicatorStyle = useMemo(() => { - if (activeIndex === undefined || !totalSprites) return {}; - - const width = (activeIndex / totalSprites) * 100; - - return { - width: `${width}%`, - }; - }, [activeIndex, totalSprites]); - - return ( -
    -
    -
    - {activeIndex !== undefined && ( -
    - )} -
    -
    - ); -}; +import { HoverScrubber } from "../Shared/HoverScrubber"; interface IScenePreviewProps { vttPath: string | undefined; diff --git a/ui/v2.5/src/components/Shared/HoverScrubber.tsx b/ui/v2.5/src/components/Shared/HoverScrubber.tsx new file mode 100644 index 00000000000..f658e1fa23e --- /dev/null +++ b/ui/v2.5/src/components/Shared/HoverScrubber.tsx @@ -0,0 +1,84 @@ +import React, { useMemo } from "react"; +import cx from "classnames"; + +interface IHoverScrubber { + totalSprites: number; + activeIndex: number | undefined; + setActiveIndex: (index: number | undefined) => void; + onClick?: () => void; +} + +export const HoverScrubber: React.FC = ({ + totalSprites, + activeIndex, + setActiveIndex, + onClick, +}) => { + function getActiveIndex(e: React.MouseEvent) { + const { width } = e.currentTarget.getBoundingClientRect(); + const x = e.nativeEvent.offsetX; + + const i = Math.round((x / width) * (totalSprites - 1)); + + // clamp to [0, totalSprites) + if (i < 0) return 0; + if (i >= totalSprites) return totalSprites - 1; + return i; + } + + function onMouseMove(e: React.MouseEvent) { + const relatedTarget = e.currentTarget; + + if (relatedTarget !== e.target) return; + + setActiveIndex(getActiveIndex(e)); + } + + function onMouseLeave() { + setActiveIndex(undefined); + } + + function onScrubberClick(e: React.MouseEvent) { + if (!onClick) return; + + const relatedTarget = e.currentTarget; + + if (relatedTarget !== e.target) return; + + e.preventDefault(); + onClick(); + } + + const indicatorStyle = useMemo(() => { + if (activeIndex === undefined || !totalSprites) return {}; + + const width = ((activeIndex + 1) / totalSprites) * 100; + + return { + width: `${width}%`, + }; + }, [activeIndex, totalSprites]); + + return ( +
    +
    +
    + {activeIndex !== undefined && ( +
    + )} +
    +
    + ); +}; diff --git a/ui/v2.5/src/core/StashService.ts b/ui/v2.5/src/core/StashService.ts index 949a061c122..1685e92dcfb 100644 --- a/ui/v2.5/src/core/StashService.ts +++ b/ui/v2.5/src/core/StashService.ts @@ -275,6 +275,10 @@ export const useFindGallery = (id: string) => { return GQL.useFindGalleryQuery({ variables: { id }, skip }); }; +export const useFindGalleryImageID = (id: string, index: number) => { + return GQL.useFindGalleryImageIdQuery({ variables: { id, index } }); +}; + export const useFindGalleries = (filter?: ListFilterModel) => GQL.useFindGalleriesQuery({ skip: filter === undefined, diff --git a/ui/v2.5/src/index.scss b/ui/v2.5/src/index.scss index c277e864ac1..a2dce6acb92 100755 --- a/ui/v2.5/src/index.scss +++ b/ui/v2.5/src/index.scss @@ -500,7 +500,7 @@ textarea.text-input { .zoom-0 { .gallery-card-image, .tag-card-image { - max-height: 180px; + height: 180px; } } @@ -509,7 +509,7 @@ textarea.text-input { .gallery-card-image, .tag-card-image { - max-height: 240px; + height: 240px; } .image-card-preview { @@ -520,7 +520,7 @@ textarea.text-input { .zoom-2 { .gallery-card-image, .tag-card-image { - max-height: 360px; + height: 360px; } .image-card-preview { @@ -531,7 +531,7 @@ textarea.text-input { .zoom-3 { .tag-card-image, .gallery-card-image { - max-height: 480px; + height: 480px; } .image-card-preview { From 10341fba58bead2f236fa36f931514eb85944988 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Wed, 28 Aug 2024 09:00:14 +1000 Subject: [PATCH 079/103] Update builtin freeones scraper (#5171) --- pkg/scraper/freeones.go | 55 ++++++++++++++++++++++------------------- 1 file changed, 30 insertions(+), 25 deletions(-) diff --git a/pkg/scraper/freeones.go b/pkg/scraper/freeones.go index 9a8eb4859c9..4b414541dd2 100644 --- a/pkg/scraper/freeones.go +++ b/pkg/scraper/freeones.go @@ -42,44 +42,48 @@ xPathScrapers: selector: //h1 postProcess: - replace: - - regex: \sBio\s*$ - with: "" + - regex: (.+)\sidentifies.+ + with: $1 URL: //link[@rel="alternate" and @hreflang="x-default"]/@href - Twitter: //a[not(starts-with(@href,'https://twitter.com/FreeOnes'))][contains(@href,'twitter.com/')]/@href - Instagram: //a[contains(@href,'instagram.com/')]/@href + Twitter: //form//a[contains(@href,'twitter.com/')]/@href + Instagram: //form//a[contains(@href,'instagram.com/')]/@href Birthdate: - selector: //span[contains(text(),'Born On')] + selector: //span[@data-test="link_span_dateOfBirth"]/text() postProcess: - - replace: - - regex: Born On - with: - parseDate: January 2, 2006 Ethnicity: - selector: //a[@data-test="link_ethnicity"]/span/text() + selector: //span[@data-test="link_span_ethnicity"] postProcess: - map: Asian: Asian Caucasian: White Black: Black Latin: Hispanic - Country: //a[@data-test="link-country"]/span/text() - EyeColor: //span[text()='Eye Color']/following-sibling::span/a + Country: + selector: //a[@data-test="link_placeOfBirth"][contains(@href, 'country')]/span/text() + postProcess: + - map: + United States: "USA" + EyeColor: //span[text()='Eye Color:']/following-sibling::span/a/span/text() Height: - selector: //span[text()='Height']/following-sibling::span/a + selector: //span[text()='Height:']/following-sibling::span/a postProcess: - replace: - - regex: \D+[\s\S]+ - with: "" + - regex: \scm + with: "" - map: Unknown: "" Measurements: - selector: //span[text()='Measurements']/following-sibling::span/span/a + selector: //span[(@data-test='link_span_bra') or (@data-test='link_span_waist') or (@data-test='link_span_hip')] concat: " - " postProcess: + - replace: + - regex: \sIn + with: "" - map: Unknown: "" FakeTits: - selector: //span[text()='Boobs']/following-sibling::span/a + selector: //span[text()='Boobs:']/following-sibling::span/a postProcess: - map: Unknown: "" @@ -88,14 +92,16 @@ xPathScrapers: CareerLength: selector: //div[contains(@class,'timeline-horizontal')]//p[@class='m-0'] concat: "-" - Aliases: //p[@data-test='p_aliases']/text() + Aliases: + selector: //span[@data-test='link_span_aliases']/text() + concat: ", " Tattoos: - selector: //span[text()='Tattoos']/following-sibling::span/span + selector: //span[text()='Tattoo locations:']/following-sibling::span postProcess: - map: Unknown: "" Piercings: - selector: //span[text()='Piercings']/following-sibling::span/span + selector: //span[text()='Piercing locations:']/following-sibling::span postProcess: - map: Unknown: "" @@ -103,7 +109,6 @@ xPathScrapers: selector: //div[contains(@class,'image-container')]//a/img/@src Gender: fixed: "Female" - Details: //div[@data-test="biography"] DeathDate: selector: //div[contains(text(),'Passed away on')] postProcess: @@ -111,15 +116,15 @@ xPathScrapers: - regex: Passed away on (.+) at the age of \d+ with: $1 - parseDate: January 2, 2006 - HairColor: //span[text()='Hair Color']/following-sibling::span/a + HairColor: //span[@data-test="link_span_hair_color"] Weight: - selector: //span[text()='Weight']/following-sibling::span/a + selector: //span[@data-test="link_span_weight"] postProcess: - - replace: - - regex: \D+[\s\S]+ + - replace: + - regex: \skg with: "" -# Last updated April 13, 2021 +# Last Updated January 2, 2024 ` func getFreeonesScraper(globalConfig GlobalConfig) scraper { From b7799df2a6f8f19aeee2307c9f1bd7a6995118c1 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Wed, 28 Aug 2024 09:01:39 +1000 Subject: [PATCH 080/103] Add package docs and project vision/goals (#5169) * Add goals/design vision to contributing doc * Add barebones package documentation --- docs/CONTRIBUTING.md | 21 +++++++++++++++++++++ internal/api/doc.go | 2 ++ internal/api/loaders/dataloaders.go | 4 ++++ internal/api/server.go | 7 ++++++- internal/api/urlbuilders/doc.go | 2 ++ internal/autotag/doc.go | 9 +++++++++ internal/build/version.go | 1 + internal/desktop/desktop.go | 1 + internal/dlna/doc.go | 3 +++ internal/identify/identify.go | 3 +++ internal/log/logger.go | 1 + internal/manager/manager.go | 2 ++ internal/static/embed.go | 1 + pkg/file/file.go | 1 + pkg/fsutil/fs.go | 1 + pkg/gallery/service.go | 2 ++ pkg/group/doc.go | 2 ++ pkg/image/service.go | 2 ++ pkg/javascript/vm.go | 7 ++----- pkg/job/job.go | 1 + pkg/match/path.go | 1 + pkg/models/doc.go | 2 ++ pkg/models/json/json_time.go | 1 + pkg/models/jsonschema/doc.go | 2 ++ pkg/models/mocks/database.go | 1 + pkg/models/paths/paths.go | 1 + pkg/performer/doc.go | 2 ++ pkg/pkg/pkg.go | 1 + pkg/python/exec.go | 1 + pkg/scene/generate/generator.go | 1 + pkg/scene/service.go | 2 ++ pkg/scraper/scraper.go | 2 ++ pkg/scraper/stashbox/stash_box.go | 1 + pkg/session/session.go | 1 + pkg/sliceutil/collections.go | 1 + pkg/sqlite/doc.go | 2 ++ pkg/studio/doc.go | 2 ++ pkg/tag/doc.go | 2 ++ pkg/txn/transaction.go | 1 + pkg/utils/doc.go | 2 ++ 40 files changed, 96 insertions(+), 6 deletions(-) create mode 100644 internal/api/doc.go create mode 100644 internal/api/urlbuilders/doc.go create mode 100644 internal/autotag/doc.go create mode 100644 internal/dlna/doc.go create mode 100644 pkg/group/doc.go create mode 100644 pkg/models/doc.go create mode 100644 pkg/models/jsonschema/doc.go create mode 100644 pkg/performer/doc.go create mode 100644 pkg/sqlite/doc.go create mode 100644 pkg/studio/doc.go create mode 100644 pkg/tag/doc.go create mode 100644 pkg/utils/doc.go diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index faf956d8f2b..5195dc34608 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -1,3 +1,24 @@ +## Goals and design vision + +The goal of stash is to be: +- an application for organising and viewing adult content - currently this is videos and images, in future this will be extended to include audio and text content + - organising includes scraping of metadata from websites and metadata repositories +- free and open-source +- portable and offline - can be run on a USB stick without needing to install dependencies (with the exception of ffmpeg) +- minimal, but highly extensible. The core feature set should be the minimum required to achieve the primary goal, while being extensible enough to extend via plugins +- easy to learn and use, with minimal technical knowledge required + +The core stash system is not intended for: +- managing downloading of content +- managing content on external websites +- publically sharing content + +Other requirements: +- support as many video and image formats as possible +- interfaces with external systems (for example stash-box) should be made as generic as possible. + +Design considerations: +- features are easy to add and difficult to remove. Large superfluous features should be scrutinised and avoided where possible (eg DLNA, filename parser). Such features should be considered for third-party plugins instead. ## Technical Debt Please be sure to consider how heavily your contribution impacts the maintainability of the project long term, sometimes less is more. We don't want to merge collossal pull requests with hundreds of dependencies by a driveby contributor. diff --git a/internal/api/doc.go b/internal/api/doc.go new file mode 100644 index 00000000000..a0498359ad7 --- /dev/null +++ b/internal/api/doc.go @@ -0,0 +1,2 @@ +// Package api provides the HTTP and Graphql API for the application. +package api diff --git a/internal/api/loaders/dataloaders.go b/internal/api/loaders/dataloaders.go index d1b13db6991..fca3e6c1842 100644 --- a/internal/api/loaders/dataloaders.go +++ b/internal/api/loaders/dataloaders.go @@ -1,3 +1,7 @@ +// Package loaders contains the dataloaders used by the resolver in [api]. +// They are generated with `make generate-dataloaders`. +// The dataloaders are used to batch requests to the database. + //go:generate go run github.com/vektah/dataloaden SceneLoader int *github.com/stashapp/stash/pkg/models.Scene //go:generate go run github.com/vektah/dataloaden GalleryLoader int *github.com/stashapp/stash/pkg/models.Gallery //go:generate go run github.com/vektah/dataloaden ImageLoader int *github.com/stashapp/stash/pkg/models.Image diff --git a/internal/api/server.go b/internal/api/server.go index 1ddf1baef44..b32ee04a027 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -75,7 +75,8 @@ func (dir osFS) Open(name string) (fs.File, error) { return os.DirFS(string(dir)).Open(name) } -// Called at startup +// Initialize creates a new [Server] instance. +// It assumes that the [manager.Manager] instance has been initialised. func Initialize() (*Server, error) { mgr := manager.GetInstance() cfg := mgr.Config @@ -289,6 +290,9 @@ func Initialize() (*Server, error) { return server, nil } +// Start starts the server. It listens on the configured address and port. +// It calls ListenAndServeTLS if TLS is configured, otherwise it calls ListenAndServe. +// Calls to Start are blocked until the server is shutdown. func (s *Server) Start() error { logger.Infof("stash is listening on " + s.Addr) logger.Infof("stash is running at " + s.displayAddress) @@ -300,6 +304,7 @@ func (s *Server) Start() error { } } +// Shutdown gracefully shuts down the server without interrupting any active connections. func (s *Server) Shutdown() { err := s.Server.Shutdown(context.TODO()) if err != nil { diff --git a/internal/api/urlbuilders/doc.go b/internal/api/urlbuilders/doc.go new file mode 100644 index 00000000000..636ec50f19b --- /dev/null +++ b/internal/api/urlbuilders/doc.go @@ -0,0 +1,2 @@ +// Package urlbuilders provides the builders used to build URLs to pass to clients. +package urlbuilders diff --git a/internal/autotag/doc.go b/internal/autotag/doc.go new file mode 100644 index 00000000000..a495949e8b6 --- /dev/null +++ b/internal/autotag/doc.go @@ -0,0 +1,9 @@ +// Package autotag provides the autotagging functionality for the application. +// +// The autotag functionality sets media metadata based on the media's path. +// The functions in this package are in the form of {ObjectType}{TagTypes}, +// where the ObjectType is the single object instance to run on, and TagTypes +// are the related types. +// For example, PerformerScenes finds and tags scenes with a provided performer, +// whereas ScenePerformers tags a single scene with any Performers that match. +package autotag diff --git a/internal/build/version.go b/internal/build/version.go index 84c5f819f4f..ecccd970372 100644 --- a/internal/build/version.go +++ b/internal/build/version.go @@ -1,3 +1,4 @@ +// Package build provides the version information for the application. package build import ( diff --git a/internal/desktop/desktop.go b/internal/desktop/desktop.go index b5a261bf7a9..a89a3c96256 100644 --- a/internal/desktop/desktop.go +++ b/internal/desktop/desktop.go @@ -1,3 +1,4 @@ +// Package desktop provides desktop integration functionality for the application. package desktop import ( diff --git a/internal/dlna/doc.go b/internal/dlna/doc.go new file mode 100644 index 00000000000..b5955c349f4 --- /dev/null +++ b/internal/dlna/doc.go @@ -0,0 +1,3 @@ +// Package dlna provides the DLNA functionality for the application. +// Much of this code is adapted from https://github.com/anacrolix/dms +package dlna diff --git a/internal/identify/identify.go b/internal/identify/identify.go index 43e1dedf359..5eecd0d9927 100644 --- a/internal/identify/identify.go +++ b/internal/identify/identify.go @@ -1,3 +1,6 @@ +// Package identify provides the scene identification functionality for the application. +// The identify functionality uses scene scrapers to identify a given scene and +// set its metadata based on the scraped data. package identify import ( diff --git a/internal/log/logger.go b/internal/log/logger.go index 50f5a42b472..5f686d32d5d 100644 --- a/internal/log/logger.go +++ b/internal/log/logger.go @@ -1,3 +1,4 @@ +// Package log provides an implementation of [logger.LoggerImpl], using logrus. package log import ( diff --git a/internal/manager/manager.go b/internal/manager/manager.go index 7032c3329fc..397503930dc 100644 --- a/internal/manager/manager.go +++ b/internal/manager/manager.go @@ -1,3 +1,5 @@ +// Package manager provides the core manager of the application. +// This consolidates all the services and managers into a single struct. package manager import ( diff --git a/internal/static/embed.go b/internal/static/embed.go index 3c9323a7073..38614ae7903 100644 --- a/internal/static/embed.go +++ b/internal/static/embed.go @@ -1,3 +1,4 @@ +// Package static provides the static files embedded in the application. package static import ( diff --git a/pkg/file/file.go b/pkg/file/file.go index 72c7f8a1ab4..407949ba127 100644 --- a/pkg/file/file.go +++ b/pkg/file/file.go @@ -1,3 +1,4 @@ +// Package file provides functionality for managing, scanning and cleaning files and folders. package file import ( diff --git a/pkg/fsutil/fs.go b/pkg/fsutil/fs.go index 0b9fc641629..2b5c37f62b8 100644 --- a/pkg/fsutil/fs.go +++ b/pkg/fsutil/fs.go @@ -1,3 +1,4 @@ +// Package fsutil provides filesystem utility functions for the application. package fsutil import ( diff --git a/pkg/gallery/service.go b/pkg/gallery/service.go index 6db604fc4d6..a764e982c60 100644 --- a/pkg/gallery/service.go +++ b/pkg/gallery/service.go @@ -1,3 +1,5 @@ +// Package gallery provides application logic for managing galleries. +// This functionality is exposed via the [Service] type. package gallery import ( diff --git a/pkg/group/doc.go b/pkg/group/doc.go new file mode 100644 index 00000000000..754f043fc1e --- /dev/null +++ b/pkg/group/doc.go @@ -0,0 +1,2 @@ +// Package group provides the application logic for groups. +package group diff --git a/pkg/image/service.go b/pkg/image/service.go index 55dc7686d1c..cb0b24ec320 100644 --- a/pkg/image/service.go +++ b/pkg/image/service.go @@ -1,3 +1,5 @@ +// Package image provides the application logic for images. +// The functionality is exposed via the [Service] type. package image import ( diff --git a/pkg/javascript/vm.go b/pkg/javascript/vm.go index 556edb1d6cc..2ac4b59dc1f 100644 --- a/pkg/javascript/vm.go +++ b/pkg/javascript/vm.go @@ -1,8 +1,8 @@ +// Package javascript provides the javascript runtime for the application. package javascript import ( "fmt" - "net/http" "os" "reflect" @@ -10,12 +10,9 @@ import ( "github.com/stashapp/stash/pkg/logger" ) +// VM is a wrapper around goja.Runtime. type VM struct { *goja.Runtime - - Progress chan float64 - SessionCookie *http.Cookie - GQLHandler http.Handler } // optionalFieldNameMapper wraps a goja.FieldNameMapper and returns the field name if the wrapped mapper returns an empty string. diff --git a/pkg/job/job.go b/pkg/job/job.go index fa1ef3c912b..48b5e7b136e 100644 --- a/pkg/job/job.go +++ b/pkg/job/job.go @@ -1,3 +1,4 @@ +// Package job provides the job execution and management functionality for the application. package job import ( diff --git a/pkg/match/path.go b/pkg/match/path.go index 171d9a5308a..1755e701262 100644 --- a/pkg/match/path.go +++ b/pkg/match/path.go @@ -1,3 +1,4 @@ +// Package match provides functions for matching paths to models. package match import ( diff --git a/pkg/models/doc.go b/pkg/models/doc.go new file mode 100644 index 00000000000..515f5775f64 --- /dev/null +++ b/pkg/models/doc.go @@ -0,0 +1,2 @@ +// Package models provides application models that are used throughout the application. +package models diff --git a/pkg/models/json/json_time.go b/pkg/models/json/json_time.go index 134bc69c6fb..20ef9b4426f 100644 --- a/pkg/models/json/json_time.go +++ b/pkg/models/json/json_time.go @@ -1,3 +1,4 @@ +// Package json provides generic JSON types. package json import ( diff --git a/pkg/models/jsonschema/doc.go b/pkg/models/jsonschema/doc.go new file mode 100644 index 00000000000..d19852ac10d --- /dev/null +++ b/pkg/models/jsonschema/doc.go @@ -0,0 +1,2 @@ +// Package jsonschema provides the JSON schema models used for importing and exporting data. +package jsonschema diff --git a/pkg/models/mocks/database.go b/pkg/models/mocks/database.go index 1892992108c..ec4177b305b 100644 --- a/pkg/models/mocks/database.go +++ b/pkg/models/mocks/database.go @@ -1,3 +1,4 @@ +// Package mocks provides mocks for various interfaces in [models]. package mocks import ( diff --git a/pkg/models/paths/paths.go b/pkg/models/paths/paths.go index ed35bca56ba..da72111cffb 100644 --- a/pkg/models/paths/paths.go +++ b/pkg/models/paths/paths.go @@ -1,3 +1,4 @@ +// Package paths provides functions to return paths to various resources. package paths import ( diff --git a/pkg/performer/doc.go b/pkg/performer/doc.go new file mode 100644 index 00000000000..67a36f88c4e --- /dev/null +++ b/pkg/performer/doc.go @@ -0,0 +1,2 @@ +// Package performer provides the application logic for performer functionality. +package performer diff --git a/pkg/pkg/pkg.go b/pkg/pkg/pkg.go index 7c2e734ef4b..51c35c3d763 100644 --- a/pkg/pkg/pkg.go +++ b/pkg/pkg/pkg.go @@ -1,3 +1,4 @@ +// Package pkg provides interfaces to interact with the package system used for plugins and scrapers. package pkg import ( diff --git a/pkg/python/exec.go b/pkg/python/exec.go index 68fd18c88f9..09863529460 100644 --- a/pkg/python/exec.go +++ b/pkg/python/exec.go @@ -1,3 +1,4 @@ +// Package python provides utilities for working with the python executable. package python import ( diff --git a/pkg/scene/generate/generator.go b/pkg/scene/generate/generator.go index 70f6857ea5d..7e5705679d1 100644 --- a/pkg/scene/generate/generator.go +++ b/pkg/scene/generate/generator.go @@ -1,3 +1,4 @@ +// Package generate provides functions to generate media assets from scenes. package generate import ( diff --git a/pkg/scene/service.go b/pkg/scene/service.go index 05fa9f532eb..f5a117309c7 100644 --- a/pkg/scene/service.go +++ b/pkg/scene/service.go @@ -1,3 +1,5 @@ +// Package scene provides the application logic for scene functionality. +// Most functionality is provided by [Service]. package scene import ( diff --git a/pkg/scraper/scraper.go b/pkg/scraper/scraper.go index 1e814bd8ad1..56c8f007398 100644 --- a/pkg/scraper/scraper.go +++ b/pkg/scraper/scraper.go @@ -1,3 +1,5 @@ +// Package scraper provides interfaces to interact with the scraper subsystem. +// The [Cache] type is the main entry point to the scraper subsystem. package scraper import ( diff --git a/pkg/scraper/stashbox/stash_box.go b/pkg/scraper/stashbox/stash_box.go index 1432eef1729..0b0cf68d67e 100644 --- a/pkg/scraper/stashbox/stash_box.go +++ b/pkg/scraper/stashbox/stash_box.go @@ -1,3 +1,4 @@ +// Package stashbox provides a client interface to a stash-box server instance. package stashbox import ( diff --git a/pkg/session/session.go b/pkg/session/session.go index 285c7cc3c7a..66cb39e0923 100644 --- a/pkg/session/session.go +++ b/pkg/session/session.go @@ -1,3 +1,4 @@ +// Package session provides session authentication and management for the application. package session import ( diff --git a/pkg/sliceutil/collections.go b/pkg/sliceutil/collections.go index 81a3deba37c..bd4070cdc94 100644 --- a/pkg/sliceutil/collections.go +++ b/pkg/sliceutil/collections.go @@ -1,3 +1,4 @@ +// Package sliceutil provides utilities for working with slices. package sliceutil // Index returns the first index of the provided value in the provided diff --git a/pkg/sqlite/doc.go b/pkg/sqlite/doc.go new file mode 100644 index 00000000000..36472189627 --- /dev/null +++ b/pkg/sqlite/doc.go @@ -0,0 +1,2 @@ +// Package sqlite provides interfaces to interact with the sqlite database. +package sqlite diff --git a/pkg/studio/doc.go b/pkg/studio/doc.go new file mode 100644 index 00000000000..72c429c5771 --- /dev/null +++ b/pkg/studio/doc.go @@ -0,0 +1,2 @@ +// Package studio provides the application logic for studio functionality. +package studio diff --git a/pkg/tag/doc.go b/pkg/tag/doc.go new file mode 100644 index 00000000000..604ab88c147 --- /dev/null +++ b/pkg/tag/doc.go @@ -0,0 +1,2 @@ +// Package tag provides application logic for tag objects. +package tag diff --git a/pkg/txn/transaction.go b/pkg/txn/transaction.go index 751588eff0b..b8d0aa8300c 100644 --- a/pkg/txn/transaction.go +++ b/pkg/txn/transaction.go @@ -1,3 +1,4 @@ +// Package txn provides functions for running transactions. package txn import ( diff --git a/pkg/utils/doc.go b/pkg/utils/doc.go new file mode 100644 index 00000000000..2ea42ced311 --- /dev/null +++ b/pkg/utils/doc.go @@ -0,0 +1,2 @@ +// Package utils provides various utility functions for the application. +package utils From ca55f96fd87f2bfa51ea3d9dea80b54bffcda927 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Wed, 28 Aug 2024 09:02:52 +1000 Subject: [PATCH 081/103] Replace group image with more consistent svg (#5170) --- internal/static/embed.go | 2 +- internal/static/group/group.png | Bin 405 -> 0 bytes internal/static/group/group.svg | 6 ++++++ 3 files changed, 7 insertions(+), 1 deletion(-) delete mode 100644 internal/static/group/group.png create mode 100644 internal/static/group/group.svg diff --git a/internal/static/embed.go b/internal/static/embed.go index 38614ae7903..67125d8266d 100644 --- a/internal/static/embed.go +++ b/internal/static/embed.go @@ -28,7 +28,7 @@ const ( DefaultStudioImage = "studio/studio.svg" Group = "group" - DefaultGroupImage = "group/group.png" + DefaultGroupImage = "group/group.svg" ) // Sub returns an FS rooted at path, using fs.Sub. diff --git a/internal/static/group/group.png b/internal/static/group/group.png deleted file mode 100644 index 0bb8b00a6cfa5999b5d1218a348a42d31754c3c0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 405 zcmeAS@N?(olHy`uVBq!ia0vp^DImWxe4W7D@5exIwKcl6`5 pnt7hzAcnHAbige@WAA&#_T0*4(eYz@nScSv;OXk;vd$@?2>^{`g)#sD diff --git a/internal/static/group/group.svg b/internal/static/group/group.svg new file mode 100644 index 00000000000..2b9f0dcbdcc --- /dev/null +++ b/internal/static/group/group.svg @@ -0,0 +1,6 @@ + + + \ No newline at end of file From c74456c07e1cce1140cce8633882c31b521c2e9b Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Wed, 28 Aug 2024 09:07:49 +1000 Subject: [PATCH 082/103] Bump linux ffmpeg URL for latest version (#5172) --- pkg/ffmpeg/downloader.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/ffmpeg/downloader.go b/pkg/ffmpeg/downloader.go index 407f38a3f0a..2498c84418e 100644 --- a/pkg/ffmpeg/downloader.go +++ b/pkg/ffmpeg/downloader.go @@ -12,11 +12,11 @@ func GetFFmpegURL() []string { case "linux": switch runtime.GOARCH { case "amd64": - urls = []string{"https://github.com/ffbinaries/ffbinaries-prebuilt/releases/download/v4.2.1/ffmpeg-4.2.1-linux-64.zip", "https://github.com/ffbinaries/ffbinaries-prebuilt/releases/download/v4.2.1/ffprobe-4.2.1-linux-64.zip"} + urls = []string{"https://github.com/ffbinaries/ffbinaries-prebuilt/releases/download/v6.1/ffmpeg-6.1-linux-64.zip", "https://github.com/ffbinaries/ffbinaries-prebuilt/releases/download/v6.1/ffprobe-6.1-linux-64.zip"} case "arm": - urls = []string{"https://github.com/ffbinaries/ffbinaries-prebuilt/releases/download/v4.2.1/ffmpeg-4.2.1-linux-armhf-32.zip", "https://github.com/ffbinaries/ffbinaries-prebuilt/releases/download/v4.2.1/ffprobe-4.2.1-linux-armhf-32.zip"} + urls = []string{"https://github.com/ffbinaries/ffbinaries-prebuilt/releases/download/v6.1/ffmpeg-6.1-linux-armhf-32.zip", "https://github.com/ffbinaries/ffbinaries-prebuilt/releases/download/v6.1/ffprobe-6.1-linux-armhf-32.zip"} case "arm64": - urls = []string{"https://github.com/ffbinaries/ffbinaries-prebuilt/releases/download/v4.2.1/ffmpeg-4.2.1-linux-arm-64.zip", "https://github.com/ffbinaries/ffbinaries-prebuilt/releases/download/v4.2.1/ffprobe-4.2.1-linux-arm-64.zip"} + urls = []string{"https://github.com/ffbinaries/ffbinaries-prebuilt/releases/download/v6.1/ffmpeg-6.1-linux-arm-64.zip", "https://github.com/ffbinaries/ffbinaries-prebuilt/releases/download/v6.1/ffprobe-6.1-linux-arm-64.zip"} } case "windows": urls = []string{"https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip"} From b1b223c90accfe8d8caa92de2fb90dfef028646e Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Wed, 28 Aug 2024 09:19:50 +1000 Subject: [PATCH 083/103] Persist tagger settings and change defaults (#5165) * Persist tagger settings in UIConfig * Show males and set tags by default * Add release note --- ui/v2.5/src/components/Tagger/config.ts | 20 +++++++++++++++++++ ui/v2.5/src/components/Tagger/constants.ts | 5 ++--- ui/v2.5/src/components/Tagger/context.tsx | 15 ++++---------- .../Tagger/performers/PerformerTagger.tsx | 9 +++------ .../Tagger/studios/StudioTagger.tsx | 9 +++------ ui/v2.5/src/core/config.ts | 3 +++ ui/v2.5/src/docs/en/ReleaseNotes/index.ts | 6 ++++++ ui/v2.5/src/docs/en/ReleaseNotes/v0270.md | 1 + 8 files changed, 42 insertions(+), 26 deletions(-) create mode 100644 ui/v2.5/src/components/Tagger/config.ts create mode 100644 ui/v2.5/src/docs/en/ReleaseNotes/v0270.md diff --git a/ui/v2.5/src/components/Tagger/config.ts b/ui/v2.5/src/components/Tagger/config.ts new file mode 100644 index 00000000000..78515f550f6 --- /dev/null +++ b/ui/v2.5/src/components/Tagger/config.ts @@ -0,0 +1,20 @@ +import { useCallback, useContext } from "react"; +import { ConfigurationContext } from "src/hooks/Config"; +import { initialConfig, ITaggerConfig } from "./constants"; +import { useConfigureUISetting } from "src/core/StashService"; + +export function useTaggerConfig() { + const { configuration: stashConfig } = useContext(ConfigurationContext); + const [saveUISetting] = useConfigureUISetting(); + + const config = stashConfig?.ui.taggerConfig ?? initialConfig; + + const setConfig = useCallback( + (c: ITaggerConfig) => { + saveUISetting({ variables: { key: "taggerConfig", value: c } }); + }, + [saveUISetting] + ); + + return { config, setConfig }; +} diff --git a/ui/v2.5/src/components/Tagger/constants.ts b/ui/v2.5/src/components/Tagger/constants.ts index cecbdeb1b81..55cc5cb1c5d 100644 --- a/ui/v2.5/src/components/Tagger/constants.ts +++ b/ui/v2.5/src/components/Tagger/constants.ts @@ -11,7 +11,6 @@ export interface ITaggerSource { supportSceneFragment?: boolean; } -export const LOCAL_FORAGE_KEY = "tagger"; export const DEFAULT_BLACKLIST = [ "\\sXXX\\s", "1080p", @@ -28,10 +27,10 @@ export const DEFAULT_EXCLUDED_STUDIO_FIELDS = ["name"]; export const initialConfig: ITaggerConfig = { blacklist: DEFAULT_BLACKLIST, - showMales: false, + showMales: true, mode: "auto", setCoverImage: true, - setTags: false, + setTags: true, tagOperation: "merge", fingerprintQueue: {}, excludedPerformerFields: DEFAULT_EXCLUDED_PERFORMER_FIELDS, diff --git a/ui/v2.5/src/components/Tagger/context.tsx b/ui/v2.5/src/components/Tagger/context.tsx index 832d9335999..dc35208c5f2 100644 --- a/ui/v2.5/src/components/Tagger/context.tsx +++ b/ui/v2.5/src/components/Tagger/context.tsx @@ -1,9 +1,5 @@ import React, { useState, useEffect, useRef } from "react"; -import { - initialConfig, - ITaggerConfig, - LOCAL_FORAGE_KEY, -} from "src/components/Tagger/constants"; +import { initialConfig, ITaggerConfig } from "src/components/Tagger/constants"; import * as GQL from "src/core/generated-graphql"; import { queryFindPerformer, @@ -20,12 +16,12 @@ import { useStudioUpdate, useTagCreate, } from "src/core/StashService"; -import { useLocalForage } from "src/hooks/LocalForage"; import { useToast } from "src/hooks/Toast"; import { ConfigurationContext } from "src/hooks/Config"; import { ITaggerSource, SCRAPER_PREFIX, STASH_BOX_PREFIX } from "./constants"; import { errorToString } from "src/utils"; import { mergeStudioStashIDs } from "./utils"; +import { useTaggerConfig } from "./config"; export interface ITaggerContextState { config: ITaggerConfig; @@ -110,11 +106,6 @@ export interface ISceneQueryResult { } export const TaggerContext: React.FC = ({ children }) => { - const [{ data: config }, setConfig] = useLocalForage( - LOCAL_FORAGE_KEY, - initialConfig - ); - const [loading, setLoading] = useState(false); const [loadingMulti, setLoadingMulti] = useState(false); const [sources, setSources] = useState([]); @@ -127,6 +118,8 @@ export const TaggerContext: React.FC = ({ children }) => { const stopping = useRef(false); const { configuration: stashConfig } = React.useContext(ConfigurationContext); + const { config, setConfig } = useTaggerConfig(); + const Scrapers = useListSceneScrapers(); const Toast = useToast(); diff --git a/ui/v2.5/src/components/Tagger/performers/PerformerTagger.tsx b/ui/v2.5/src/components/Tagger/performers/PerformerTagger.tsx index 311d8007ad3..f0c87ff5723 100755 --- a/ui/v2.5/src/components/Tagger/performers/PerformerTagger.tsx +++ b/ui/v2.5/src/components/Tagger/performers/PerformerTagger.tsx @@ -3,7 +3,6 @@ import { Button, Card, Form, InputGroup, ProgressBar } from "react-bootstrap"; import { FormattedMessage, useIntl } from "react-intl"; import { Link } from "react-router-dom"; import { HashLink } from "react-router-hash-link"; -import { useLocalForage } from "src/hooks/LocalForage"; import * as GQL from "src/core/generated-graphql"; import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; @@ -21,12 +20,13 @@ import { ConfigurationContext } from "src/hooks/Config"; import StashSearchResult from "./StashSearchResult"; import PerformerConfig from "./Config"; -import { LOCAL_FORAGE_KEY, ITaggerConfig, initialConfig } from "../constants"; +import { ITaggerConfig } from "../constants"; import PerformerModal from "../PerformerModal"; import { useUpdatePerformer } from "../queries"; import { faStar, faTags } from "@fortawesome/free-solid-svg-icons"; import { mergeStashIDs } from "src/utils/stashbox"; import { ExternalLink } from "src/components/Shared/ExternalLink"; +import { useTaggerConfig } from "../config"; type JobFragment = Pick< GQL.Job, @@ -621,10 +621,7 @@ export const PerformerTagger: React.FC = ({ performers }) => { const jobsSubscribe = useJobsSubscribe(); const intl = useIntl(); const { configuration: stashConfig } = React.useContext(ConfigurationContext); - const [{ data: config }, setConfig] = useLocalForage( - LOCAL_FORAGE_KEY, - initialConfig - ); + const { config, setConfig } = useTaggerConfig(); const [showConfig, setShowConfig] = useState(false); const [showManual, setShowManual] = useState(false); diff --git a/ui/v2.5/src/components/Tagger/studios/StudioTagger.tsx b/ui/v2.5/src/components/Tagger/studios/StudioTagger.tsx index 41a5250c986..b8fbefdb57a 100644 --- a/ui/v2.5/src/components/Tagger/studios/StudioTagger.tsx +++ b/ui/v2.5/src/components/Tagger/studios/StudioTagger.tsx @@ -3,7 +3,6 @@ import { Button, Card, Form, InputGroup, ProgressBar } from "react-bootstrap"; import { FormattedMessage, useIntl } from "react-intl"; import { Link } from "react-router-dom"; import { HashLink } from "react-router-hash-link"; -import { useLocalForage } from "src/hooks/LocalForage"; import * as GQL from "src/core/generated-graphql"; import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; @@ -22,13 +21,14 @@ import { ConfigurationContext } from "src/hooks/Config"; import StashSearchResult from "./StashSearchResult"; import StudioConfig from "./Config"; -import { LOCAL_FORAGE_KEY, ITaggerConfig, initialConfig } from "../constants"; +import { ITaggerConfig } from "../constants"; import StudioModal from "../scenes/StudioModal"; import { useUpdateStudio } from "../queries"; import { apolloError } from "src/utils"; import { faStar, faTags } from "@fortawesome/free-solid-svg-icons"; import { ExternalLink } from "src/components/Shared/ExternalLink"; import { mergeStudioStashIDs } from "../utils"; +import { useTaggerConfig } from "../config"; type JobFragment = Pick< GQL.Job, @@ -670,10 +670,7 @@ export const StudioTagger: React.FC = ({ studios }) => { const jobsSubscribe = useJobsSubscribe(); const intl = useIntl(); const { configuration: stashConfig } = React.useContext(ConfigurationContext); - const [{ data: config }, setConfig] = useLocalForage( - LOCAL_FORAGE_KEY, - initialConfig - ); + const { config, setConfig } = useTaggerConfig(); const [showConfig, setShowConfig] = useState(false); const [showManual, setShowManual] = useState(false); diff --git a/ui/v2.5/src/core/config.ts b/ui/v2.5/src/core/config.ts index 213d8c11340..1b85ebe6dcb 100644 --- a/ui/v2.5/src/core/config.ts +++ b/ui/v2.5/src/core/config.ts @@ -8,6 +8,7 @@ import { SortDirectionEnum, } from "./generated-graphql"; import { View } from "src/components/List/views"; +import { ITaggerConfig } from "src/components/Tagger/constants"; // NOTE: double capitals aren't converted correctly in the backend @@ -97,6 +98,8 @@ export interface IUIConfig { taskDefaults?: Record; defaultFilters?: DefaultFilters; + + taggerConfig?: ITaggerConfig; } export function getFrontPageContent( diff --git a/ui/v2.5/src/docs/en/ReleaseNotes/index.ts b/ui/v2.5/src/docs/en/ReleaseNotes/index.ts index c3018eb1546..8e2f503d4dc 100644 --- a/ui/v2.5/src/docs/en/ReleaseNotes/index.ts +++ b/ui/v2.5/src/docs/en/ReleaseNotes/index.ts @@ -3,6 +3,7 @@ import v0200 from "./v0200.md"; import v0240 from "./v0240.md"; import v0250 from "./v0250.md"; import v0260 from "./v0260.md"; +import v0270 from "./v0270.md"; export interface IReleaseNotes { // handle should be in the form of YYYYMMDD @@ -12,6 +13,11 @@ export interface IReleaseNotes { } export const releaseNotes: IReleaseNotes[] = [ + { + date: 20240826, + version: "v0.27.0", + content: v0270, + }, { date: 20240510, version: "v0.26.0", diff --git a/ui/v2.5/src/docs/en/ReleaseNotes/v0270.md b/ui/v2.5/src/docs/en/ReleaseNotes/v0270.md new file mode 100644 index 00000000000..a8e45a47b18 --- /dev/null +++ b/ui/v2.5/src/docs/en/ReleaseNotes/v0270.md @@ -0,0 +1 @@ +Tagger settings have been reset, but are now persisted between browser sessions. `Show male performers` and `Set Tags` are now defaulted to true. Please verify your settings before using the Tagger. \ No newline at end of file From cdea9374d866948b37a91b1052563f597dbe165e Mon Sep 17 00:00:00 2001 From: Gykes Date: Tue, 27 Aug 2024 16:23:58 -0700 Subject: [PATCH 084/103] Standardizing the delete dropdown. (#5176) Changed to use "delete" rather than "delete_entity" --- ui/v2.5/src/components/Galleries/GalleryDetails/Gallery.tsx | 2 +- ui/v2.5/src/components/Images/ImageDetails/Image.tsx | 2 +- ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/ui/v2.5/src/components/Galleries/GalleryDetails/Gallery.tsx b/ui/v2.5/src/components/Galleries/GalleryDetails/Gallery.tsx index b272eb173bb..ae73f2a1ddb 100644 --- a/ui/v2.5/src/components/Galleries/GalleryDetails/Gallery.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryDetails/Gallery.tsx @@ -189,7 +189,7 @@ export const GalleryPage: React.FC = ({ gallery, add }) => { onClick={() => setIsDeleteAlertOpen(true)} > diff --git a/ui/v2.5/src/components/Images/ImageDetails/Image.tsx b/ui/v2.5/src/components/Images/ImageDetails/Image.tsx index d2254c5b7bd..0a77180ceaa 100644 --- a/ui/v2.5/src/components/Images/ImageDetails/Image.tsx +++ b/ui/v2.5/src/components/Images/ImageDetails/Image.tsx @@ -193,7 +193,7 @@ const ImagePage: React.FC = ({ image }) => { onClick={() => setIsDeleteAlertOpen(true)} > diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx index bc632df34e2..4a0c67ff126 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/Scene.tsx @@ -408,7 +408,7 @@ const ScenePage: React.FC = ({ onClick={() => setIsDeleteAlertOpen(true)} > From c69d72b2438d2d370948fecd13cf7af3d528e9fd Mon Sep 17 00:00:00 2001 From: Gykes Date: Tue, 27 Aug 2024 16:40:58 -0700 Subject: [PATCH 085/103] Add Overlay-duration to span (#5177) Adding overlay-duration so it has it's own dedicated class. Helps with theming/customization closes https://github.com/stashapp/stash/issues/4240 --- ui/v2.5/src/components/Scenes/SceneCard.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/ui/v2.5/src/components/Scenes/SceneCard.tsx b/ui/v2.5/src/components/Scenes/SceneCard.tsx index e2f773626c0..cbe3ee64e0c 100644 --- a/ui/v2.5/src/components/Scenes/SceneCard.tsx +++ b/ui/v2.5/src/components/Scenes/SceneCard.tsx @@ -373,9 +373,13 @@ const SceneCardImage = PatchComponent( ) : ( "" )} - {(file?.duration ?? 0) >= 1 - ? TextUtils.secondsToTimestamp(file?.duration ?? 0) - : ""} + {(file?.duration ?? 0) >= 1 ? ( + + {TextUtils.secondsToTimestamp(file?.duration ?? 0)} + + ) : ( + "" + )}
    ); } From 294e2090d05b32d309dbbd5496906b3aec14a555 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Wed, 28 Aug 2024 10:10:47 +1000 Subject: [PATCH 086/103] Scene player presentation improvements (#5145) * Show controls before video plays * Allow interaction with controls while displaying error * Source selector improvements Don't auto-play next source if manually selected. Don't remove errored sources * Show errored sources in different style --- .../components/ScenePlayer/source-selector.ts | 46 ++++++++++++++++--- .../src/components/ScenePlayer/styles.scss | 30 ++++++++++++ 2 files changed, 70 insertions(+), 6 deletions(-) diff --git a/ui/v2.5/src/components/ScenePlayer/source-selector.ts b/ui/v2.5/src/components/ScenePlayer/source-selector.ts index 3a8337b1369..7cf6cfd757d 100644 --- a/ui/v2.5/src/components/ScenePlayer/source-selector.ts +++ b/ui/v2.5/src/components/ScenePlayer/source-selector.ts @@ -2,6 +2,7 @@ import videojs, { VideoJsPlayer } from "video.js"; export interface ISource extends videojs.Tech.SourceObject { label?: string; + errored?: boolean; } class SourceMenuItem extends videojs.getComponent("MenuItem") { @@ -81,6 +82,22 @@ class SourceMenuButton extends videojs.getComponent("MenuButton") { return this.items; } + + setSelectedSource(source: ISource) { + this.selectedSource = source; + if (this.items === undefined) return; + + for (const item of this.items) { + item.selected(item.source === this.selectedSource); + } + } + + markSourceErrored(source: ISource) { + const item = this.items.find((i) => i.source.src === source.src); + if (item === undefined) return; + + item.addClass("vjs-source-menu-item-error"); + } } class SourceSelectorPlugin extends videojs.getPlugin("plugin") { @@ -90,6 +107,9 @@ class SourceSelectorPlugin extends videojs.getPlugin("plugin") { private cleanupTextTracks: HTMLTrackElement[] = []; private manualTextTracks: HTMLTrackElement[] = []; + // don't auto play next source if user manually selected a source + private manuallySelected = false; + constructor(player: VideoJsPlayer) { super(player); @@ -99,6 +119,8 @@ class SourceSelectorPlugin extends videojs.getPlugin("plugin") { this.selectedIndex = this.sources.findIndex((src) => src === source); if (this.selectedIndex === -1) return; + this.manuallySelected = true; + const loadSrc = this.sources[this.selectedIndex]; const currentTime = player.currentTime(); @@ -154,14 +176,26 @@ class SourceSelectorPlugin extends videojs.getPlugin("plugin") { const currentSource = player.currentSource() as ISource; console.log(`Source '${currentSource.label}' is unsupported`); - if (this.sources.length > 1) { - if (this.selectedIndex === -1) return; + // mark current source as errored + currentSource.errored = true; + this.menu.markSourceErrored(currentSource); + + // don't auto play next source if user manually selected a source + if (this.manuallySelected) { + return; + } - this.sources.splice(this.selectedIndex, 1); - const newSource = this.sources[0]; + // TODO - make auto play next source configurable + // try the next source in the list + if ( + this.selectedIndex !== -1 && + this.selectedIndex + 1 < this.sources.length + ) { + this.selectedIndex += 1; + const newSource = this.sources[this.selectedIndex]; console.log(`Trying next source in playlist: '${newSource.label}'`); - this.menu.setSources(this.sources); - this.selectedIndex = 0; + this.menu.setSelectedSource(newSource); + player.src(newSource); player.load(); player.play(); diff --git a/ui/v2.5/src/components/ScenePlayer/styles.scss b/ui/v2.5/src/components/ScenePlayer/styles.scss index 0878c77047e..e7a3e61ab65 100644 --- a/ui/v2.5/src/components/ScenePlayer/styles.scss +++ b/ui/v2.5/src/components/ScenePlayer/styles.scss @@ -39,6 +39,28 @@ $sceneTabWidth: 450px; position: absolute; width: 100%; + &:not(.vjs-has-started) .vjs-control-bar { + display: flex; + } + + // show controls even when an error is displayed + /* stylelint-disable declaration-no-important */ + &.vjs-error .vjs-control-bar { + display: flex !important; + } + /* stylelint-enable declaration-no-important */ + + // allow interaction with the controls when error is displayed + .vjs-error-display, + .vjs-error-display .vjs-modal-dialog-content { + position: static; + } + + // hide spinner when error is displayed + &.vjs-error .vjs-loading-spinner { + display: none; + } + .vjs-button { outline: none; } @@ -197,6 +219,14 @@ $sceneTabWidth: 450px; content: "\f110"; font-family: VideoJS; } + + .vjs-menu-item.vjs-source-menu-item-error:not(.vjs-selected) { + color: $text-muted; + } + + .vjs-menu-item.vjs-source-menu-item-error { + font-style: italic; + } } .vjs-vr-selector { From a023a86ca69d71b2b62d5bb9271e90008a3505e0 Mon Sep 17 00:00:00 2001 From: Gykes Date: Tue, 27 Aug 2024 17:20:16 -0700 Subject: [PATCH 087/103] Fix Studio Pluralization (#5161) Small bug fix so that if a studio only has 1 child studio then the correct pluralization is used. --- ui/v2.5/src/components/Studios/StudioCard.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/ui/v2.5/src/components/Studios/StudioCard.tsx b/ui/v2.5/src/components/Studios/StudioCard.tsx index e66316c7478..32748767528 100644 --- a/ui/v2.5/src/components/Studios/StudioCard.tsx +++ b/ui/v2.5/src/components/Studios/StudioCard.tsx @@ -58,7 +58,11 @@ function maybeRenderChildren(studio: GQL.StudioDataFragment) { values={{ children: ( - {studio.child_studios.length} studios + {studio.child_studios.length}  + ), }} From b3d6a8eeddac6129a5aca777aae2e67edd1b3ded Mon Sep 17 00:00:00 2001 From: Gykes Date: Tue, 27 Aug 2024 17:50:04 -0700 Subject: [PATCH 088/103] Removing Play Button With No File (#5141) * Remove Play Button With No File * Hide controls when there is no file --------- Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com> --- ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx | 2 +- ui/v2.5/src/components/ScenePlayer/styles.scss | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx b/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx index 88bda1c88a6..897ac870bf3 100644 --- a/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx +++ b/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx @@ -822,7 +822,7 @@ export const ScenePlayer: React.FC = ({ return (
    diff --git a/ui/v2.5/src/components/ScenePlayer/styles.scss b/ui/v2.5/src/components/ScenePlayer/styles.scss index e7a3e61ab65..1b4fc6b25fc 100644 --- a/ui/v2.5/src/components/ScenePlayer/styles.scss +++ b/ui/v2.5/src/components/ScenePlayer/styles.scss @@ -34,6 +34,13 @@ $sceneTabWidth: 450px; } } +.VideoPlayer.no-file .video-js { + .vjs-big-play-button, + .vjs-control-bar { + display: none; + } +} + .video-js { height: 100%; position: absolute; From 27aef4ac2e6d1e7a2532a91568805acf28c01379 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Wed, 28 Aug 2024 14:31:56 +1000 Subject: [PATCH 089/103] Update gqlgen and gqlparser dependencies (#5179) --- go.mod | 34 ++++++++++++++--------------- go.sum | 68 ++++++++++++++++++++++++++++++++++------------------------ 2 files changed, 57 insertions(+), 45 deletions(-) diff --git a/go.mod b/go.mod index 67a6f01838b..ddda8eec1ea 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/stashapp/stash go 1.22 require ( - github.com/99designs/gqlgen v0.17.2 + github.com/99designs/gqlgen v0.17.49 github.com/WithoutPants/sortorder v0.0.0-20230616003020-921c9ef69552 github.com/Yamashou/gqlgenc v0.0.6 github.com/anacrolix/dms v1.2.2 @@ -25,7 +25,7 @@ require ( github.com/gorilla/securecookie v1.1.1 github.com/gorilla/sessions v1.2.1 github.com/gorilla/websocket v1.5.0 - github.com/hashicorp/golang-lru/v2 v2.0.6 + github.com/hashicorp/golang-lru/v2 v2.0.7 github.com/jinzhu/copier v0.4.0 github.com/jmoiron/sqlx v1.4.0 github.com/json-iterator/go v1.1.12 @@ -42,19 +42,19 @@ require ( github.com/sirupsen/logrus v1.9.3 github.com/spf13/cast v1.6.0 github.com/spf13/pflag v1.0.5 - github.com/stretchr/testify v1.8.4 + github.com/stretchr/testify v1.9.0 github.com/tidwall/gjson v1.16.0 github.com/vearutop/statigz v1.4.0 github.com/vektah/dataloaden v0.3.0 - github.com/vektah/gqlparser/v2 v2.4.2 + github.com/vektah/gqlparser/v2 v2.5.16 github.com/vektra/mockery/v2 v2.10.0 github.com/xWTF/chardet v0.0.0-20230208095535-c780f2ac244e github.com/zencoder/go-dash/v3 v3.0.2 - golang.org/x/crypto v0.23.0 + golang.org/x/crypto v0.24.0 golang.org/x/image v0.18.0 - golang.org/x/net v0.25.0 - golang.org/x/sys v0.20.0 - golang.org/x/term v0.20.0 + golang.org/x/net v0.26.0 + golang.org/x/sys v0.21.0 + golang.org/x/term v0.21.0 golang.org/x/text v0.16.0 gopkg.in/guregu/null.v4 v4.0.0 gopkg.in/yaml.v2 v2.4.0 @@ -66,7 +66,7 @@ require ( github.com/asticode/go-astikit v0.20.0 // indirect github.com/asticode/go-astits v1.8.0 // indirect github.com/chromedp/sysutil v1.0.0 // indirect - github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect + github.com/cpuguy83/go-md2man/v2 v2.0.4 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/dlclark/regexp2 v1.7.0 // indirect github.com/fsnotify/fsnotify v1.6.0 // indirect @@ -76,17 +76,16 @@ require ( github.com/gobwas/ws v1.3.0 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/google/pprof v0.0.0-20230207041349-798e818bf904 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect - github.com/hashicorp/golang-lru v0.5.4 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/magiconair/properties v1.8.7 // indirect github.com/mailru/easyjson v0.7.7 // indirect - github.com/matryer/moq v0.2.3 // indirect github.com/mattn/go-colorable v0.1.13 // indirect - github.com/mattn/go-isatty v0.0.19 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect @@ -99,20 +98,21 @@ require ( github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rs/zerolog v1.30.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect + github.com/sosodev/duration v1.3.1 // indirect github.com/spf13/afero v1.9.5 // indirect github.com/spf13/cobra v1.7.0 // indirect github.com/spf13/jwalterweatherman v1.1.0 // indirect github.com/spf13/viper v1.16.0 // indirect - github.com/stretchr/objx v0.5.0 // indirect + github.com/stretchr/objx v0.5.2 // indirect github.com/subosito/gotenv v1.6.0 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect - github.com/urfave/cli/v2 v2.8.1 // indirect - github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect + github.com/urfave/cli/v2 v2.27.2 // indirect + github.com/xrash/smetrics v0.0.0-20240312152122-5f08fbb34913 // indirect go.uber.org/atomic v1.11.0 // indirect - golang.org/x/mod v0.17.0 // indirect + golang.org/x/mod v0.18.0 // indirect golang.org/x/sync v0.7.0 // indirect - golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect + golang.org/x/tools v0.22.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index b808cbd7421..6d6762c5442 100644 --- a/go.sum +++ b/go.sum @@ -51,14 +51,17 @@ cloud.google.com/go/storage v1.14.0/go.mod h1:GrKmX003DSIwi9o29oFT7YDnHYwZoctc3f dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= -github.com/99designs/gqlgen v0.17.2 h1:yczvlwMsfcVu/JtejqfrLwXuSP0yZFhmcss3caEvHw8= github.com/99designs/gqlgen v0.17.2/go.mod h1:K5fzLKwtph+FFgh9j7nFbRUdBKvTcGnsta51fsMTn3o= +github.com/99designs/gqlgen v0.17.49 h1:b3hNGexHd33fBSAd4NDT/c3NCcQzcAVkknhN9ym36YQ= +github.com/99designs/gqlgen v0.17.49/go.mod h1:tC8YFVZMed81x7UJ7ORUwXF4Kn6SXuucFqQBhN8+BU0= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= github.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60= github.com/DATA-DOG/go-sqlmock v1.5.0/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM= github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ= github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= +github.com/PuerkitoBio/goquery v1.9.2 h1:4/wZksC3KgkQw7SQgkKotmKljk0M6V8TUvA8Wb4yPeE= +github.com/PuerkitoBio/goquery v1.9.2/go.mod h1:GHPCaP0ODyyxqcNoFGYlAprUFH81NuRPd0GX3Zu2Mvk= github.com/RoaringBitmap/roaring v0.4.7/go.mod h1:8khRDP4HmeXns4xIj9oGrKSz7XTQiJx2zgh7AcNke4w= github.com/WithoutPants/sortorder v0.0.0-20230616003020-921c9ef69552 h1:eukVk+mGmbSZppLw8WJGpEUgMC570eb32y7FOsPW4Kc= github.com/WithoutPants/sortorder v0.0.0-20230616003020-921c9ef69552/go.mod h1:LKbO1i6L1lSlwWx4NHWVECxubHNKFz2YQoEMGXAFVy8= @@ -84,6 +87,8 @@ github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNg github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/cCs= github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= +github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss= +github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU= github.com/antchfx/htmlquery v1.3.0 h1:5I5yNFOVI+egyia5F2s/5Do2nFWxJz41Tr3DyfKD25E= github.com/antchfx/htmlquery v1.3.0/go.mod h1:zKPDVTMhfOmcwxheXUsx4rKJy8KEY/PU6eXr/2SebQ8= github.com/antchfx/xpath v1.2.3 h1:CCZWOzv5bAqjVv0offZ2LVgVYFbeldKQVuLNbViZdes= @@ -158,8 +163,9 @@ github.com/corona10/goimagehash v1.1.0 h1:teNMX/1e+Wn/AYSbLHX8mj+mF9r60R1kBeqE9M github.com/corona10/goimagehash v1.1.0/go.mod h1:VkvE0mLn84L4aF8vCb6mafVajEb6QYMHl2ZJLn0mOGI= github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= github.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= -github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w= github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/cpuguy83/go-md2man/v2 v2.0.4 h1:wfIWP927BUkWJb2NmU/kNDYIBTh/ziUX91+lVfRxZq4= +github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= @@ -326,6 +332,8 @@ github.com/google/pprof v0.0.0-20230207041349-798e818bf904 h1:4/hN5RUoecvl+RmJRE github.com/google/pprof v0.0.0-20230207041349-798e818bf904/go.mod h1:uglQLonpP8qtYCYyzA+8c/9qtqgA3qsXGYqCPKARAFg= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= github.com/googleapis/gax-go/v2 v2.1.0/go.mod h1:Q3nei7sK6ybPYH7twZdmQpAd1MKb7pfu6SK+H1/DsU0= @@ -375,10 +383,9 @@ github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/b github.com/hashicorp/go-version v1.1.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= -github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc= github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= -github.com/hashicorp/golang-lru/v2 v2.0.6 h1:3xi/Cafd1NaoEnS/yDssIiuVeDVywU0QdFGl3aQaQHM= -github.com/hashicorp/golang-lru/v2 v2.0.6/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= @@ -461,7 +468,6 @@ github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0V github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= -github.com/matryer/moq v0.2.3 h1:Q06vEqnBYjjfx5KKgHfYRKE/lvlRu+Nj+xodG4YdHnU= github.com/matryer/moq v0.2.3/go.mod h1:9RtPYjTnH1bSBIkpvtHkFN7nbWAnO7oRpdJkEIn6UtE= github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= @@ -477,8 +483,9 @@ github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOA github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= -github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-sqlite3 v1.14.7/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= @@ -590,8 +597,9 @@ github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46/go.mod h1:uAQ5P github.com/sagikazarmark/crypt v0.3.0/go.mod h1:uD/D+6UF4SrIR1uGEv7bBNkNqLGqUr43MRiaGWX1Nig= github.com/sagikazarmark/crypt v0.4.0/go.mod h1:ALv2SRj7GxYV4HO9elxH9nS6M9gW+xDNxqmyJ6RfDFM= github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= -github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= +github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= +github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= github.com/shurcooL/graphql v0.0.0-20181231061246-d48a9a75455f h1:tygelZueB1EtXkPI6mQ4o9DQ0+FKW41hTbunoXZCTqk= github.com/shurcooL/graphql v0.0.0-20181231061246-d48a9a75455f/go.mod h1:AuYgA5Kyo4c7HfUmvRGs/6rGlMMV/6B1bVnB9JxJEEg= github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= @@ -602,6 +610,8 @@ github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= 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/sosodev/duration v1.3.1 h1:qtHBDMQ6lvMQsL15g4aopM4HEfOaYuhWBw3NPTtlqq4= +github.com/sosodev/duration v1.3.1/go.mod h1:RQIBBX0+fMLc/D9+Jb/fwvVmo0eZvDDEERAikUR6SDg= github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= github.com/spf13/afero v1.3.3/go.mod h1:5KUK8ByomD5Ti5Artl0RtHeI5pTF7MIDuXL3yY520V4= github.com/spf13/afero v1.6.0/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I= @@ -625,8 +635,9 @@ github.com/spf13/viper v1.16.0/go.mod h1:yg78JgCJcbrQOvV9YLXgkLaZqUidkY9K+Dd1Fof github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.2.1/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= @@ -636,8 +647,9 @@ github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= @@ -651,23 +663,23 @@ github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhso github.com/tinylib/msgp v1.0.2/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE= github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM= github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI= -github.com/urfave/cli/v2 v2.8.1 h1:CGuYNZF9IKZY/rfBe3lJpccSoIY1ytfvmgQT90cNOl4= -github.com/urfave/cli/v2 v2.8.1/go.mod h1:Z41J9TPoffeoqP0Iza0YbAhGvymRdZAd2uPmZ5JxRdY= +github.com/urfave/cli/v2 v2.27.2 h1:6e0H+AkS+zDckwPCUrZkKX38mRaau4nL2uipkJpbkcI= +github.com/urfave/cli/v2 v2.27.2/go.mod h1:g0+79LmHHATl7DAcHO99smiR/T7uGLw84w8Y42x+4eM= github.com/vearutop/statigz v1.4.0 h1:RQL0KG3j/uyA/PFpHeZ/L6l2ta920/MxlOAIGEOuwmU= github.com/vearutop/statigz v1.4.0/go.mod h1:LYTolBLiz9oJISwiVKnOQoIwhO1LWX1A7OECawGS8XE= github.com/vektah/dataloaden v0.3.0 h1:ZfVN2QD6swgvp+tDqdH/OIT/wu3Dhu0cus0k5gIZS84= github.com/vektah/dataloaden v0.3.0/go.mod h1:/HUdMve7rvxZma+2ZELQeNh88+003LL7Pf/CZ089j8U= github.com/vektah/gqlparser/v2 v2.4.0/go.mod h1:flJWIR04IMQPGz+BXLrORkrARBxv/rtyIAFvd/MceW0= github.com/vektah/gqlparser/v2 v2.4.1/go.mod h1:flJWIR04IMQPGz+BXLrORkrARBxv/rtyIAFvd/MceW0= -github.com/vektah/gqlparser/v2 v2.4.2 h1:29TGc6QmhEUq5fll+2FPoTmhUhR65WEKN4VK/jo0OlM= -github.com/vektah/gqlparser/v2 v2.4.2/go.mod h1:flJWIR04IMQPGz+BXLrORkrARBxv/rtyIAFvd/MceW0= +github.com/vektah/gqlparser/v2 v2.5.16 h1:1gcmLTvs3JLKXckwCwlUagVn/IlV2bwqle0vJ0vy5p8= +github.com/vektah/gqlparser/v2 v2.5.16/go.mod h1:1lz1OeCqgQbQepsGxPVywrjdBHW2T08PUS3pJqepRww= github.com/vektra/mockery/v2 v2.10.0 h1:MiiQWxwdq7/ET6dCXLaJzSGEN17k758H7JHS9kOdiks= github.com/vektra/mockery/v2 v2.10.0/go.mod h1:m/WO2UzWzqgVX3nvqpRQq70I4Z7jbSCRhdmkgtp+Ab4= github.com/willf/bitset v1.1.9/go.mod h1:RjeCKbqT1RxIR/KWY6phxZiaY1IyutSBfGjNPySAYV4= github.com/xWTF/chardet v0.0.0-20230208095535-c780f2ac244e h1:GruPsb+44XvYAzuAgJW1d1WHqmcI73L2XSjsbx/eJZw= github.com/xWTF/chardet v0.0.0-20230208095535-c780f2ac244e/go.mod h1:wA8kQ8WFipMciY9WcWzqQgZordm/P7l8IZdvx1crwmc= -github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU= -github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8= +github.com/xrash/smetrics v0.0.0-20240312152122-5f08fbb34913 h1:+qGGcbkzsfDQNPPe9UDgpxAWQrhbbBXOYJFQDq/dtJw= +github.com/xrash/smetrics v0.0.0-20240312152122-5f08fbb34913/go.mod h1:4aEEwZQutDLsQv2Deui4iYQ6DWTxR14g6m8Wv88+Xqk= github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= @@ -714,8 +726,8 @@ golang.org/x/crypto v0.0.0-20211108221036-ceb1ce70b4fa/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.0.0-20211215165025-cf75a172585e/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= golang.org/x/crypto v0.0.0-20220112180741-5e0467b6c7ce/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI= -golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI= +golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -758,8 +770,8 @@ golang.org/x/mod v0.5.0/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro= golang.org/x/mod v0.5.1/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro= golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= -golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.18.0 h1:5+9lSbEzPSdWkH32vYPBwEpX8KwDbM52Ud9xBUvNlb0= +golang.org/x/mod v0.18.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -809,8 +821,8 @@ golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qx golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws= -golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= -golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ= +golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -933,13 +945,13 @@ golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= -golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= +golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.4.0/go.mod h1:9P2UbLfCdcvo3p/nzKvsmas4TnlujnuoV9hGgYzW1lQ= -golang.org/x/term v0.20.0 h1:VnkxpohqXaOBYJtBmEppKUG6mXpi+4O6purfc2+sMhw= -golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/term v0.21.0 h1:WVXCp+/EBEHOj53Rvu+7KiT/iElMrO8ACK16SMZ3jaA= +golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -1019,8 +1031,8 @@ golang.org/x/tools v0.1.8/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU= golang.org/x/tools v0.1.9/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU= golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= -golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/tools v0.22.0 h1:gqSGLZqv+AI9lIQzniJ0nZDRG5GBPsSi+DRNHWNz6yA= +golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= From ae1841efb02ecf2fb02e842e10e089cb57db914a Mon Sep 17 00:00:00 2001 From: Andi <36215014+ChengenH@users.noreply.github.com> Date: Wed, 28 Aug 2024 12:45:57 +0800 Subject: [PATCH 090/103] chore: use errors.New to replace fmt.Errorf with no parameters will much better (#4778) --- pkg/file/import.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/file/import.go b/pkg/file/import.go index 0af94a4d211..7c28197b8e5 100644 --- a/pkg/file/import.go +++ b/pkg/file/import.go @@ -89,7 +89,7 @@ func (i *Importer) fileJSONToFile(ctx context.Context, fileJSON jsonschema.DirEn return i.baseFileJSONToBaseFile(ctx, ff) } - return nil, fmt.Errorf("unknown file type") + return nil, errors.New("unknown file type") } func (i *Importer) baseFileJSONToBaseFile(ctx context.Context, baseJSON *jsonschema.BaseFile) (*models.BaseFile, error) { From 8133aa8c9173bbb32c6efb6d88496818439a4e5e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 29 Aug 2024 10:21:04 +1000 Subject: [PATCH 091/103] Bump micromatch from 4.0.5 to 4.0.8 in /ui/v2.5 (#5180) Bumps [micromatch](https://github.com/micromatch/micromatch) from 4.0.5 to 4.0.8. - [Release notes](https://github.com/micromatch/micromatch/releases) - [Changelog](https://github.com/micromatch/micromatch/blob/master/CHANGELOG.md) - [Commits](https://github.com/micromatch/micromatch/compare/4.0.5...4.0.8) --- updated-dependencies: - dependency-name: micromatch dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- ui/v2.5/yarn.lock | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/ui/v2.5/yarn.lock b/ui/v2.5/yarn.lock index e76d7694c1d..911fa135f20 100644 --- a/ui/v2.5/yarn.lock +++ b/ui/v2.5/yarn.lock @@ -3075,7 +3075,7 @@ brace-expansion@^1.1.7: balanced-match "^1.0.0" concat-map "0.0.1" -braces@^3.0.2, braces@~3.0.2: +braces@^3.0.2, braces@^3.0.3, braces@~3.0.2: version "3.0.3" resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.3.tgz#490332f40919452272d55a8480adc0c441358789" integrity sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA== @@ -5761,11 +5761,11 @@ micromark@^2.11.3, micromark@~2.11.0, micromark@~2.11.3: parse-entities "^2.0.0" micromatch@^4.0.4, micromatch@^4.0.5: - version "4.0.5" - resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.5.tgz#bc8999a7cbbf77cdc89f132f6e467051b49090c6" - integrity sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA== + version "4.0.8" + resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.8.tgz#d66fa18f3a47076789320b9b1af32bd86d9fa202" + integrity sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA== dependencies: - braces "^3.0.2" + braces "^3.0.3" picomatch "^2.3.1" mimic-fn@^2.1.0: From 68738bd227445756b39e0bcffd0ab02cd100243b Mon Sep 17 00:00:00 2001 From: sezzim <174854242+sezzim@users.noreply.github.com> Date: Wed, 28 Aug 2024 18:24:52 -0700 Subject: [PATCH 092/103] Support for assigning any image from a gallery as the cover (#5053) Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com> --- graphql/schema/schema.graphql | 2 + graphql/schema/types/gallery.graphql | 9 +++ internal/api/resolver_mutation_gallery.go | 55 +++++++++++++++++++ internal/manager/repository.go | 3 + pkg/gallery/update.go | 16 ++++++ pkg/image/query.go | 7 +++ pkg/models/mocks/GalleryReaderWriter.go | 28 ++++++++++ pkg/models/mocks/ImageReaderWriter.go | 23 ++++++++ pkg/models/repository_gallery.go | 2 + pkg/models/repository_image.go | 1 + pkg/sqlite/database.go | 2 +- pkg/sqlite/gallery.go | 8 +++ pkg/sqlite/gallery_test.go | 28 ++++++++++ pkg/sqlite/image.go | 36 ++++++++++++ pkg/sqlite/migrations/66_gallery_cover.up.sql | 2 + pkg/sqlite/table.go | 39 +++++++++++++ pkg/sqlite/tables.go | 12 ++-- ui/v2.5/graphql/mutations/gallery.graphql | 10 ++++ .../Galleries/GalleryDetails/Gallery.tsx | 28 +++++++++- .../GalleryDetails/GalleryImagesPanel.tsx | 44 ++++++++++++++- ui/v2.5/src/components/List/ItemList.tsx | 8 +++ ui/v2.5/src/core/StashService.ts | 28 ++++++++++ ui/v2.5/src/locales/en-GB.json | 2 + 23 files changed, 383 insertions(+), 10 deletions(-) create mode 100644 pkg/sqlite/migrations/66_gallery_cover.up.sql diff --git a/graphql/schema/schema.graphql b/graphql/schema/schema.graphql index da02af57528..da096707ac5 100644 --- a/graphql/schema/schema.graphql +++ b/graphql/schema/schema.graphql @@ -317,6 +317,8 @@ type Mutation { addGalleryImages(input: GalleryAddInput!): Boolean! removeGalleryImages(input: GalleryRemoveInput!): Boolean! + setGalleryCover(input: GallerySetCoverInput!): Boolean! + resetGalleryCover(input: GalleryResetCoverInput!): Boolean! galleryChapterCreate(input: GalleryChapterCreateInput!): GalleryChapter galleryChapterUpdate(input: GalleryChapterUpdateInput!): GalleryChapter diff --git a/graphql/schema/types/gallery.graphql b/graphql/schema/types/gallery.graphql index 3cf3216b9ae..fe8e3fab684 100644 --- a/graphql/schema/types/gallery.graphql +++ b/graphql/schema/types/gallery.graphql @@ -115,3 +115,12 @@ input GalleryRemoveInput { gallery_id: ID! image_ids: [ID!]! } + +input GallerySetCoverInput { + gallery_id: ID! + cover_image_id: ID! +} + +input GalleryResetCoverInput { + gallery_id: ID! +} diff --git a/internal/api/resolver_mutation_gallery.go b/internal/api/resolver_mutation_gallery.go index 2df6f1b7751..5d5cd4b3716 100644 --- a/internal/api/resolver_mutation_gallery.go +++ b/internal/api/resolver_mutation_gallery.go @@ -478,6 +478,61 @@ func (r *mutationResolver) RemoveGalleryImages(ctx context.Context, input Galler return true, nil } +func (r *mutationResolver) SetGalleryCover(ctx context.Context, input GallerySetCoverInput) (bool, error) { + galleryID, err := strconv.Atoi(input.GalleryID) + if err != nil { + return false, fmt.Errorf("converting gallery id: %w", err) + } + + coverImageID, err := strconv.Atoi(input.CoverImageID) + if err != nil { + return false, fmt.Errorf("converting cover image id: %w", err) + } + + if err := r.withTxn(ctx, func(ctx context.Context) error { + qb := r.repository.Gallery + gallery, err := qb.Find(ctx, galleryID) + if err != nil { + return err + } + + if gallery == nil { + return fmt.Errorf("gallery with id %d not found", galleryID) + } + + return r.galleryService.SetCover(ctx, gallery, coverImageID) + }); err != nil { + return false, err + } + + return true, nil +} + +func (r *mutationResolver) ResetGalleryCover(ctx context.Context, input GalleryResetCoverInput) (bool, error) { + galleryID, err := strconv.Atoi(input.GalleryID) + if err != nil { + return false, fmt.Errorf("converting gallery id: %w", err) + } + + if err := r.withTxn(ctx, func(ctx context.Context) error { + qb := r.repository.Gallery + gallery, err := qb.Find(ctx, galleryID) + if err != nil { + return err + } + + if gallery == nil { + return fmt.Errorf("gallery with id %d not found", galleryID) + } + + return r.galleryService.ResetCover(ctx, gallery) + }); err != nil { + return false, err + } + + return true, nil +} + func (r *mutationResolver) getGalleryChapter(ctx context.Context, id int) (ret *models.GalleryChapter, err error) { if err := r.withTxn(ctx, func(ctx context.Context) error { ret, err = r.repository.GalleryChapter.Find(ctx, id) diff --git a/internal/manager/repository.go b/internal/manager/repository.go index adfbfcb63d3..766f8039f85 100644 --- a/internal/manager/repository.go +++ b/internal/manager/repository.go @@ -24,6 +24,9 @@ type GalleryService interface { AddImages(ctx context.Context, g *models.Gallery, toAdd ...int) error RemoveImages(ctx context.Context, g *models.Gallery, toRemove ...int) error + SetCover(ctx context.Context, g *models.Gallery, coverImageId int) error + ResetCover(ctx context.Context, g *models.Gallery) error + Destroy(ctx context.Context, i *models.Gallery, fileDeleter *image.FileDeleter, deleteGenerated, deleteFile bool) ([]*models.Image, error) ValidateImageGalleryChange(ctx context.Context, i *models.Image, updateIDs models.UpdateIDs) error diff --git a/pkg/gallery/update.go b/pkg/gallery/update.go index d66da197c81..4f8b1f198eb 100644 --- a/pkg/gallery/update.go +++ b/pkg/gallery/update.go @@ -52,6 +52,22 @@ func (s *Service) RemoveImages(ctx context.Context, g *models.Gallery, toRemove return s.Updated(ctx, g.ID) } +func (s *Service) SetCover(ctx context.Context, g *models.Gallery, coverImageID int) error { + if err := s.Repository.SetCover(ctx, g.ID, coverImageID); err != nil { + return fmt.Errorf("failed to set cover: %w", err) + } + + return s.Updated(ctx, g.ID) +} + +func (s *Service) ResetCover(ctx context.Context, g *models.Gallery) error { + if err := s.Repository.ResetCover(ctx, g.ID); err != nil { + return fmt.Errorf("failed to reset cover: %w", err) + } + + return s.Updated(ctx, g.ID) +} + func AddPerformer(ctx context.Context, qb models.GalleryUpdater, o *models.Gallery, performerID int) error { galleryPartial := models.NewGalleryPartial() galleryPartial.PerformerIDs = &models.UpdateIDs{ diff --git a/pkg/image/query.go b/pkg/image/query.go index a5c9a17322f..9e82cd09a33 100644 --- a/pkg/image/query.go +++ b/pkg/image/query.go @@ -107,6 +107,13 @@ func FindGalleryCover(ctx context.Context, r models.ImageQueryer, galleryID int, } func findGalleryCover(ctx context.Context, r models.ImageQueryer, galleryID int, useCoverJpg bool, galleryCoverRegex string) (*models.Image, error) { + img, err := r.CoverByGalleryID(ctx, galleryID) + if err != nil { + return nil, err + } else if img != nil { + return img, nil + } + // try to find cover.jpg in the gallery perPage := 1 sortBy := "path" diff --git a/pkg/models/mocks/GalleryReaderWriter.go b/pkg/models/mocks/GalleryReaderWriter.go index bd1fbf0d2b9..f07f8a7d902 100644 --- a/pkg/models/mocks/GalleryReaderWriter.go +++ b/pkg/models/mocks/GalleryReaderWriter.go @@ -628,6 +628,34 @@ func (_m *GalleryReaderWriter) RemoveImages(ctx context.Context, galleryID int, return r0 } +// ResetCover provides a mock function with given fields: ctx, galleryID +func (_m *GalleryReaderWriter) ResetCover(ctx context.Context, galleryID int) error { + ret := _m.Called(ctx, galleryID) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, int) error); ok { + r0 = rf(ctx, galleryID) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// SetCover provides a mock function with given fields: ctx, galleryID, coverImageID +func (_m *GalleryReaderWriter) SetCover(ctx context.Context, galleryID int, coverImageID int) error { + ret := _m.Called(ctx, galleryID, coverImageID) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, int, int) error); ok { + r0 = rf(ctx, galleryID, coverImageID) + } else { + r0 = ret.Error(0) + } + + return r0 +} + // Update provides a mock function with given fields: ctx, updatedGallery func (_m *GalleryReaderWriter) Update(ctx context.Context, updatedGallery *models.Gallery) error { ret := _m.Called(ctx, updatedGallery) diff --git a/pkg/models/mocks/ImageReaderWriter.go b/pkg/models/mocks/ImageReaderWriter.go index 4cdd0d8ee20..04fd6690004 100644 --- a/pkg/models/mocks/ImageReaderWriter.go +++ b/pkg/models/mocks/ImageReaderWriter.go @@ -114,6 +114,29 @@ func (_m *ImageReaderWriter) CountByGalleryID(ctx context.Context, galleryID int return r0, r1 } +// CoverByGalleryID provides a mock function with given fields: ctx, galleryId +func (_m *ImageReaderWriter) CoverByGalleryID(ctx context.Context, galleryId int) (*models.Image, error) { + ret := _m.Called(ctx, galleryId) + + var r0 *models.Image + if rf, ok := ret.Get(0).(func(context.Context, int) *models.Image); ok { + r0 = rf(ctx, galleryId) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*models.Image) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { + r1 = rf(ctx, galleryId) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // Create provides a mock function with given fields: ctx, newImage, fileIDs func (_m *ImageReaderWriter) Create(ctx context.Context, newImage *models.Image, fileIDs []models.FileID) error { ret := _m.Called(ctx, newImage, fileIDs) diff --git a/pkg/models/repository_gallery.go b/pkg/models/repository_gallery.go index 45ad5beb710..0cfb9964fab 100644 --- a/pkg/models/repository_gallery.go +++ b/pkg/models/repository_gallery.go @@ -83,6 +83,8 @@ type GalleryWriter interface { AddFileID(ctx context.Context, id int, fileID FileID) error AddImages(ctx context.Context, galleryID int, imageIDs ...int) error RemoveImages(ctx context.Context, galleryID int, imageIDs ...int) error + SetCover(ctx context.Context, galleryID int, coverImageID int) error + ResetCover(ctx context.Context, galleryID int) error } // GalleryReaderWriter provides all gallery methods. diff --git a/pkg/models/repository_image.go b/pkg/models/repository_image.go index fd58ed762ff..274374b41cb 100644 --- a/pkg/models/repository_image.go +++ b/pkg/models/repository_image.go @@ -25,6 +25,7 @@ type ImageFinder interface { type ImageQueryer interface { Query(ctx context.Context, options ImageQueryOptions) (*ImageQueryResult, error) QueryCount(ctx context.Context, imageFilter *ImageFilterType, findFilter *FindFilterType) (int, error) + CoverByGalleryID(ctx context.Context, galleryId int) (*Image, error) } // ImageCounter provides methods to count images. diff --git a/pkg/sqlite/database.go b/pkg/sqlite/database.go index 3486313840f..ee5e5399bf9 100644 --- a/pkg/sqlite/database.go +++ b/pkg/sqlite/database.go @@ -30,7 +30,7 @@ const ( dbConnTimeout = 30 ) -var appSchemaVersion uint = 65 +var appSchemaVersion uint = 66 //go:embed migrations/*.sql var migrationsBox embed.FS diff --git a/pkg/sqlite/gallery.go b/pkg/sqlite/gallery.go index b7f7552c2b6..5473b9c36ee 100644 --- a/pkg/sqlite/gallery.go +++ b/pkg/sqlite/gallery.go @@ -890,6 +890,14 @@ func (qb *GalleryStore) UpdateImages(ctx context.Context, galleryID int, imageID return galleryRepository.images.replace(ctx, galleryID, imageIDs) } +func (qb *GalleryStore) SetCover(ctx context.Context, galleryID int, coverImageID int) error { + return imageGalleriesTableMgr.setCover(ctx, coverImageID, galleryID) +} + +func (qb *GalleryStore) ResetCover(ctx context.Context, galleryID int) error { + return imageGalleriesTableMgr.resetCover(ctx, galleryID) +} + func (qb *GalleryStore) GetSceneIDs(ctx context.Context, id int) ([]int, error) { return galleryRepository.scenes.getIDs(ctx, id) } diff --git a/pkg/sqlite/gallery_test.go b/pkg/sqlite/gallery_test.go index fcc10aece10..be1edb687ae 100644 --- a/pkg/sqlite/gallery_test.go +++ b/pkg/sqlite/gallery_test.go @@ -2973,6 +2973,34 @@ func TestGalleryQueryHasChapters(t *testing.T) { }) } +func TestGallerySetAndResetCover(t *testing.T) { + withTxn(func(ctx context.Context) error { + sqb := db.Gallery + + imagePath2 := getFilePath(folderIdxWithImageFiles, getImageBasename(imageIdx2WithGallery)) + + result, err := db.Image.CoverByGalleryID(ctx, galleryIDs[galleryIdxWithTwoImages]) + assert.Nil(t, err) + assert.Nil(t, result) + + err = sqb.SetCover(ctx, galleryIDs[galleryIdxWithTwoImages], imageIDs[imageIdx2WithGallery]) + assert.Nil(t, err) + + result, err = db.Image.CoverByGalleryID(ctx, galleryIDs[galleryIdxWithTwoImages]) + assert.Nil(t, err) + assert.Equal(t, result.Path, imagePath2) + + err = sqb.ResetCover(ctx, galleryIDs[galleryIdxWithTwoImages]) + assert.Nil(t, err) + + result, err = db.Image.CoverByGalleryID(ctx, galleryIDs[galleryIdxWithTwoImages]) + assert.Nil(t, err) + assert.Nil(t, result) + + return nil + }) +} + // TODO Count // TODO All // TODO Query diff --git a/pkg/sqlite/image.go b/pkg/sqlite/image.go index 3d1882a1e9b..8248427a8eb 100644 --- a/pkg/sqlite/image.go +++ b/pkg/sqlite/image.go @@ -480,6 +480,42 @@ func (qb *ImageStore) getMany(ctx context.Context, q *goqu.SelectDataset) ([]*mo return ret, nil } +// Returns the custom cover for the gallery, if one has been set. +func (qb *ImageStore) CoverByGalleryID(ctx context.Context, galleryID int) (*models.Image, error) { + table := qb.table() + + sq := dialect.From(table). + InnerJoin( + galleriesImagesJoinTable, + goqu.On(table.Col(idColumn).Eq(galleriesImagesJoinTable.Col(imageIDColumn))), + ). + Select(table.Col(idColumn)). + Where(goqu.And( + galleriesImagesJoinTable.Col("gallery_id").Eq(galleryID), + galleriesImagesJoinTable.Col("cover").Eq(true), + )) + + q := qb.selectDataset().Prepared(true).Where( + table.Col(idColumn).Eq( + sq, + ), + ) + + ret, err := qb.getMany(ctx, q) + if err != nil { + return nil, fmt.Errorf("getting cover for gallery %d: %w", galleryID, err) + } + + switch { + case len(ret) > 1: + return nil, fmt.Errorf("internal error: multiple covers returned for gallery %d", galleryID) + case len(ret) == 1: + return ret[0], nil + default: + return nil, nil + } +} + func (qb *ImageStore) GetFiles(ctx context.Context, id int) ([]models.File, error) { fileIDs, err := imageRepository.files.get(ctx, id) if err != nil { diff --git a/pkg/sqlite/migrations/66_gallery_cover.up.sql b/pkg/sqlite/migrations/66_gallery_cover.up.sql new file mode 100644 index 00000000000..7be80293a7d --- /dev/null +++ b/pkg/sqlite/migrations/66_gallery_cover.up.sql @@ -0,0 +1,2 @@ +ALTER TABLE `galleries_images` ADD COLUMN `cover` BOOLEAN NOT NULL DEFAULT 0; +CREATE UNIQUE INDEX `index_galleries_images_gallery_id_cover` on `galleries_images` (`gallery_id`, `cover`) WHERE `cover` = 1; \ No newline at end of file diff --git a/pkg/sqlite/table.go b/pkg/sqlite/table.go index 240918f3e0c..2ae3bf9458c 100644 --- a/pkg/sqlite/table.go +++ b/pkg/sqlite/table.go @@ -710,6 +710,45 @@ func (t *scenesGroupsTable) modifyJoins(ctx context.Context, id int, v []models. return nil } +type imageGalleriesTable struct { + joinTable +} + +func (t *imageGalleriesTable) setCover(ctx context.Context, id int, galleryID int) error { + if err := t.resetCover(ctx, galleryID); err != nil { + return err + } + + table := t.table.table + + q := dialect.Update(table).Prepared(true).Set(goqu.Record{ + "cover": true, + }).Where(t.idColumn.Eq(id), table.Col(galleryIDColumn).Eq(galleryID)) + + if _, err := exec(ctx, q); err != nil { + return fmt.Errorf("setting cover flag in %s: %w", t.table.table.GetTable(), err) + } + + return nil +} + +func (t *imageGalleriesTable) resetCover(ctx context.Context, galleryID int) error { + table := t.table.table + + q := dialect.Update(table).Prepared(true).Set(goqu.Record{ + "cover": false, + }).Where( + table.Col(galleryIDColumn).Eq(galleryID), + table.Col("cover").Eq(true), + ) + + if _, err := exec(ctx, q); err != nil { + return fmt.Errorf("unsetting cover flags in %s: %w", t.table.table.GetTable(), err) + } + + return nil +} + type relatedFilesTable struct { table } diff --git a/pkg/sqlite/tables.go b/pkg/sqlite/tables.go index 7f93c814852..365abe81292 100644 --- a/pkg/sqlite/tables.go +++ b/pkg/sqlite/tables.go @@ -57,12 +57,14 @@ var ( }, } - imageGalleriesTableMgr = &joinTable{ - table: table{ - table: galleriesImagesJoinTable, - idColumn: galleriesImagesJoinTable.Col(imageIDColumn), + imageGalleriesTableMgr = &imageGalleriesTable{ + joinTable: joinTable{ + table: table{ + table: galleriesImagesJoinTable, + idColumn: galleriesImagesJoinTable.Col(imageIDColumn), + }, + fkColumn: galleriesImagesJoinTable.Col(galleryIDColumn), }, - fkColumn: galleriesImagesJoinTable.Col(galleryIDColumn), } imagesTagsTableMgr = &joinTable{ diff --git a/ui/v2.5/graphql/mutations/gallery.graphql b/ui/v2.5/graphql/mutations/gallery.graphql index 9f9fd1e0b48..d76f98a4f95 100644 --- a/ui/v2.5/graphql/mutations/gallery.graphql +++ b/ui/v2.5/graphql/mutations/gallery.graphql @@ -43,3 +43,13 @@ mutation AddGalleryImages($gallery_id: ID!, $image_ids: [ID!]!) { mutation RemoveGalleryImages($gallery_id: ID!, $image_ids: [ID!]!) { removeGalleryImages(input: { gallery_id: $gallery_id, image_ids: $image_ids }) } + +mutation SetGalleryCover($gallery_id: ID!, $cover_image_id: ID!) { + setGalleryCover( + input: { gallery_id: $gallery_id, cover_image_id: $cover_image_id } + ) +} + +mutation ResetGalleryCover($gallery_id: ID!) { + resetGalleryCover(input: { gallery_id: $gallery_id }) +} diff --git a/ui/v2.5/src/components/Galleries/GalleryDetails/Gallery.tsx b/ui/v2.5/src/components/Galleries/GalleryDetails/Gallery.tsx index ae73f2a1ddb..7dc2a17b6ac 100644 --- a/ui/v2.5/src/components/Galleries/GalleryDetails/Gallery.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryDetails/Gallery.tsx @@ -11,6 +11,7 @@ import { Helmet } from "react-helmet"; import * as GQL from "src/core/generated-graphql"; import { mutateMetadataScan, + mutateResetGalleryCover, useFindGallery, useGalleryUpdate, } from "src/core/StashService"; @@ -138,6 +139,25 @@ export const GalleryPage: React.FC = ({ gallery, add }) => { ); } + async function onResetCover() { + try { + await mutateResetGalleryCover({ + gallery_id: gallery.id!, + }); + + Toast.success( + intl.formatMessage( + { id: "toast.updated_entity" }, + { + entity: intl.formatMessage({ id: "gallery" }).toLocaleLowerCase(), + } + ) + ); + } catch (e) { + Toast.error(e); + } + } + async function onClickChapter(imageindex: number) { showLightbox(imageindex - 1); } @@ -176,7 +196,6 @@ export const GalleryPage: React.FC = ({ gallery, add }) => { {path ? ( onRescan()} > @@ -184,7 +203,12 @@ export const GalleryPage: React.FC = ({ gallery, add }) => { ) : undefined} onResetCover()} + > + + + setIsDeleteAlertOpen(true)} > diff --git a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryImagesPanel.tsx b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryImagesPanel.tsx index ea28ffabf8c..1ca450c5e47 100644 --- a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryImagesPanel.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryImagesPanel.tsx @@ -3,8 +3,14 @@ import * as GQL from "src/core/generated-graphql"; import { GalleriesCriterion } from "src/models/list-filter/criteria/galleries"; import { ListFilterModel } from "src/models/list-filter/filter"; import { ImageList } from "src/components/Images/ImageList"; -import { mutateRemoveGalleryImages } from "src/core/StashService"; -import { showWhenSelected } from "src/components/List/ItemList"; +import { + mutateRemoveGalleryImages, + mutateSetGalleryCover, +} from "src/core/StashService"; +import { + showWhenSelected, + showWhenSingleSelection, +} from "src/components/List/ItemList"; import { useToast } from "src/hooks/Toast"; import { useIntl } from "react-intl"; import { faMinus } from "@fortawesome/free-solid-svg-icons"; @@ -58,6 +64,35 @@ export const GalleryImagesPanel: React.FC = ({ return filter; } + async function setCover( + result: GQL.FindImagesQueryResult, + filter: ListFilterModel, + selectedIds: Set + ) { + const coverImageID = selectedIds.values().next(); + if (coverImageID.done) { + // operation should only be displayed when exactly one image is selected + return; + } + try { + await mutateSetGalleryCover({ + gallery_id: gallery.id!, + cover_image_id: coverImageID.value, + }); + + Toast.success( + intl.formatMessage( + { id: "toast.updated_entity" }, + { + entity: intl.formatMessage({ id: "gallery" }).toLocaleLowerCase(), + } + ) + ); + } catch (e) { + Toast.error(e); + } + } + async function removeImages( result: GQL.FindImagesQueryResult, filter: ListFilterModel, @@ -85,6 +120,11 @@ export const GalleryImagesPanel: React.FC = ({ } const otherOperations = [ + { + text: intl.formatMessage({ id: "actions.set_cover" }), + onClick: setCover, + isDisplayed: showWhenSingleSelection, + }, { text: intl.formatMessage({ id: "actions.remove_from_gallery" }), onClick: removeImages, diff --git a/ui/v2.5/src/components/List/ItemList.tsx b/ui/v2.5/src/components/List/ItemList.tsx index 5be226c331d..dbcdde1d382 100644 --- a/ui/v2.5/src/components/List/ItemList.tsx +++ b/ui/v2.5/src/components/List/ItemList.tsx @@ -335,3 +335,11 @@ export const showWhenSelected = ( ) => { return selectedIds.size > 0; }; + +export const showWhenSingleSelection = ( + result: T, + filter: ListFilterModel, + selectedIds: Set +) => { + return selectedIds.size == 1; +}; diff --git a/ui/v2.5/src/core/StashService.ts b/ui/v2.5/src/core/StashService.ts index 1685e92dcfb..e0ff90aaf70 100644 --- a/ui/v2.5/src/core/StashService.ts +++ b/ui/v2.5/src/core/StashService.ts @@ -1526,6 +1526,34 @@ export const mutateAddGalleryImages = (input: GQL.GalleryAddInput) => }, }); +export const mutateSetGalleryCover = (input: GQL.GallerySetCoverInput) => + client.mutate({ + mutation: GQL.SetGalleryCoverDocument, + variables: input, + update(cache, result) { + if (!result.data?.setGalleryCover) return; + + cache.evict({ + id: cache.identify({ __typename: "Gallery", id: input.gallery_id }), + fieldName: "cover", + }); + }, + }); + +export const mutateResetGalleryCover = (input: GQL.GalleryResetCoverInput) => + client.mutate({ + mutation: GQL.ResetGalleryCoverDocument, + variables: input, + update(cache, result) { + if (!result.data?.resetGalleryCover) return; + + cache.evict({ + id: cache.identify({ __typename: "Gallery", id: input.gallery_id }), + fieldName: "cover", + }); + }, + }); + export const mutateRemoveGalleryImages = (input: GQL.GalleryRemoveInput) => client.mutate({ mutation: GQL.RemoveGalleryImagesDocument, diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index 59ea1459e17..b572140139b 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -94,6 +94,7 @@ "remove_from_gallery": "Remove from Gallery", "rename_gen_files": "Rename generated files", "rescan": "Rescan", + "reset_cover": "Restore Default Cover", "reshuffle": "Reshuffle", "running": "running", "save": "Save", @@ -114,6 +115,7 @@ "selective_scan": "Selective Scan", "set_as_default": "Set as default", "set_back_image": "Back image…", + "set_cover": "Set as Cover", "set_front_image": "Front image…", "set_image": "Set image…", "show": "Show", From 96fdd94a01cc17a75b9a294fd98afba9a1f68927 Mon Sep 17 00:00:00 2001 From: Ian McKenzie <13459320+ikmckenz@users.noreply.github.com> Date: Wed, 28 Aug 2024 20:34:22 -0700 Subject: [PATCH 093/103] Create a section in the history panel to reset scene activity (#5168) Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com> --- graphql/schema/schema.graphql | 7 ++ internal/api/resolver_mutation_scene.go | 18 ++++ pkg/models/mocks/SceneReaderWriter.go | 21 +++++ pkg/models/repository_scene.go | 1 + pkg/sqlite/scene.go | 24 ++++++ ui/v2.5/graphql/mutations/scene.graphql | 12 +++ .../Scenes/SceneDetails/SceneHistoryPanel.tsx | 85 ++++++++++++++++++- ui/v2.5/src/core/StashService.ts | 15 ++++ ui/v2.5/src/locales/en-GB.json | 2 + 9 files changed, 184 insertions(+), 1 deletion(-) diff --git a/graphql/schema/schema.graphql b/graphql/schema/schema.graphql index da096707ac5..f11edb46f36 100644 --- a/graphql/schema/schema.graphql +++ b/graphql/schema/schema.graphql @@ -276,6 +276,13 @@ type Mutation { "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! + "Resets the resume time point and play duration" + sceneResetActivity( + id: ID! + reset_resume: Boolean + reset_duration: Boolean + ): Boolean! + "Increments the play count for the scene. Returns the new play count value." sceneIncrementPlayCount(id: ID!): Int! @deprecated(reason: "Use sceneAddPlay instead") diff --git a/internal/api/resolver_mutation_scene.go b/internal/api/resolver_mutation_scene.go index 356214d59e1..ca99dafc150 100644 --- a/internal/api/resolver_mutation_scene.go +++ b/internal/api/resolver_mutation_scene.go @@ -847,6 +847,24 @@ func (r *mutationResolver) SceneSaveActivity(ctx context.Context, id string, res return ret, nil } +func (r *mutationResolver) SceneResetActivity(ctx context.Context, id string, resetResume *bool, resetDuration *bool) (ret bool, err error) { + sceneID, err := strconv.Atoi(id) + if err != nil { + return false, fmt.Errorf("converting id: %w", err) + } + + if err := r.withTxn(ctx, func(ctx context.Context) error { + qb := r.repository.Scene + + ret, err = qb.ResetActivity(ctx, sceneID, utils.IsTrue(resetResume), utils.IsTrue(resetDuration)) + return err + }); err != nil { + return false, err + } + + return ret, nil +} + // deprecated func (r *mutationResolver) SceneIncrementPlayCount(ctx context.Context, id string) (ret int, err error) { sceneID, err := strconv.Atoi(id) diff --git a/pkg/models/mocks/SceneReaderWriter.go b/pkg/models/mocks/SceneReaderWriter.go index 3787d8182d3..e12ae999c6f 100644 --- a/pkg/models/mocks/SceneReaderWriter.go +++ b/pkg/models/mocks/SceneReaderWriter.go @@ -1267,6 +1267,27 @@ func (_m *SceneReaderWriter) QueryCount(ctx context.Context, sceneFilter *models return r0, r1 } +// ResetActivity provides a mock function with given fields: ctx, sceneID, resetResume, resetDuration +func (_m *SceneReaderWriter) ResetActivity(ctx context.Context, sceneID int, resetResume bool, resetDuration bool) (bool, error) { + ret := _m.Called(ctx, sceneID, resetResume, resetDuration) + + var r0 bool + if rf, ok := ret.Get(0).(func(context.Context, int, bool, bool) bool); ok { + r0 = rf(ctx, sceneID, resetResume, resetDuration) + } else { + r0 = ret.Get(0).(bool) + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, int, bool, bool) error); ok { + r1 = rf(ctx, sceneID, resetResume, resetDuration) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // ResetO provides a mock function with given fields: ctx, id func (_m *SceneReaderWriter) ResetO(ctx context.Context, id int) (int, error) { ret := _m.Called(ctx, id) diff --git a/pkg/models/repository_scene.go b/pkg/models/repository_scene.go index bbd69606635..60783fff5cd 100644 --- a/pkg/models/repository_scene.go +++ b/pkg/models/repository_scene.go @@ -137,6 +137,7 @@ type SceneWriter interface { OHistoryWriter ViewHistoryWriter SaveActivity(ctx context.Context, sceneID int, resumeTime *float64, playDuration *float64) (bool, error) + ResetActivity(ctx context.Context, sceneID int, resetResume bool, resetDuration bool) (bool, error) } // SceneReaderWriter provides all scene methods. diff --git a/pkg/sqlite/scene.go b/pkg/sqlite/scene.go index 99da461e7d5..9b8bd73157b 100644 --- a/pkg/sqlite/scene.go +++ b/pkg/sqlite/scene.go @@ -1234,6 +1234,30 @@ func (qb *SceneStore) SaveActivity(ctx context.Context, id int, resumeTime *floa return true, nil } +func (qb *SceneStore) ResetActivity(ctx context.Context, id int, resetResume bool, resetDuration bool) (bool, error) { + if err := qb.tableMgr.checkIDExists(ctx, id); err != nil { + return false, err + } + + record := goqu.Record{} + + if resetResume { + record["resume_time"] = 0.0 + } + + if resetDuration { + record["play_duration"] = 0.0 + } + + if len(record) > 0 { + if err := qb.tableMgr.updateByID(ctx, id, record); err != nil { + return false, err + } + } + + return true, nil +} + func (qb *SceneStore) GetURLs(ctx context.Context, sceneID int) ([]string, error) { return scenesURLsTableMgr.get(ctx, sceneID) } diff --git a/ui/v2.5/graphql/mutations/scene.graphql b/ui/v2.5/graphql/mutations/scene.graphql index 874db5c7316..c04857b7fc6 100644 --- a/ui/v2.5/graphql/mutations/scene.graphql +++ b/ui/v2.5/graphql/mutations/scene.graphql @@ -34,6 +34,18 @@ mutation SceneSaveActivity( ) } +mutation SceneResetActivity( + $id: ID! + $reset_resume: Boolean! + $reset_duration: Boolean! +) { + sceneResetActivity( + id: $id + reset_resume: $reset_resume + reset_duration: $reset_duration + ) +} + mutation SceneAddPlay($id: ID!, $times: [Timestamp!]) { sceneAddPlay(id: $id, times: $times) { count diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneHistoryPanel.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneHistoryPanel.tsx index 7baaab151a1..1ac9dd5a27b 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneHistoryPanel.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneHistoryPanel.tsx @@ -18,8 +18,10 @@ import { useSceneIncrementPlayCount, useSceneResetO, useSceneResetPlayCount, + useSceneResetActivity, } from "src/core/StashService"; import * as GQL from "src/core/generated-graphql"; +import { useToast } from "src/hooks/Toast"; import { TextField } from "src/utils/field"; import TextUtils from "src/utils/text"; @@ -72,9 +74,19 @@ const History: React.FC<{ const HistoryMenu: React.FC<{ hasHistory: boolean; + showResetResumeDuration: boolean; onAddDate: () => void; onClearDates: () => void; -}> = ({ hasHistory, onAddDate, onClearDates }) => { + resetResume: () => void; + resetDuration: () => void; +}> = ({ + hasHistory, + showResetResumeDuration, + onAddDate, + onClearDates, + resetResume, + resetDuration, +}) => { const intl = useIntl(); return ( @@ -101,6 +113,22 @@ const HistoryMenu: React.FC<{ )} + {showResetResumeDuration && ( + resetResume()} + > + + + )} + {showResetResumeDuration && ( + resetDuration()} + > + + + )} ); @@ -142,6 +170,7 @@ interface ISceneHistoryProps { export const SceneHistoryPanel: React.FC = ({ scene }) => { const intl = useIntl(); + const Toast = useToast(); const [dialogs, setDialogs] = React.useState({ playHistory: false, @@ -160,6 +189,8 @@ export const SceneHistoryPanel: React.FC = ({ scene }) => { const [incrementOCount] = useSceneIncrementO(scene.id); const [decrementOCount] = useSceneDecrementO(scene.id); const [resetO] = useSceneResetO(scene.id); + const [resetResume] = useSceneResetActivity(scene.id, true, false); + const [resetDuration] = useSceneResetActivity(scene.id, false, true); function dateStringToISOString(time: string) { const date = TextUtils.stringToFuzzyDateTime(time); @@ -221,6 +252,52 @@ export const SceneHistoryPanel: React.FC = ({ scene }) => { }); } + async function handleResetResume() { + try { + await resetResume({ + variables: { + id: scene.id, + reset_resume: true, + reset_duration: false, + }, + }); + + Toast.success( + intl.formatMessage( + { id: "toast.updated_entity" }, + { + entity: intl.formatMessage({ id: "scene" }).toLocaleLowerCase(), + } + ) + ); + } catch (e) { + Toast.error(e); + } + } + + async function handleResetDuration() { + try { + await resetDuration({ + variables: { + id: scene.id, + reset_resume: false, + reset_duration: true, + }, + }); + + Toast.success( + intl.formatMessage( + { id: "toast.updated_entity" }, + { + entity: intl.formatMessage({ id: "scene" }).toLocaleLowerCase(), + } + ) + ); + } catch (e) { + Toast.error(e); + } + } + function maybeRenderDialogs() { return ( <> @@ -296,8 +373,11 @@ export const SceneHistoryPanel: React.FC = ({ scene }) => { 0} + showResetResumeDuration={true} onAddDate={() => setDialogPartial({ addPlay: true })} onClearDates={() => setDialogPartial({ playHistory: true })} + resetResume={() => handleResetResume()} + resetDuration={() => handleResetDuration()} /> @@ -336,8 +416,11 @@ export const SceneHistoryPanel: React.FC = ({ scene }) => { 0} + showResetResumeDuration={false} onAddDate={() => setDialogPartial({ addO: true })} onClearDates={() => setDialogPartial({ oHistory: true })} + resetResume={() => handleResetResume()} + resetDuration={() => handleResetDuration()} /> diff --git a/ui/v2.5/src/core/StashService.ts b/ui/v2.5/src/core/StashService.ts index e0ff90aaf70..ee1d17ef9e4 100644 --- a/ui/v2.5/src/core/StashService.ts +++ b/ui/v2.5/src/core/StashService.ts @@ -786,6 +786,21 @@ export const useSceneResetO = (id: string) => }, }); +export const useSceneResetActivity = ( + id: string, + reset_resume: boolean, + reset_duration: boolean +) => + GQL.useSceneResetActivityMutation({ + variables: { id, reset_resume, reset_duration }, + update(cache, result) { + if (!result.data?.sceneResetActivity) return; + + evictTypeFields(cache, sceneMutationImpactedTypeFields); + evictQueries(cache, sceneMutationImpactedQueries); + }, + }); + export const useSceneGenerateScreenshot = () => GQL.useSceneGenerateScreenshotMutation(); diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index b572140139b..b76016c8c09 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -94,6 +94,8 @@ "remove_from_gallery": "Remove from Gallery", "rename_gen_files": "Rename generated files", "rescan": "Rescan", + "reset_play_duration": "Reset play duration", + "reset_resume_time": "Reset resume time", "reset_cover": "Restore Default Cover", "reshuffle": "Reshuffle", "running": "running", From bcf0fda7acdfa19117ef267f4c7789b896c3ccf4 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Fri, 30 Aug 2024 11:43:44 +1000 Subject: [PATCH 094/103] Containing Group/Sub-Group relationships (#5105) * Add UI support for setting containing groups * Show containing groups in group details panel * Move tag hierarchical filter code into separate type * Add depth to scene_count and add sub_group_count * Add sub-groups tab to groups page * Add containing groups to edit groups dialog * Show containing group description in sub-group view * Show group scene number in group scenes view * Add ability to drag move grid cards * Add sub group order option * Add reorder sub-groups interface * Separate page size selector component * Add interfaces to add and remove sub-groups to a group * Separate MultiSet components * Allow setting description while setting containing groups --- graphql/schema/schema.graphql | 6 + graphql/schema/types/filters.graphql | 11 +- graphql/schema/types/group.graphql | 59 +- graphql/schema/types/movie.graphql | 2 +- graphql/schema/types/performer.graphql | 2 +- internal/api/changeset_translator.go | 61 + internal/api/resolver.go | 1 + internal/api/resolver_model_movie.go | 68 +- internal/api/resolver_mutation_group.go | 154 ++- internal/api/server.go | 2 + internal/dlna/cds.go | 2 +- internal/manager/init.go | 6 + internal/manager/manager.go | 1 + internal/manager/repository.go | 10 + internal/manager/task_export.go | 23 + internal/manager/task_import.go | 67 +- pkg/group/create.go | 41 + pkg/group/import.go | 78 ++ pkg/group/query.go | 12 + pkg/group/reorder.go | 33 + pkg/group/service.go | 46 + pkg/group/update.go | 112 ++ pkg/group/validate.go | 117 ++ pkg/models/group.go | 8 + pkg/models/jsonschema/group.go | 34 +- pkg/models/mocks/GroupReaderWriter.go | 46 + pkg/models/model_group.go | 33 +- pkg/models/model_joins.go | 5 + pkg/models/relationships.go | 86 ++ pkg/models/repository_group.go | 2 + pkg/models/repository_scene.go | 3 - pkg/models/scene.go | 2 +- pkg/models/update.go | 65 + pkg/scene/query.go | 12 + pkg/sliceutil/collections.go | 2 +- pkg/sqlite/database.go | 2 +- pkg/sqlite/filter_hierarchical.go | 222 +++ pkg/sqlite/group.go | 131 +- pkg/sqlite/group_filter.go | 12 + pkg/sqlite/group_relationships.go | 457 +++++++ pkg/sqlite/group_test.go | 1210 ++++++++++++++++- .../migrations/67_group_relationships.up.sql | 13 + pkg/sqlite/query.go | 10 + pkg/sqlite/scene.go | 15 +- pkg/sqlite/scene_filter.go | 22 +- pkg/sqlite/scene_test.go | 169 ++- pkg/sqlite/setup_test.go | 54 +- pkg/sqlite/tables.go | 9 +- pkg/sqlite/tag_filter.go | 227 +--- pkg/sqlite/tag_test.go | 2 +- ui/v2.5/graphql/data/group.graphql | 10 + ui/v2.5/graphql/mutations/group.graphql | 12 + .../Groups/ContainingGroupsMultiSet.tsx | 61 + .../components/Groups/EditGroupsDialog.tsx | 79 +- ui/v2.5/src/components/Groups/GroupCard.tsx | 79 +- .../src/components/Groups/GroupCardGrid.tsx | 6 + .../Groups/GroupDetails/AddGroupsDialog.tsx | 121 ++ .../components/Groups/GroupDetails/Group.tsx | 114 +- .../Groups/GroupDetails/GroupDetailsPanel.tsx | 30 +- .../Groups/GroupDetails/GroupEditPanel.tsx | 56 +- .../Groups/GroupDetails/GroupScenesPanel.tsx | 39 +- .../GroupDetails/GroupSubGroupsPanel.tsx | 204 +++ .../Groups/GroupDetails/RelatedGroupTable.tsx | 137 ++ ui/v2.5/src/components/Groups/GroupList.tsx | 128 +- ui/v2.5/src/components/Groups/GroupSelect.tsx | 16 +- ui/v2.5/src/components/Groups/GroupTag.tsx | 28 + .../components/Groups/RelatedGroupPopover.tsx | 110 ++ ui/v2.5/src/components/Groups/styles.scss | 24 +- .../components/List/FilteredListToolbar.tsx | 24 +- .../Filters/HierarchicalLabelValueFilter.tsx | 26 +- ui/v2.5/src/components/List/ItemList.tsx | 85 +- ui/v2.5/src/components/List/ListFilter.tsx | 230 ++-- .../components/List/ListOperationButtons.tsx | 41 +- ui/v2.5/src/components/List/ListProvider.tsx | 20 + ui/v2.5/src/components/List/styles.scss | 4 + ui/v2.5/src/components/List/util.ts | 20 + ui/v2.5/src/components/List/views.ts | 1 + ui/v2.5/src/components/Scenes/SceneCard.tsx | 63 +- .../src/components/Scenes/SceneCardsGrid.tsx | 3 + .../Scenes/SceneDetails/SceneGroupPanel.tsx | 2 +- ui/v2.5/src/components/Scenes/SceneList.tsx | 3 + ui/v2.5/src/components/Scenes/styles.scss | 4 + .../components/Shared/GridCard/GridCard.tsx | 140 +- .../Shared/GridCard/dragMoveSelect.ts | 143 ++ .../components/Shared/GridCard/styles.scss | 25 + ui/v2.5/src/components/Shared/Icon.tsx | 23 +- ui/v2.5/src/components/Shared/MultiSet.tsx | 126 +- .../components/Shared/PopoverCountButton.tsx | 59 +- ui/v2.5/src/components/Shared/TagLink.tsx | 13 +- ui/v2.5/src/components/Shared/styles.scss | 36 +- ui/v2.5/src/core/StashService.ts | 54 + ui/v2.5/src/locales/en-GB.json | 11 + .../models/list-filter/criteria/criterion.ts | 1 + .../src/models/list-filter/criteria/groups.ts | 49 +- ui/v2.5/src/models/list-filter/filter.ts | 6 + ui/v2.5/src/models/list-filter/groups.ts | 9 + ui/v2.5/src/models/list-filter/types.ts | 4 + ui/v2.5/src/utils/bulkUpdate.ts | 9 +- ui/v2.5/src/utils/navigation.ts | 60 +- 99 files changed, 5384 insertions(+), 931 deletions(-) create mode 100644 pkg/group/create.go create mode 100644 pkg/group/reorder.go create mode 100644 pkg/group/service.go create mode 100644 pkg/group/update.go create mode 100644 pkg/group/validate.go create mode 100644 pkg/sqlite/filter_hierarchical.go create mode 100644 pkg/sqlite/group_relationships.go create mode 100644 pkg/sqlite/migrations/67_group_relationships.up.sql create mode 100644 ui/v2.5/src/components/Groups/ContainingGroupsMultiSet.tsx create mode 100644 ui/v2.5/src/components/Groups/GroupDetails/AddGroupsDialog.tsx create mode 100644 ui/v2.5/src/components/Groups/GroupDetails/GroupSubGroupsPanel.tsx create mode 100644 ui/v2.5/src/components/Groups/GroupDetails/RelatedGroupTable.tsx create mode 100644 ui/v2.5/src/components/Groups/GroupTag.tsx create mode 100644 ui/v2.5/src/components/Groups/RelatedGroupPopover.tsx create mode 100644 ui/v2.5/src/components/Shared/GridCard/dragMoveSelect.ts diff --git a/graphql/schema/schema.graphql b/graphql/schema/schema.graphql index f11edb46f36..251c2af838c 100644 --- a/graphql/schema/schema.graphql +++ b/graphql/schema/schema.graphql @@ -359,6 +359,12 @@ type Mutation { groupsDestroy(ids: [ID!]!): Boolean! bulkGroupUpdate(input: BulkGroupUpdateInput!): [Group!] + addGroupSubGroups(input: GroupSubGroupAddInput!): Boolean! + removeGroupSubGroups(input: GroupSubGroupRemoveInput!): Boolean! + + "Reorder sub groups within a group. Returns true if successful." + reorderSubGroups(input: ReorderSubGroupsInput!): Boolean! + tagCreate(input: TagCreateInput!): Tag tagUpdate(input: TagUpdateInput!): Tag tagDestroy(input: TagDestroyInput!): Boolean! diff --git a/graphql/schema/types/filters.graphql b/graphql/schema/types/filters.graphql index 1ca8c1fb08d..f0f84efda8c 100644 --- a/graphql/schema/types/filters.graphql +++ b/graphql/schema/types/filters.graphql @@ -261,7 +261,7 @@ input SceneFilterType { "Filter to only include scenes with this movie" movies: MultiCriterionInput @deprecated(reason: "use groups instead") "Filter to only include scenes with this group" - groups: MultiCriterionInput + groups: HierarchicalMultiCriterionInput "Filter to only include scenes with this gallery" galleries: MultiCriterionInput "Filter to only include scenes with these tags" @@ -390,6 +390,15 @@ input GroupFilterType { "Filter by last update time" updated_at: TimestampCriterionInput + "Filter by containing groups" + containing_groups: HierarchicalMultiCriterionInput + "Filter by sub groups" + sub_groups: HierarchicalMultiCriterionInput + "Filter by number of containing groups the group has" + containing_group_count: IntCriterionInput + "Filter by number of sub-groups the group has" + sub_group_count: IntCriterionInput + "Filter by related scenes that meet this criteria" scenes_filter: SceneFilterType "Filter by related studios that meet this criteria" diff --git a/graphql/schema/types/group.graphql b/graphql/schema/types/group.graphql index 15bb3556ca6..b42e4fd1fef 100644 --- a/graphql/schema/types/group.graphql +++ b/graphql/schema/types/group.graphql @@ -1,3 +1,9 @@ +"GroupDescription represents a relationship to a group with a description of the relationship" +type GroupDescription { + group: Group! + description: String +} + type Group { id: ID! name: String! @@ -15,12 +21,21 @@ type Group { created_at: Time! updated_at: Time! + containing_groups: [GroupDescription!]! + sub_groups: [GroupDescription!]! + front_image_path: String # Resolver back_image_path: String # Resolver - scene_count: Int! # Resolver + scene_count(depth: Int): Int! # Resolver + sub_group_count(depth: Int): Int! # Resolver scenes: [Scene!]! } +input GroupDescriptionInput { + group_id: ID! + description: String +} + input GroupCreateInput { name: String! aliases: String @@ -34,6 +49,10 @@ input GroupCreateInput { synopsis: String urls: [String!] tag_ids: [ID!] + + containing_groups: [GroupDescriptionInput!] + sub_groups: [GroupDescriptionInput!] + "This should be a URL or a base64 encoded data URL" front_image: String "This should be a URL or a base64 encoded data URL" @@ -53,12 +72,21 @@ input GroupUpdateInput { synopsis: String urls: [String!] tag_ids: [ID!] + + containing_groups: [GroupDescriptionInput!] + sub_groups: [GroupDescriptionInput!] + "This should be a URL or a base64 encoded data URL" front_image: String "This should be a URL or a base64 encoded data URL" back_image: String } +input BulkUpdateGroupDescriptionsInput { + groups: [GroupDescriptionInput!]! + mode: BulkUpdateIdMode! +} + input BulkGroupUpdateInput { clientMutationId: String ids: [ID!] @@ -68,13 +96,42 @@ input BulkGroupUpdateInput { director: String urls: BulkUpdateStrings tag_ids: BulkUpdateIds + + containing_groups: BulkUpdateGroupDescriptionsInput + sub_groups: BulkUpdateGroupDescriptionsInput } input GroupDestroyInput { id: ID! } +input ReorderSubGroupsInput { + "ID of the group to reorder sub groups for" + group_id: ID! + """ + IDs of the sub groups to reorder. These must be a subset of the current sub groups. + Sub groups will be inserted in this order at the insert_index + """ + sub_group_ids: [ID!]! + "The sub-group ID at which to insert the sub groups" + insert_at_id: ID! + "If true, the sub groups will be inserted after the insert_index, otherwise they will be inserted before" + insert_after: Boolean +} + type FindGroupsResultType { count: Int! groups: [Group!]! } + +input GroupSubGroupAddInput { + containing_group_id: ID! + sub_groups: [GroupDescriptionInput!]! + "The index at which to insert the sub groups. If not provided, the sub groups will be appended to the end" + insert_index: Int +} + +input GroupSubGroupRemoveInput { + containing_group_id: ID! + sub_group_ids: [ID!]! +} diff --git a/graphql/schema/types/movie.graphql b/graphql/schema/types/movie.graphql index 0723bcc4f28..845827b3f17 100644 --- a/graphql/schema/types/movie.graphql +++ b/graphql/schema/types/movie.graphql @@ -18,7 +18,7 @@ type Movie { front_image_path: String # Resolver back_image_path: String # Resolver - scene_count: Int! # Resolver + scene_count(depth: Int): Int! # Resolver scenes: [Scene!]! } diff --git a/graphql/schema/types/performer.graphql b/graphql/schema/types/performer.graphql index 8ac6c6579ad..d6f3dd832c4 100644 --- a/graphql/schema/types/performer.graphql +++ b/graphql/schema/types/performer.graphql @@ -56,7 +56,7 @@ type Performer { weight: Int created_at: Time! updated_at: Time! - groups: [Group!]! @deprecated(reason: "use groups instead") + groups: [Group!]! movies: [Movie!]! @deprecated(reason: "use groups instead") } diff --git a/internal/api/changeset_translator.go b/internal/api/changeset_translator.go index efac25087d6..1170088aac9 100644 --- a/internal/api/changeset_translator.go +++ b/internal/api/changeset_translator.go @@ -434,3 +434,64 @@ func (t changesetTranslator) updateGroupIDsBulk(value *BulkUpdateIds, field stri Mode: value.Mode, }, nil } + +func groupsDescriptionsFromGroupInput(input []*GroupDescriptionInput) ([]models.GroupIDDescription, error) { + ret := make([]models.GroupIDDescription, len(input)) + + for i, v := range input { + gID, err := strconv.Atoi(v.GroupID) + if err != nil { + return nil, fmt.Errorf("invalid group ID: %s", v.GroupID) + } + + ret[i] = models.GroupIDDescription{ + GroupID: gID, + } + if v.Description != nil { + ret[i].Description = *v.Description + } + } + + return ret, nil +} + +func (t changesetTranslator) groupIDDescriptions(value []*GroupDescriptionInput) (models.RelatedGroupDescriptions, error) { + groupsScenes, err := groupsDescriptionsFromGroupInput(value) + if err != nil { + return models.RelatedGroupDescriptions{}, err + } + + return models.NewRelatedGroupDescriptions(groupsScenes), nil +} + +func (t changesetTranslator) updateGroupIDDescriptions(value []*GroupDescriptionInput, field string) (*models.UpdateGroupDescriptions, error) { + if !t.hasField(field) { + return nil, nil + } + + groupsScenes, err := groupsDescriptionsFromGroupInput(value) + if err != nil { + return nil, err + } + + return &models.UpdateGroupDescriptions{ + Groups: groupsScenes, + Mode: models.RelationshipUpdateModeSet, + }, nil +} + +func (t changesetTranslator) updateGroupIDDescriptionsBulk(value *BulkUpdateGroupDescriptionsInput, field string) (*models.UpdateGroupDescriptions, error) { + if !t.hasField(field) || value == nil { + return nil, nil + } + + groups, err := groupsDescriptionsFromGroupInput(value.Groups) + if err != nil { + return nil, err + } + + return &models.UpdateGroupDescriptions{ + Groups: groups, + Mode: value.Mode, + }, nil +} diff --git a/internal/api/resolver.go b/internal/api/resolver.go index e5c635b9a7d..ab6eead7e5e 100644 --- a/internal/api/resolver.go +++ b/internal/api/resolver.go @@ -37,6 +37,7 @@ type Resolver struct { sceneService manager.SceneService imageService manager.ImageService galleryService manager.GalleryService + groupService manager.GroupService hookExecutor hookExecutor } diff --git a/internal/api/resolver_model_movie.go b/internal/api/resolver_model_movie.go index abbbccaf10a..04018d81fbb 100644 --- a/internal/api/resolver_model_movie.go +++ b/internal/api/resolver_model_movie.go @@ -5,7 +5,9 @@ import ( "github.com/stashapp/stash/internal/api/loaders" "github.com/stashapp/stash/internal/api/urlbuilders" + "github.com/stashapp/stash/pkg/group" "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/scene" ) func (r *groupResolver) Date(ctx context.Context, obj *models.Group) (*string, error) { @@ -71,6 +73,68 @@ func (r groupResolver) Tags(ctx context.Context, obj *models.Group) (ret []*mode return ret, firstError(errs) } +func (r groupResolver) relatedGroups(ctx context.Context, rgd models.RelatedGroupDescriptions) (ret []*GroupDescription, err error) { + // rgd must be loaded + gds := rgd.List() + ids := make([]int, len(gds)) + for i, gd := range gds { + ids[i] = gd.GroupID + } + + groups, errs := loaders.From(ctx).GroupByID.LoadAll(ids) + + err = firstError(errs) + if err != nil { + return + } + + ret = make([]*GroupDescription, len(groups)) + for i, group := range groups { + ret[i] = &GroupDescription{Group: group} + d := gds[i].Description + if d != "" { + ret[i].Description = &d + } + } + + return ret, firstError(errs) +} + +func (r groupResolver) ContainingGroups(ctx context.Context, obj *models.Group) (ret []*GroupDescription, err error) { + if !obj.ContainingGroups.Loaded() { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { + return obj.LoadContainingGroupIDs(ctx, r.repository.Group) + }); err != nil { + return nil, err + } + } + + return r.relatedGroups(ctx, obj.ContainingGroups) +} + +func (r groupResolver) SubGroups(ctx context.Context, obj *models.Group) (ret []*GroupDescription, err error) { + if !obj.SubGroups.Loaded() { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { + return obj.LoadSubGroupIDs(ctx, r.repository.Group) + }); err != nil { + return nil, err + } + } + + return r.relatedGroups(ctx, obj.SubGroups) +} + +func (r *groupResolver) SubGroupCount(ctx context.Context, obj *models.Group, depth *int) (ret int, err error) { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { + ret, err = group.CountByContainingGroupID(ctx, r.repository.Group, obj.ID, depth) + return err + }); err != nil { + return 0, err + } + + return ret, nil +} + func (r *groupResolver) FrontImagePath(ctx context.Context, obj *models.Group) (*string, error) { var hasImage bool if err := r.withReadTxn(ctx, func(ctx context.Context) error { @@ -106,9 +170,9 @@ func (r *groupResolver) BackImagePath(ctx context.Context, obj *models.Group) (* return &imagePath, nil } -func (r *groupResolver) SceneCount(ctx context.Context, obj *models.Group) (ret int, err error) { +func (r *groupResolver) SceneCount(ctx context.Context, obj *models.Group, depth *int) (ret int, err error) { if err := r.withReadTxn(ctx, func(ctx context.Context) error { - ret, err = r.repository.Scene.CountByGroupID(ctx, obj.ID) + ret, err = scene.CountByGroupID(ctx, r.repository.Scene, obj.ID, depth) return err }); err != nil { return 0, err diff --git a/internal/api/resolver_mutation_group.go b/internal/api/resolver_mutation_group.go index d455dd1058c..d75994d1497 100644 --- a/internal/api/resolver_mutation_group.go +++ b/internal/api/resolver_mutation_group.go @@ -6,6 +6,7 @@ import ( "strconv" "github.com/stashapp/stash/internal/static" + "github.com/stashapp/stash/pkg/group" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/plugin/hook" "github.com/stashapp/stash/pkg/sliceutil/stringslice" @@ -43,6 +44,16 @@ func groupFromGroupCreateInput(ctx context.Context, input GroupCreateInput) (*mo return nil, fmt.Errorf("converting tag ids: %w", err) } + newGroup.ContainingGroups, err = translator.groupIDDescriptions(input.ContainingGroups) + if err != nil { + return nil, fmt.Errorf("converting containing group ids: %w", err) + } + + newGroup.SubGroups, err = translator.groupIDDescriptions(input.SubGroups) + if err != nil { + return nil, fmt.Errorf("converting containing group ids: %w", err) + } + if input.Urls != nil { newGroup.URLs = models.NewRelatedStrings(input.Urls) } @@ -82,26 +93,10 @@ func (r *mutationResolver) GroupCreate(ctx context.Context, input GroupCreateInp // Start the transaction and save the group if err := r.withTxn(ctx, func(ctx context.Context) error { - qb := r.repository.Group - - err = qb.Create(ctx, newGroup) - if err != nil { + if err = r.groupService.Create(ctx, newGroup, frontimageData, backimageData); err != nil { return err } - // update image table - if len(frontimageData) > 0 { - if err := qb.UpdateFrontImage(ctx, newGroup.ID, frontimageData); err != nil { - return err - } - } - - if len(backimageData) > 0 { - if err := qb.UpdateBackImage(ctx, newGroup.ID, backimageData); err != nil { - return err - } - } - return nil }); err != nil { return nil, err @@ -141,6 +136,18 @@ func groupPartialFromGroupUpdateInput(translator changesetTranslator, input Grou return } + updatedGroup.ContainingGroups, err = translator.updateGroupIDDescriptions(input.ContainingGroups, "containing_groups") + if err != nil { + err = fmt.Errorf("converting containing group ids: %w", err) + return + } + + updatedGroup.SubGroups, err = translator.updateGroupIDDescriptions(input.SubGroups, "sub_groups") + if err != nil { + err = fmt.Errorf("converting containing group ids: %w", err) + return + } + updatedGroup.URLs = translator.updateStrings(input.Urls, "urls") return updatedGroup, nil @@ -179,26 +186,20 @@ func (r *mutationResolver) GroupUpdate(ctx context.Context, input GroupUpdateInp } } - // Start the transaction and save the group - var group *models.Group if err := r.withTxn(ctx, func(ctx context.Context) error { - qb := r.repository.Group - group, err = qb.UpdatePartial(ctx, groupID, updatedGroup) - if err != nil { - return err + frontImage := group.ImageInput{ + Image: frontimageData, + Set: frontImageIncluded, } - // update image table - if frontImageIncluded { - if err := qb.UpdateFrontImage(ctx, group.ID, frontimageData); err != nil { - return err - } + backImage := group.ImageInput{ + Image: backimageData, + Set: backImageIncluded, } - if backImageIncluded { - if err := qb.UpdateBackImage(ctx, group.ID, backimageData); err != nil { - return err - } + _, err = r.groupService.UpdatePartial(ctx, groupID, updatedGroup, frontImage, backImage) + if err != nil { + return err } return nil @@ -207,9 +208,9 @@ func (r *mutationResolver) GroupUpdate(ctx context.Context, input GroupUpdateInp } // for backwards compatibility - run both movie and group hooks - r.hookExecutor.ExecutePostHooks(ctx, group.ID, hook.GroupUpdatePost, input, translator.getFields()) - r.hookExecutor.ExecutePostHooks(ctx, group.ID, hook.MovieUpdatePost, input, translator.getFields()) - return r.getGroup(ctx, group.ID) + r.hookExecutor.ExecutePostHooks(ctx, groupID, hook.GroupUpdatePost, input, translator.getFields()) + r.hookExecutor.ExecutePostHooks(ctx, groupID, hook.MovieUpdatePost, input, translator.getFields()) + return r.getGroup(ctx, groupID) } func groupPartialFromBulkGroupUpdateInput(translator changesetTranslator, input BulkGroupUpdateInput) (ret models.GroupPartial, err error) { @@ -230,6 +231,18 @@ func groupPartialFromBulkGroupUpdateInput(translator changesetTranslator, input return } + updatedGroup.ContainingGroups, err = translator.updateGroupIDDescriptionsBulk(input.ContainingGroups, "containing_groups") + if err != nil { + err = fmt.Errorf("converting containing group ids: %w", err) + return + } + + updatedGroup.SubGroups, err = translator.updateGroupIDDescriptionsBulk(input.SubGroups, "sub_groups") + if err != nil { + err = fmt.Errorf("converting containing group ids: %w", err) + return + } + updatedGroup.URLs = translator.optionalURLsBulk(input.Urls, nil) return updatedGroup, nil @@ -254,10 +267,8 @@ func (r *mutationResolver) BulkGroupUpdate(ctx context.Context, input BulkGroupU ret := []*models.Group{} if err := r.withTxn(ctx, func(ctx context.Context) error { - qb := r.repository.Group - for _, groupID := range groupIDs { - group, err := qb.UpdatePartial(ctx, groupID, updatedGroup) + group, err := r.groupService.UpdatePartial(ctx, groupID, updatedGroup, group.ImageInput{}, group.ImageInput{}) if err != nil { return err } @@ -333,3 +344,70 @@ func (r *mutationResolver) GroupsDestroy(ctx context.Context, groupIDs []string) return true, nil } + +func (r *mutationResolver) AddGroupSubGroups(ctx context.Context, input GroupSubGroupAddInput) (bool, error) { + groupID, err := strconv.Atoi(input.ContainingGroupID) + if err != nil { + return false, fmt.Errorf("converting group id: %w", err) + } + + subGroups, err := groupsDescriptionsFromGroupInput(input.SubGroups) + if err != nil { + return false, fmt.Errorf("converting sub group ids: %w", err) + } + + if err := r.withTxn(ctx, func(ctx context.Context) error { + return r.groupService.AddSubGroups(ctx, groupID, subGroups, input.InsertIndex) + }); err != nil { + return false, err + } + + return true, nil +} + +func (r *mutationResolver) RemoveGroupSubGroups(ctx context.Context, input GroupSubGroupRemoveInput) (bool, error) { + groupID, err := strconv.Atoi(input.ContainingGroupID) + if err != nil { + return false, fmt.Errorf("converting group id: %w", err) + } + + subGroupIDs, err := stringslice.StringSliceToIntSlice(input.SubGroupIds) + if err != nil { + return false, fmt.Errorf("converting sub group ids: %w", err) + } + + if err := r.withTxn(ctx, func(ctx context.Context) error { + return r.groupService.RemoveSubGroups(ctx, groupID, subGroupIDs) + }); err != nil { + return false, err + } + + return true, nil +} + +func (r *mutationResolver) ReorderSubGroups(ctx context.Context, input ReorderSubGroupsInput) (bool, error) { + groupID, err := strconv.Atoi(input.GroupID) + if err != nil { + return false, fmt.Errorf("converting group id: %w", err) + } + + subGroupIDs, err := stringslice.StringSliceToIntSlice(input.SubGroupIds) + if err != nil { + return false, fmt.Errorf("converting sub group ids: %w", err) + } + + insertPointID, err := strconv.Atoi(input.InsertAtID) + if err != nil { + return false, fmt.Errorf("converting insert at id: %w", err) + } + + insertAfter := utils.IsTrue(input.InsertAfter) + + if err := r.withTxn(ctx, func(ctx context.Context) error { + return r.groupService.ReorderSubGroups(ctx, groupID, subGroupIDs, insertPointID, insertAfter) + }); err != nil { + return false, err + } + + return true, nil +} diff --git a/internal/api/server.go b/internal/api/server.go index b32ee04a027..63a81da7c2e 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -158,11 +158,13 @@ func Initialize() (*Server, error) { sceneService := mgr.SceneService imageService := mgr.ImageService galleryService := mgr.GalleryService + groupService := mgr.GroupService resolver := &Resolver{ repository: repo, sceneService: sceneService, imageService: imageService, galleryService: galleryService, + groupService: groupService, hookExecutor: pluginCache, } diff --git a/internal/dlna/cds.go b/internal/dlna/cds.go index 531fc1cb55c..a38e0e55bed 100644 --- a/internal/dlna/cds.go +++ b/internal/dlna/cds.go @@ -682,7 +682,7 @@ func (me *contentDirectoryService) getGroups() []interface{} { func (me *contentDirectoryService) getGroupScenes(paths []string, host string) []interface{} { sceneFilter := &models.SceneFilterType{ - Groups: &models.MultiCriterionInput{ + Groups: &models.HierarchicalMultiCriterionInput{ Modifier: models.CriterionModifierIncludes, Value: []string{paths[0]}, }, diff --git a/internal/manager/init.go b/internal/manager/init.go index 347d08a153e..020ba944d40 100644 --- a/internal/manager/init.go +++ b/internal/manager/init.go @@ -17,6 +17,7 @@ import ( "github.com/stashapp/stash/pkg/ffmpeg" "github.com/stashapp/stash/pkg/fsutil" "github.com/stashapp/stash/pkg/gallery" + "github.com/stashapp/stash/pkg/group" "github.com/stashapp/stash/pkg/image" "github.com/stashapp/stash/pkg/job" "github.com/stashapp/stash/pkg/logger" @@ -67,6 +68,10 @@ func Initialize(cfg *config.Config, l *log.Logger) (*Manager, error) { Folder: db.Folder, } + groupService := &group.Service{ + Repository: db.Group, + } + sceneServer := &SceneServer{ TxnManager: repo.TxnManager, SceneCoverGetter: repo.Scene, @@ -99,6 +104,7 @@ func Initialize(cfg *config.Config, l *log.Logger) (*Manager, error) { SceneService: sceneService, ImageService: imageService, GalleryService: galleryService, + GroupService: groupService, scanSubs: &subscriptionManager{}, } diff --git a/internal/manager/manager.go b/internal/manager/manager.go index 397503930dc..ffba184d2bd 100644 --- a/internal/manager/manager.go +++ b/internal/manager/manager.go @@ -66,6 +66,7 @@ type Manager struct { SceneService SceneService ImageService ImageService GalleryService GalleryService + GroupService GroupService scanSubs *subscriptionManager } diff --git a/internal/manager/repository.go b/internal/manager/repository.go index 766f8039f85..13e1e8ae81b 100644 --- a/internal/manager/repository.go +++ b/internal/manager/repository.go @@ -3,6 +3,7 @@ package manager import ( "context" + "github.com/stashapp/stash/pkg/group" "github.com/stashapp/stash/pkg/image" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/scene" @@ -33,3 +34,12 @@ type GalleryService interface { Updated(ctx context.Context, galleryID int) error } + +type GroupService interface { + Create(ctx context.Context, group *models.Group, frontimageData []byte, backimageData []byte) error + UpdatePartial(ctx context.Context, id int, updatedGroup models.GroupPartial, frontImage group.ImageInput, backImage group.ImageInput) (*models.Group, error) + + AddSubGroups(ctx context.Context, groupID int, subGroups []models.GroupIDDescription, insertIndex *int) error + RemoveSubGroups(ctx context.Context, groupID int, subGroupIDs []int) error + ReorderSubGroups(ctx context.Context, groupID int, subGroupIDs []int, insertPointID int, insertAfter bool) error +} diff --git a/internal/manager/task_export.go b/internal/manager/task_export.go index ecbcf593af5..19abba2158d 100644 --- a/internal/manager/task_export.go +++ b/internal/manager/task_export.go @@ -1134,6 +1134,10 @@ func (t *ExportTask) exportGroup(ctx context.Context, wg *sync.WaitGroup, jobCha logger.Errorf("[groups] <%s> error getting group urls: %v", m.Name, err) continue } + if err := m.LoadSubGroupIDs(ctx, r.Group); err != nil { + logger.Errorf("[groups] <%s> error getting group sub-groups: %v", m.Name, err) + continue + } newGroupJSON, err := group.ToJSON(ctx, groupReader, studioReader, m) @@ -1150,6 +1154,25 @@ func (t *ExportTask) exportGroup(ctx context.Context, wg *sync.WaitGroup, jobCha newGroupJSON.Tags = tag.GetNames(tags) + subGroups := m.SubGroups.List() + if err := func() error { + for _, sg := range subGroups { + subGroup, err := groupReader.Find(ctx, sg.GroupID) + if err != nil { + return fmt.Errorf("error getting sub group: %v", err) + } + + newGroupJSON.SubGroups = append(newGroupJSON.SubGroups, jsonschema.SubGroupDescription{ + // TODO - this won't be unique + Group: subGroup.Name, + Description: sg.Description, + }) + } + return nil + }(); err != nil { + logger.Errorf("[groups] <%s> %v", m.Name, err) + } + if t.includeDependencies { if m.StudioID != nil { t.studios.IDs = sliceutil.AppendUnique(t.studios.IDs, *m.StudioID) diff --git a/internal/manager/task_import.go b/internal/manager/task_import.go index ae9a5865765..87185c66183 100644 --- a/internal/manager/task_import.go +++ b/internal/manager/task_import.go @@ -327,6 +327,7 @@ func (t *ImportTask) importStudio(ctx context.Context, studioJSON *jsonschema.St func (t *ImportTask) ImportGroups(ctx context.Context) { logger.Info("[groups] importing") + pendingSubs := make(map[string][]*jsonschema.Group) path := t.json.json.Groups files, err := os.ReadDir(path) @@ -351,24 +352,72 @@ func (t *ImportTask) ImportGroups(ctx context.Context) { logger.Progressf("[groups] %d of %d", index, len(files)) if err := r.WithTxn(ctx, func(ctx context.Context) error { - groupImporter := &group.Importer{ - ReaderWriter: r.Group, - StudioWriter: r.Studio, - TagWriter: r.Tag, - Input: *groupJSON, - MissingRefBehaviour: t.MissingRefBehaviour, + return t.importGroup(ctx, groupJSON, pendingSubs, false) + }); err != nil { + var subError group.SubGroupNotExistError + if errors.As(err, &subError) { + missingSub := subError.MissingSubGroup() + pendingSubs[missingSub] = append(pendingSubs[missingSub], groupJSON) + continue } - return performImport(ctx, groupImporter, t.DuplicateBehaviour) - }); err != nil { - logger.Errorf("[groups] <%s> import failed: %v", fi.Name(), err) + logger.Errorf("[groups] <%s> failed to import: %v", fi.Name(), err) continue } } + for _, s := range pendingSubs { + for _, orphanGroupJSON := range s { + if err := r.WithTxn(ctx, func(ctx context.Context) error { + return t.importGroup(ctx, orphanGroupJSON, nil, true) + }); err != nil { + logger.Errorf("[groups] <%s> failed to create: %v", orphanGroupJSON.Name, err) + continue + } + } + } + logger.Info("[groups] import complete") } +func (t *ImportTask) importGroup(ctx context.Context, groupJSON *jsonschema.Group, pendingSub map[string][]*jsonschema.Group, fail bool) error { + r := t.repository + + importer := &group.Importer{ + ReaderWriter: r.Group, + StudioWriter: r.Studio, + TagWriter: r.Tag, + Input: *groupJSON, + MissingRefBehaviour: t.MissingRefBehaviour, + } + + // first phase: return error if parent does not exist + if !fail { + importer.MissingRefBehaviour = models.ImportMissingRefEnumFail + } + + if err := performImport(ctx, importer, t.DuplicateBehaviour); err != nil { + return err + } + + for _, containingGroupJSON := range pendingSub[groupJSON.Name] { + if err := t.importGroup(ctx, containingGroupJSON, pendingSub, fail); err != nil { + var subError group.SubGroupNotExistError + if errors.As(err, &subError) { + missingSub := subError.MissingSubGroup() + pendingSub[missingSub] = append(pendingSub[missingSub], containingGroupJSON) + continue + } + + return fmt.Errorf("failed to create containing group <%s>: %v", containingGroupJSON.Name, err) + } + } + + delete(pendingSub, groupJSON.Name) + + return nil +} + func (t *ImportTask) ImportFiles(ctx context.Context) { logger.Info("[files] importing") diff --git a/pkg/group/create.go b/pkg/group/create.go new file mode 100644 index 00000000000..56d6b7a4ed4 --- /dev/null +++ b/pkg/group/create.go @@ -0,0 +1,41 @@ +package group + +import ( + "context" + "errors" + + "github.com/stashapp/stash/pkg/models" +) + +var ( + ErrEmptyName = errors.New("name cannot be empty") + ErrHierarchyLoop = errors.New("a group cannot be contained by one of its subgroups") +) + +func (s *Service) Create(ctx context.Context, group *models.Group, frontimageData []byte, backimageData []byte) error { + r := s.Repository + + if err := s.validateCreate(ctx, group); err != nil { + return err + } + + err := r.Create(ctx, group) + if err != nil { + return err + } + + // update image table + if len(frontimageData) > 0 { + if err := r.UpdateFrontImage(ctx, group.ID, frontimageData); err != nil { + return err + } + } + + if len(backimageData) > 0 { + if err := r.UpdateBackImage(ctx, group.ID, backimageData); err != nil { + return err + } + } + + return nil +} diff --git a/pkg/group/import.go b/pkg/group/import.go index 4bf038c8776..589e75df30d 100644 --- a/pkg/group/import.go +++ b/pkg/group/import.go @@ -16,6 +16,18 @@ type ImporterReaderWriter interface { FindByName(ctx context.Context, name string, nocase bool) (*models.Group, error) } +type SubGroupNotExistError struct { + missingSubGroup string +} + +func (e SubGroupNotExistError) Error() string { + return fmt.Sprintf("sub group <%s> does not exist", e.missingSubGroup) +} + +func (e SubGroupNotExistError) MissingSubGroup() string { + return e.missingSubGroup +} + type Importer struct { ReaderWriter ImporterReaderWriter StudioWriter models.StudioFinderCreator @@ -202,6 +214,22 @@ func (i *Importer) createStudio(ctx context.Context, name string) (int, error) { } func (i *Importer) PostImport(ctx context.Context, id int) error { + subGroups, err := i.getSubGroups(ctx) + if err != nil { + return err + } + + if len(subGroups) > 0 { + if _, err := i.ReaderWriter.UpdatePartial(ctx, id, models.GroupPartial{ + SubGroups: &models.UpdateGroupDescriptions{ + Groups: subGroups, + Mode: models.RelationshipUpdateModeSet, + }, + }); err != nil { + return fmt.Errorf("error setting parents: %v", err) + } + } + if len(i.frontImageData) > 0 { if err := i.ReaderWriter.UpdateFrontImage(ctx, id, i.frontImageData); err != nil { return fmt.Errorf("error setting group front image: %v", err) @@ -256,3 +284,53 @@ func (i *Importer) Update(ctx context.Context, id int) error { return nil } + +func (i *Importer) getSubGroups(ctx context.Context) ([]models.GroupIDDescription, error) { + var subGroups []models.GroupIDDescription + for _, subGroup := range i.Input.SubGroups { + group, err := i.ReaderWriter.FindByName(ctx, subGroup.Group, false) + if err != nil { + return nil, fmt.Errorf("error finding parent by name: %v", err) + } + + if group == nil { + if i.MissingRefBehaviour == models.ImportMissingRefEnumFail { + return nil, SubGroupNotExistError{missingSubGroup: subGroup.Group} + } + + if i.MissingRefBehaviour == models.ImportMissingRefEnumIgnore { + continue + } + + if i.MissingRefBehaviour == models.ImportMissingRefEnumCreate { + parentID, err := i.createSubGroup(ctx, subGroup.Group) + if err != nil { + return nil, err + } + subGroups = append(subGroups, models.GroupIDDescription{ + GroupID: parentID, + Description: subGroup.Description, + }) + } + } else { + subGroups = append(subGroups, models.GroupIDDescription{ + GroupID: group.ID, + Description: subGroup.Description, + }) + } + } + + return subGroups, nil +} + +func (i *Importer) createSubGroup(ctx context.Context, name string) (int, error) { + newGroup := models.NewGroup() + newGroup.Name = name + + err := i.ReaderWriter.Create(ctx, &newGroup) + if err != nil { + return 0, err + } + + return newGroup.ID, nil +} diff --git a/pkg/group/query.go b/pkg/group/query.go index bc0753b0055..b3adafaf523 100644 --- a/pkg/group/query.go +++ b/pkg/group/query.go @@ -30,3 +30,15 @@ func CountByTagID(ctx context.Context, r models.GroupQueryer, id int, depth *int return r.QueryCount(ctx, filter, nil) } + +func CountByContainingGroupID(ctx context.Context, r models.GroupQueryer, id int, depth *int) (int, error) { + filter := &models.GroupFilterType{ + ContainingGroups: &models.HierarchicalMultiCriterionInput{ + Value: []string{strconv.Itoa(id)}, + Modifier: models.CriterionModifierIncludes, + Depth: depth, + }, + } + + return r.QueryCount(ctx, filter, nil) +} diff --git a/pkg/group/reorder.go b/pkg/group/reorder.go new file mode 100644 index 00000000000..b4afd1b0968 --- /dev/null +++ b/pkg/group/reorder.go @@ -0,0 +1,33 @@ +package group + +import ( + "context" + "errors" + + "github.com/stashapp/stash/pkg/models" +) + +var ErrInvalidInsertIndex = errors.New("invalid insert index") + +func (s *Service) ReorderSubGroups(ctx context.Context, groupID int, subGroupIDs []int, insertPointID int, insertAfter bool) error { + // get the group + existing, err := s.Repository.Find(ctx, groupID) + if err != nil { + return err + } + + // ensure it exists + if existing == nil { + return models.ErrNotFound + } + + // TODO - ensure the subgroups exist in the group + + // ensure the insert index is valid + if insertPointID < 0 { + return ErrInvalidInsertIndex + } + + // reorder the subgroups + return s.Repository.ReorderSubGroups(ctx, groupID, subGroupIDs, insertPointID, insertAfter) +} diff --git a/pkg/group/service.go b/pkg/group/service.go new file mode 100644 index 00000000000..ff6e0354184 --- /dev/null +++ b/pkg/group/service.go @@ -0,0 +1,46 @@ +package group + +import ( + "context" + + "github.com/stashapp/stash/pkg/models" +) + +type CreatorUpdater interface { + models.GroupGetter + models.GroupCreator + models.GroupUpdater + + models.ContainingGroupLoader + models.SubGroupLoader + + AnscestorFinder + SubGroupIDFinder + SubGroupAdder + SubGroupRemover + SubGroupReorderer +} + +type AnscestorFinder interface { + FindInAncestors(ctx context.Context, ascestorIDs []int, ids []int) ([]int, error) +} + +type SubGroupIDFinder interface { + FindSubGroupIDs(ctx context.Context, containingID int, ids []int) ([]int, error) +} + +type SubGroupAdder interface { + AddSubGroups(ctx context.Context, groupID int, subGroups []models.GroupIDDescription, insertIndex *int) error +} + +type SubGroupRemover interface { + RemoveSubGroups(ctx context.Context, groupID int, subGroupIDs []int) error +} + +type SubGroupReorderer interface { + ReorderSubGroups(ctx context.Context, groupID int, subGroupIDs []int, insertID int, insertAfter bool) error +} + +type Service struct { + Repository CreatorUpdater +} diff --git a/pkg/group/update.go b/pkg/group/update.go new file mode 100644 index 00000000000..d0bc9602add --- /dev/null +++ b/pkg/group/update.go @@ -0,0 +1,112 @@ +package group + +import ( + "context" + "fmt" + + "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/sliceutil" +) + +type SubGroupAlreadyInGroupError struct { + GroupIDs []int +} + +func (e *SubGroupAlreadyInGroupError) Error() string { + return fmt.Sprintf("subgroups with IDs %v already in group", e.GroupIDs) +} + +type ImageInput struct { + Image []byte + Set bool +} + +func (s *Service) UpdatePartial(ctx context.Context, id int, updatedGroup models.GroupPartial, frontImage ImageInput, backImage ImageInput) (*models.Group, error) { + if err := s.validateUpdate(ctx, id, updatedGroup); err != nil { + return nil, err + } + + r := s.Repository + + group, err := r.UpdatePartial(ctx, id, updatedGroup) + if err != nil { + return nil, err + } + + // update image table + if frontImage.Set { + if err := r.UpdateFrontImage(ctx, id, frontImage.Image); err != nil { + return nil, err + } + } + + if backImage.Set { + if err := r.UpdateBackImage(ctx, id, backImage.Image); err != nil { + return nil, err + } + } + + return group, nil +} + +func (s *Service) AddSubGroups(ctx context.Context, groupID int, subGroups []models.GroupIDDescription, insertIndex *int) error { + // get the group + existing, err := s.Repository.Find(ctx, groupID) + if err != nil { + return err + } + + // ensure it exists + if existing == nil { + return models.ErrNotFound + } + + // ensure the subgroups aren't already sub-groups of the group + subGroupIDs := sliceutil.Map(subGroups, func(sg models.GroupIDDescription) int { + return sg.GroupID + }) + + existingSubGroupIDs, err := s.Repository.FindSubGroupIDs(ctx, groupID, subGroupIDs) + if err != nil { + return err + } + + if len(existingSubGroupIDs) > 0 { + return &SubGroupAlreadyInGroupError{ + GroupIDs: existingSubGroupIDs, + } + } + + // validate the hierarchy + d := &models.UpdateGroupDescriptions{ + Groups: subGroups, + Mode: models.RelationshipUpdateModeAdd, + } + if err := s.validateUpdateGroupHierarchy(ctx, existing, nil, d); err != nil { + return err + } + + // validate insert index + if insertIndex != nil && *insertIndex < 0 { + return ErrInvalidInsertIndex + } + + // add the subgroups + return s.Repository.AddSubGroups(ctx, groupID, subGroups, insertIndex) +} + +func (s *Service) RemoveSubGroups(ctx context.Context, groupID int, subGroupIDs []int) error { + // get the group + existing, err := s.Repository.Find(ctx, groupID) + if err != nil { + return err + } + + // ensure it exists + if existing == nil { + return models.ErrNotFound + } + + // add the subgroups + return s.Repository.RemoveSubGroups(ctx, groupID, subGroupIDs) +} diff --git a/pkg/group/validate.go b/pkg/group/validate.go new file mode 100644 index 00000000000..723b9f6997a --- /dev/null +++ b/pkg/group/validate.go @@ -0,0 +1,117 @@ +package group + +import ( + "context" + "strings" + + "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/sliceutil" +) + +func (s *Service) validateCreate(ctx context.Context, group *models.Group) error { + if err := validateName(group.Name); err != nil { + return err + } + + containingIDs := group.ContainingGroups.IDs() + subIDs := group.SubGroups.IDs() + + if err := s.validateGroupHierarchy(ctx, containingIDs, subIDs); err != nil { + return err + } + + return nil +} + +func (s *Service) validateUpdate(ctx context.Context, id int, partial models.GroupPartial) error { + // get the existing group - ensure it exists + existing, err := s.Repository.Find(ctx, id) + if err != nil { + return err + } + + if existing == nil { + return models.ErrNotFound + } + + if partial.Name.Set { + if err := validateName(partial.Name.Value); err != nil { + return err + } + } + + if err := s.validateUpdateGroupHierarchy(ctx, existing, partial.ContainingGroups, partial.SubGroups); err != nil { + return err + } + + return nil +} + +func validateName(n string) error { + // ensure name is not empty + if strings.TrimSpace(n) == "" { + return ErrEmptyName + } + + return nil +} + +func (s *Service) validateGroupHierarchy(ctx context.Context, containingIDs []int, subIDs []int) error { + // only need to validate if both are non-empty + if len(containingIDs) == 0 || len(subIDs) == 0 { + return nil + } + + // ensure none of the containing groups are in the sub groups + found, err := s.Repository.FindInAncestors(ctx, containingIDs, subIDs) + if err != nil { + return err + } + + if len(found) > 0 { + return ErrHierarchyLoop + } + + return nil +} + +func (s *Service) validateUpdateGroupHierarchy(ctx context.Context, existing *models.Group, containingGroups *models.UpdateGroupDescriptions, subGroups *models.UpdateGroupDescriptions) error { + // no need to validate if there are no changes + if containingGroups == nil && subGroups == nil { + return nil + } + + if err := existing.LoadContainingGroupIDs(ctx, s.Repository); err != nil { + return err + } + existingContainingGroups := existing.ContainingGroups.List() + + if err := existing.LoadSubGroupIDs(ctx, s.Repository); err != nil { + return err + } + existingSubGroups := existing.SubGroups.List() + + effectiveContainingGroups := existingContainingGroups + if containingGroups != nil { + effectiveContainingGroups = containingGroups.Apply(existingContainingGroups) + } + + effectiveSubGroups := existingSubGroups + if subGroups != nil { + effectiveSubGroups = subGroups.Apply(existingSubGroups) + } + + containingIDs := idsFromGroupDescriptions(effectiveContainingGroups) + subIDs := idsFromGroupDescriptions(effectiveSubGroups) + + // ensure we haven't set the group as a subgroup of itself + if sliceutil.Contains(containingIDs, existing.ID) || sliceutil.Contains(subIDs, existing.ID) { + return ErrHierarchyLoop + } + + return s.validateGroupHierarchy(ctx, containingIDs, subIDs) +} + +func idsFromGroupDescriptions(v []models.GroupIDDescription) []int { + return sliceutil.Map(v, func(g models.GroupIDDescription) int { return g.GroupID }) +} diff --git a/pkg/models/group.go b/pkg/models/group.go index db7badccc90..6afda3f4890 100644 --- a/pkg/models/group.go +++ b/pkg/models/group.go @@ -23,6 +23,14 @@ type GroupFilterType struct { TagCount *IntCriterionInput `json:"tag_count"` // Filter by date Date *DateCriterionInput `json:"date"` + // Filter by containing groups + ContainingGroups *HierarchicalMultiCriterionInput `json:"containing_groups"` + // Filter by sub groups + SubGroups *HierarchicalMultiCriterionInput `json:"sub_groups"` + // Filter by number of containing groups the group has + ContainingGroupCount *IntCriterionInput `json:"containing_group_count"` + // Filter by number of sub-groups the group has + SubGroupCount *IntCriterionInput `json:"sub_group_count"` // Filter by related scenes that meet this criteria ScenesFilter *SceneFilterType `json:"scenes_filter"` // Filter by related studios that meet this criteria diff --git a/pkg/models/jsonschema/group.go b/pkg/models/jsonschema/group.go index fcf1ffe60a0..b284dab6e77 100644 --- a/pkg/models/jsonschema/group.go +++ b/pkg/models/jsonschema/group.go @@ -11,21 +11,27 @@ import ( "github.com/stashapp/stash/pkg/models/json" ) +type SubGroupDescription struct { + Group string `json:"name,omitempty"` + Description string `json:"description,omitempty"` +} + type Group struct { - Name string `json:"name,omitempty"` - Aliases string `json:"aliases,omitempty"` - Duration int `json:"duration,omitempty"` - Date string `json:"date,omitempty"` - Rating int `json:"rating,omitempty"` - Director string `json:"director,omitempty"` - Synopsis string `json:"synopsis,omitempty"` - FrontImage string `json:"front_image,omitempty"` - BackImage string `json:"back_image,omitempty"` - URLs []string `json:"urls,omitempty"` - Studio string `json:"studio,omitempty"` - Tags []string `json:"tags,omitempty"` - CreatedAt json.JSONTime `json:"created_at,omitempty"` - UpdatedAt json.JSONTime `json:"updated_at,omitempty"` + Name string `json:"name,omitempty"` + Aliases string `json:"aliases,omitempty"` + Duration int `json:"duration,omitempty"` + Date string `json:"date,omitempty"` + Rating int `json:"rating,omitempty"` + Director string `json:"director,omitempty"` + Synopsis string `json:"synopsis,omitempty"` + FrontImage string `json:"front_image,omitempty"` + BackImage string `json:"back_image,omitempty"` + URLs []string `json:"urls,omitempty"` + Studio string `json:"studio,omitempty"` + Tags []string `json:"tags,omitempty"` + SubGroups []SubGroupDescription `json:"sub_groups,omitempty"` + CreatedAt json.JSONTime `json:"created_at,omitempty"` + UpdatedAt json.JSONTime `json:"updated_at,omitempty"` // deprecated - for import only URL string `json:"url,omitempty"` diff --git a/pkg/models/mocks/GroupReaderWriter.go b/pkg/models/mocks/GroupReaderWriter.go index 5e3a2644ca8..dc745d09487 100644 --- a/pkg/models/mocks/GroupReaderWriter.go +++ b/pkg/models/mocks/GroupReaderWriter.go @@ -289,6 +289,29 @@ func (_m *GroupReaderWriter) GetBackImage(ctx context.Context, groupID int) ([]b return r0, r1 } +// GetContainingGroupDescriptions provides a mock function with given fields: ctx, id +func (_m *GroupReaderWriter) GetContainingGroupDescriptions(ctx context.Context, id int) ([]models.GroupIDDescription, error) { + ret := _m.Called(ctx, id) + + var r0 []models.GroupIDDescription + if rf, ok := ret.Get(0).(func(context.Context, int) []models.GroupIDDescription); ok { + r0 = rf(ctx, id) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]models.GroupIDDescription) + } + } + + 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 +} + // GetFrontImage provides a mock function with given fields: ctx, groupID func (_m *GroupReaderWriter) GetFrontImage(ctx context.Context, groupID int) ([]byte, error) { ret := _m.Called(ctx, groupID) @@ -312,6 +335,29 @@ func (_m *GroupReaderWriter) GetFrontImage(ctx context.Context, groupID int) ([] return r0, r1 } +// GetSubGroupDescriptions provides a mock function with given fields: ctx, id +func (_m *GroupReaderWriter) GetSubGroupDescriptions(ctx context.Context, id int) ([]models.GroupIDDescription, error) { + ret := _m.Called(ctx, id) + + var r0 []models.GroupIDDescription + if rf, ok := ret.Get(0).(func(context.Context, int) []models.GroupIDDescription); ok { + r0 = rf(ctx, id) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]models.GroupIDDescription) + } + } + + 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 +} + // GetTagIDs provides a mock function with given fields: ctx, relatedID func (_m *GroupReaderWriter) GetTagIDs(ctx context.Context, relatedID int) ([]int, error) { ret := _m.Called(ctx, relatedID) diff --git a/pkg/models/model_group.go b/pkg/models/model_group.go index af3ac56c68c..82c71996ae8 100644 --- a/pkg/models/model_group.go +++ b/pkg/models/model_group.go @@ -21,6 +21,9 @@ type Group struct { URLs RelatedStrings `json:"urls"` TagIDs RelatedIDs `json:"tag_ids"` + + ContainingGroups RelatedGroupDescriptions `json:"containing_groups"` + SubGroups RelatedGroupDescriptions `json:"sub_groups"` } func NewGroup() Group { @@ -43,20 +46,34 @@ func (m *Group) LoadTagIDs(ctx context.Context, l TagIDLoader) error { }) } +func (m *Group) LoadContainingGroupIDs(ctx context.Context, l ContainingGroupLoader) error { + return m.ContainingGroups.load(func() ([]GroupIDDescription, error) { + return l.GetContainingGroupDescriptions(ctx, m.ID) + }) +} + +func (m *Group) LoadSubGroupIDs(ctx context.Context, l SubGroupLoader) error { + return m.SubGroups.load(func() ([]GroupIDDescription, error) { + return l.GetSubGroupDescriptions(ctx, m.ID) + }) +} + type GroupPartial struct { Name OptionalString Aliases OptionalString Duration OptionalInt Date OptionalDate // Rating expressed in 1-100 scale - Rating OptionalInt - StudioID OptionalInt - Director OptionalString - Synopsis OptionalString - URLs *UpdateStrings - TagIDs *UpdateIDs - CreatedAt OptionalTime - UpdatedAt OptionalTime + Rating OptionalInt + StudioID OptionalInt + Director OptionalString + Synopsis OptionalString + URLs *UpdateStrings + TagIDs *UpdateIDs + ContainingGroups *UpdateGroupDescriptions + SubGroups *UpdateGroupDescriptions + CreatedAt OptionalTime + UpdatedAt OptionalTime } func NewGroupPartial() GroupPartial { diff --git a/pkg/models/model_joins.go b/pkg/models/model_joins.go index 189c2d7721f..7b7cae3e46a 100644 --- a/pkg/models/model_joins.go +++ b/pkg/models/model_joins.go @@ -68,3 +68,8 @@ func GroupsScenesFromInput(input []SceneMovieInput) ([]GroupsScenes, error) { return ret, nil } + +type GroupIDDescription struct { + GroupID int `json:"group_id"` + Description string `json:"description"` +} diff --git a/pkg/models/relationships.go b/pkg/models/relationships.go index 81528c26e95..5495f858b17 100644 --- a/pkg/models/relationships.go +++ b/pkg/models/relationships.go @@ -2,6 +2,8 @@ package models import ( "context" + + "github.com/stashapp/stash/pkg/sliceutil" ) type SceneIDLoader interface { @@ -37,6 +39,14 @@ type SceneGroupLoader interface { GetGroups(ctx context.Context, id int) ([]GroupsScenes, error) } +type ContainingGroupLoader interface { + GetContainingGroupDescriptions(ctx context.Context, id int) ([]GroupIDDescription, error) +} + +type SubGroupLoader interface { + GetSubGroupDescriptions(ctx context.Context, id int) ([]GroupIDDescription, error) +} + type StashIDLoader interface { GetStashIDs(ctx context.Context, relatedID int) ([]StashID, error) } @@ -185,6 +195,82 @@ func (r *RelatedGroups) load(fn func() ([]GroupsScenes, error)) error { return nil } +type RelatedGroupDescriptions struct { + list []GroupIDDescription +} + +// NewRelatedGroups returns a loaded RelateGroups object with the provided groups. +// Loaded will return true when called on the returned object if the provided slice is not nil. +func NewRelatedGroupDescriptions(list []GroupIDDescription) RelatedGroupDescriptions { + return RelatedGroupDescriptions{ + list: list, + } +} + +// Loaded returns true if the relationship has been loaded. +func (r RelatedGroupDescriptions) Loaded() bool { + return r.list != nil +} + +func (r RelatedGroupDescriptions) mustLoaded() { + if !r.Loaded() { + panic("list has not been loaded") + } +} + +// List returns the related Groups. Panics if the relationship has not been loaded. +func (r RelatedGroupDescriptions) List() []GroupIDDescription { + r.mustLoaded() + + return r.list +} + +// List returns the related Groups. Panics if the relationship has not been loaded. +func (r RelatedGroupDescriptions) IDs() []int { + r.mustLoaded() + + return sliceutil.Map(r.list, func(d GroupIDDescription) int { return d.GroupID }) +} + +// Add adds the provided ids to the list. Panics if the relationship has not been loaded. +func (r *RelatedGroupDescriptions) Add(groups ...GroupIDDescription) { + r.mustLoaded() + + r.list = append(r.list, groups...) +} + +// ForID returns the GroupsScenes object for the given group ID. Returns nil if not found. +func (r *RelatedGroupDescriptions) ForID(id int) *GroupIDDescription { + r.mustLoaded() + + for _, v := range r.list { + if v.GroupID == id { + return &v + } + } + + return nil +} + +func (r *RelatedGroupDescriptions) load(fn func() ([]GroupIDDescription, error)) error { + if r.Loaded() { + return nil + } + + ids, err := fn() + if err != nil { + return err + } + + if ids == nil { + ids = []GroupIDDescription{} + } + + r.list = ids + + return nil +} + type RelatedStashIDs struct { list []StashID } diff --git a/pkg/models/repository_group.go b/pkg/models/repository_group.go index 0396049b66e..704390d77b3 100644 --- a/pkg/models/repository_group.go +++ b/pkg/models/repository_group.go @@ -66,6 +66,8 @@ type GroupReader interface { GroupCounter URLLoader TagIDLoader + ContainingGroupLoader + SubGroupLoader All(ctx context.Context) ([]*Group, error) GetFrontImage(ctx context.Context, groupID int) ([]byte, error) diff --git a/pkg/models/repository_scene.go b/pkg/models/repository_scene.go index 60783fff5cd..e28347c5b82 100644 --- a/pkg/models/repository_scene.go +++ b/pkg/models/repository_scene.go @@ -37,10 +37,7 @@ type SceneQueryer interface { type SceneCounter interface { Count(ctx context.Context) (int, error) CountByPerformerID(ctx context.Context, performerID int) (int, error) - CountByGroupID(ctx context.Context, groupID int) (int, error) CountByFileID(ctx context.Context, fileID FileID) (int, error) - CountByStudioID(ctx context.Context, studioID int) (int, error) - CountByTagID(ctx context.Context, tagID int) (int, error) CountMissingChecksum(ctx context.Context) (int, error) CountMissingOSHash(ctx context.Context) (int, error) OCountByPerformerID(ctx context.Context, performerID int) (int, error) diff --git a/pkg/models/scene.go b/pkg/models/scene.go index 814c4a41d62..48317240276 100644 --- a/pkg/models/scene.go +++ b/pkg/models/scene.go @@ -56,7 +56,7 @@ type SceneFilterType struct { // Filter to only include scenes with this studio Studios *HierarchicalMultiCriterionInput `json:"studios"` // Filter to only include scenes with this group - Groups *MultiCriterionInput `json:"groups"` + Groups *HierarchicalMultiCriterionInput `json:"groups"` // Filter to only include scenes with this movie Movies *MultiCriterionInput `json:"movies"` // Filter to only include scenes with this gallery diff --git a/pkg/models/update.go b/pkg/models/update.go index 2302a2e699a..6aaff8c317f 100644 --- a/pkg/models/update.go +++ b/pkg/models/update.go @@ -133,3 +133,68 @@ func applyUpdate[T comparable](values []T, mode RelationshipUpdateMode, existing return nil } + +type UpdateGroupDescriptions struct { + Groups []GroupIDDescription `json:"groups"` + Mode RelationshipUpdateMode `json:"mode"` +} + +// Apply applies the update to a list of existing ids, returning the result. +func (u *UpdateGroupDescriptions) Apply(existing []GroupIDDescription) []GroupIDDescription { + if u == nil { + return existing + } + + switch u.Mode { + case RelationshipUpdateModeAdd: + return u.applyAdd(existing) + case RelationshipUpdateModeRemove: + return u.applyRemove(existing) + case RelationshipUpdateModeSet: + return u.Groups + } + + return nil +} + +func (u *UpdateGroupDescriptions) applyAdd(existing []GroupIDDescription) []GroupIDDescription { + // overwrite any existing values with the same id + ret := append([]GroupIDDescription{}, existing...) + for _, v := range u.Groups { + found := false + for i, vv := range ret { + if vv.GroupID == v.GroupID { + ret[i] = v + found = true + break + } + } + + if !found { + ret = append(ret, v) + } + } + + return ret +} + +func (u *UpdateGroupDescriptions) applyRemove(existing []GroupIDDescription) []GroupIDDescription { + // remove any existing values with the same id + var ret []GroupIDDescription + for _, v := range existing { + found := false + for _, vv := range u.Groups { + if vv.GroupID == v.GroupID { + found = true + break + } + } + + // if not found in the remove list, keep it + if !found { + ret = append(ret, v) + } + } + + return ret +} diff --git a/pkg/scene/query.go b/pkg/scene/query.go index a8b1993a6a0..c640266f9ef 100644 --- a/pkg/scene/query.go +++ b/pkg/scene/query.go @@ -144,3 +144,15 @@ func CountByTagID(ctx context.Context, r models.SceneQueryer, id int, depth *int return r.QueryCount(ctx, filter, nil) } + +func CountByGroupID(ctx context.Context, r models.SceneQueryer, id int, depth *int) (int, error) { + filter := &models.SceneFilterType{ + Groups: &models.HierarchicalMultiCriterionInput{ + Value: []string{strconv.Itoa(id)}, + Modifier: models.CriterionModifierIncludes, + Depth: depth, + }, + } + + return r.QueryCount(ctx, filter, nil) +} diff --git a/pkg/sliceutil/collections.go b/pkg/sliceutil/collections.go index bd4070cdc94..18930df259e 100644 --- a/pkg/sliceutil/collections.go +++ b/pkg/sliceutil/collections.go @@ -146,7 +146,7 @@ func Filter[T any](vs []T, f func(T) bool) []T { return ret } -// Filter returns the result of applying f to each element of the vs slice. +// Map returns the result of applying f to each element of the vs slice. func Map[T any, V any](vs []T, f func(T) V) []V { ret := make([]V, len(vs)) for i, v := range vs { diff --git a/pkg/sqlite/database.go b/pkg/sqlite/database.go index ee5e5399bf9..7dd4771d33f 100644 --- a/pkg/sqlite/database.go +++ b/pkg/sqlite/database.go @@ -30,7 +30,7 @@ const ( dbConnTimeout = 30 ) -var appSchemaVersion uint = 66 +var appSchemaVersion uint = 67 //go:embed migrations/*.sql var migrationsBox embed.FS diff --git a/pkg/sqlite/filter_hierarchical.go b/pkg/sqlite/filter_hierarchical.go new file mode 100644 index 00000000000..bc5ff9032b3 --- /dev/null +++ b/pkg/sqlite/filter_hierarchical.go @@ -0,0 +1,222 @@ +package sqlite + +import ( + "context" + "fmt" + + "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/utils" +) + +// hierarchicalRelationshipHandler provides handlers for parent, children, parent count, and child count criteria. +type hierarchicalRelationshipHandler struct { + primaryTable string + relationTable string + aliasPrefix string + parentIDCol string + childIDCol string +} + +func (h hierarchicalRelationshipHandler) validateModifier(m models.CriterionModifier) error { + switch m { + case models.CriterionModifierIncludesAll, models.CriterionModifierIncludes, models.CriterionModifierExcludes, models.CriterionModifierIsNull, models.CriterionModifierNotNull: + // valid + return nil + default: + return fmt.Errorf("invalid modifier %s", m) + } +} + +func (h hierarchicalRelationshipHandler) handleNullNotNull(f *filterBuilder, m models.CriterionModifier, isParents bool) { + var notClause string + if m == models.CriterionModifierNotNull { + notClause = "NOT" + } + + as := h.aliasPrefix + "_parents" + col := h.childIDCol + if !isParents { + as = h.aliasPrefix + "_children" + col = h.parentIDCol + } + + // Based on: + // f.addLeftJoin("tags_relations", "parent_relations", "tags.id = parent_relations.child_id") + // f.addWhere(fmt.Sprintf("parent_relations.parent_id IS %s NULL", notClause)) + + f.addLeftJoin(h.relationTable, as, fmt.Sprintf("%s.id = %s.%s", h.primaryTable, as, col)) + f.addWhere(fmt.Sprintf("%s.%s IS %s NULL", as, col, notClause)) +} + +func (h hierarchicalRelationshipHandler) parentsAlias() string { + return h.aliasPrefix + "_parents" +} + +func (h hierarchicalRelationshipHandler) childrenAlias() string { + return h.aliasPrefix + "_children" +} + +func (h hierarchicalRelationshipHandler) valueQuery(value []string, depth int, alias string, isParents bool) string { + var depthCondition string + if depth != -1 { + depthCondition = fmt.Sprintf("WHERE depth < %d", depth) + } + + queryTempl := `{alias} AS ( +SELECT {root_id_col} AS root_id, {item_id_col} AS item_id, 0 AS depth FROM {relation_table} WHERE {root_id_col} IN` + getInBinding(len(value)) + ` +UNION +SELECT root_id, {item_id_col}, depth + 1 FROM {relation_table} INNER JOIN {alias} ON item_id = {root_id_col} ` + depthCondition + ` +)` + + var queryMap utils.StrFormatMap + if isParents { + queryMap = utils.StrFormatMap{ + "root_id_col": h.parentIDCol, + "item_id_col": h.childIDCol, + } + } else { + queryMap = utils.StrFormatMap{ + "root_id_col": h.childIDCol, + "item_id_col": h.parentIDCol, + } + } + + queryMap["alias"] = alias + queryMap["relation_table"] = h.relationTable + + return utils.StrFormat(queryTempl, queryMap) +} + +func (h hierarchicalRelationshipHandler) handleValues(f *filterBuilder, c models.HierarchicalMultiCriterionInput, isParents bool, aliasSuffix string) { + if len(c.Value) == 0 { + return + } + + var args []interface{} + for _, val := range c.Value { + args = append(args, val) + } + + depthVal := 0 + if c.Depth != nil { + depthVal = *c.Depth + } + + tableAlias := h.parentsAlias() + if !isParents { + tableAlias = h.childrenAlias() + } + tableAlias += aliasSuffix + + query := h.valueQuery(c.Value, depthVal, tableAlias, isParents) + f.addRecursiveWith(query, args...) + + f.addLeftJoin(tableAlias, "", fmt.Sprintf("%s.item_id = %s.id", tableAlias, h.primaryTable)) + addHierarchicalConditionClauses(f, c, tableAlias, "root_id") +} + +func (h hierarchicalRelationshipHandler) handleValuesSimple(f *filterBuilder, value string, isParents bool) { + joinCol := h.childIDCol + valueCol := h.parentIDCol + if !isParents { + joinCol = h.parentIDCol + valueCol = h.childIDCol + } + + tableAlias := h.parentsAlias() + if !isParents { + tableAlias = h.childrenAlias() + } + + f.addInnerJoin(h.relationTable, tableAlias, fmt.Sprintf("%s.%s = %s.id", tableAlias, joinCol, h.primaryTable)) + f.addWhere(fmt.Sprintf("%s.%s = ?", tableAlias, valueCol), value) +} + +func (h hierarchicalRelationshipHandler) hierarchicalCriterionHandler(criterion *models.HierarchicalMultiCriterionInput, isParents bool) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if criterion != nil { + c := criterion.CombineExcludes() + + // validate the modifier + if err := h.validateModifier(c.Modifier); err != nil { + f.setError(err) + return + } + + if c.Modifier == models.CriterionModifierIsNull || c.Modifier == models.CriterionModifierNotNull { + h.handleNullNotNull(f, c.Modifier, isParents) + return + } + + if len(c.Value) == 0 && len(c.Excludes) == 0 { + return + } + + depth := 0 + if c.Depth != nil { + depth = *c.Depth + } + + // if we have a single include, no excludes, and no depth, we can use a simple join and where clause + if (c.Modifier == models.CriterionModifierIncludes || c.Modifier == models.CriterionModifierIncludesAll) && len(c.Value) == 1 && len(c.Excludes) == 0 && depth == 0 { + h.handleValuesSimple(f, c.Value[0], isParents) + return + } + + aliasSuffix := "" + h.handleValues(f, c, isParents, aliasSuffix) + + if len(c.Excludes) > 0 { + exCriterion := models.HierarchicalMultiCriterionInput{ + Value: c.Excludes, + Depth: c.Depth, + Modifier: models.CriterionModifierExcludes, + } + + aliasSuffix := "2" + h.handleValues(f, exCriterion, isParents, aliasSuffix) + } + } + } +} + +func (h hierarchicalRelationshipHandler) ParentsCriterionHandler(criterion *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { + const isParents = true + return h.hierarchicalCriterionHandler(criterion, isParents) +} + +func (h hierarchicalRelationshipHandler) ChildrenCriterionHandler(criterion *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { + const isParents = false + return h.hierarchicalCriterionHandler(criterion, isParents) +} + +func (h hierarchicalRelationshipHandler) countCriterionHandler(c *models.IntCriterionInput, isParents bool) criterionHandlerFunc { + tableAlias := h.parentsAlias() + col := h.childIDCol + otherCol := h.parentIDCol + if !isParents { + tableAlias = h.childrenAlias() + col = h.parentIDCol + otherCol = h.childIDCol + } + tableAlias += "_count" + + return func(ctx context.Context, f *filterBuilder) { + if c != nil { + f.addLeftJoin(h.relationTable, tableAlias, fmt.Sprintf("%s.%s = %s.id", tableAlias, col, h.primaryTable)) + clause, args := getIntCriterionWhereClause(fmt.Sprintf("count(distinct %s.%s)", tableAlias, otherCol), *c) + + f.addHaving(clause, args...) + } + } +} + +func (h hierarchicalRelationshipHandler) ParentCountCriterionHandler(parentCount *models.IntCriterionInput) criterionHandlerFunc { + const isParents = true + return h.countCriterionHandler(parentCount, isParents) +} + +func (h hierarchicalRelationshipHandler) ChildCountCriterionHandler(childCount *models.IntCriterionInput) criterionHandlerFunc { + const isParents = false + return h.countCriterionHandler(childCount, isParents) +} diff --git a/pkg/sqlite/group.go b/pkg/sqlite/group.go index 21c224242f8..603494fe71a 100644 --- a/pkg/sqlite/group.go +++ b/pkg/sqlite/group.go @@ -27,6 +27,8 @@ const ( groupURLsTable = "group_urls" groupURLColumn = "url" + + groupRelationsTable = "groups_relations" ) type groupRow struct { @@ -128,6 +130,7 @@ var ( type GroupStore struct { blobJoinQueryBuilder tagRelationshipStore + groupRelationshipStore tableMgr *table } @@ -143,6 +146,9 @@ func NewGroupStore(blobStore *BlobStore) *GroupStore { joinTable: groupsTagsTableMgr, }, }, + groupRelationshipStore: groupRelationshipStore{ + table: groupRelationshipTableMgr, + }, tableMgr: groupTableMgr, } @@ -176,6 +182,14 @@ func (qb *GroupStore) Create(ctx context.Context, newObject *models.Group) error return err } + if err := qb.groupRelationshipStore.createContainingRelationships(ctx, id, newObject.ContainingGroups); err != nil { + return err + } + + if err := qb.groupRelationshipStore.createSubRelationships(ctx, id, newObject.SubGroups); err != nil { + return err + } + updated, err := qb.find(ctx, id) if err != nil { return fmt.Errorf("finding after create: %w", err) @@ -211,6 +225,14 @@ func (qb *GroupStore) UpdatePartial(ctx context.Context, id int, partial models. return nil, err } + if err := qb.groupRelationshipStore.modifyContainingRelationships(ctx, id, partial.ContainingGroups); err != nil { + return nil, err + } + + if err := qb.groupRelationshipStore.modifySubRelationships(ctx, id, partial.SubGroups); err != nil { + return nil, err + } + return qb.find(ctx, id) } @@ -232,6 +254,14 @@ func (qb *GroupStore) Update(ctx context.Context, updatedObject *models.Group) e return err } + if err := qb.groupRelationshipStore.replaceContainingRelationships(ctx, updatedObject.ID, updatedObject.ContainingGroups); err != nil { + return err + } + + if err := qb.groupRelationshipStore.replaceSubRelationships(ctx, updatedObject.ID, updatedObject.SubGroups); err != nil { + return err + } + return nil } @@ -412,9 +442,7 @@ func (qb *GroupStore) makeQuery(ctx context.Context, groupFilter *models.GroupFi return nil, err } - var err error - query.sortAndPagination, err = qb.getGroupSort(findFilter) - if err != nil { + if err := qb.setGroupSort(&query, findFilter); err != nil { return nil, err } @@ -460,11 +488,12 @@ var groupSortOptions = sortOptions{ "random", "rating", "scenes_count", + "sub_group_order", "tag_count", "updated_at", } -func (qb *GroupStore) getGroupSort(findFilter *models.FindFilterType) (string, error) { +func (qb *GroupStore) setGroupSort(query *queryBuilder, findFilter *models.FindFilterType) error { var sort string var direction string if findFilter == nil { @@ -477,22 +506,31 @@ func (qb *GroupStore) getGroupSort(findFilter *models.FindFilterType) (string, e // CVE-2024-32231 - ensure sort is in the list of allowed sorts if err := groupSortOptions.validateSort(sort); err != nil { - return "", err + return err } - sortQuery := "" switch sort { + case "sub_group_order": + // sub_group_order is a special sort that sorts by the order_index of the subgroups + if query.hasJoin("groups_parents") { + query.sortAndPagination += getSort("order_index", direction, "groups_parents") + } else { + // this will give unexpected results if the query is not filtered by a parent group and + // the group has multiple parents and order indexes + query.join(groupRelationsTable, "", "groups.id = groups_relations.sub_id") + query.sortAndPagination += getSort("order_index", direction, groupRelationsTable) + } case "tag_count": - sortQuery += getCountSort(groupTable, groupsTagsTable, groupIDColumn, direction) + query.sortAndPagination += getCountSort(groupTable, groupsTagsTable, groupIDColumn, direction) case "scenes_count": // generic getSort won't work for this - sortQuery += getCountSort(groupTable, groupsScenesTable, groupIDColumn, direction) + query.sortAndPagination += getCountSort(groupTable, groupsScenesTable, groupIDColumn, direction) default: - sortQuery += getSort(sort, direction, "groups") + query.sortAndPagination += getSort(sort, direction, "groups") } // Whatever the sorting, always use name/id as a final sort - sortQuery += ", COALESCE(groups.name, groups.id) COLLATE NATURAL_CI ASC" - return sortQuery, nil + query.sortAndPagination += ", COALESCE(groups.name, groups.id) COLLATE NATURAL_CI ASC" + return nil } func (qb *GroupStore) queryGroups(ctx context.Context, query string, args []interface{}) ([]*models.Group, error) { @@ -592,3 +630,74 @@ WHERE groups.studio_id = ? func (qb *GroupStore) GetURLs(ctx context.Context, groupID int) ([]string, error) { return groupsURLsTableMgr.get(ctx, groupID) } + +// FindSubGroupIDs returns a list of group IDs where a group in the ids list is a sub-group of the parent group +func (qb *GroupStore) FindSubGroupIDs(ctx context.Context, containingID int, ids []int) ([]int, error) { + /* + SELECT gr.sub_id FROM groups_relations gr + WHERE gr.containing_id = :parentID AND gr.sub_id IN (:ids); + */ + table := groupRelationshipTableMgr.table + q := dialect.From(table).Prepared(true). + Select(table.Col("sub_id")).Where( + table.Col("containing_id").Eq(containingID), + table.Col("sub_id").In(ids), + ) + + const single = false + var ret []int + if err := queryFunc(ctx, q, single, func(r *sqlx.Rows) error { + var id int + if err := r.Scan(&id); err != nil { + return err + } + + ret = append(ret, id) + return nil + }); err != nil { + return nil, err + } + + return ret, nil +} + +// FindInAscestors returns a list of group IDs where a group in the ids list is an ascestor of the ancestor group IDs +func (qb *GroupStore) FindInAncestors(ctx context.Context, ascestorIDs []int, ids []int) ([]int, error) { + /* + WITH RECURSIVE ascestors AS ( + SELECT g.id AS parent_id FROM groups g WHERE g.id IN (:ascestorIDs) + UNION + SELECT gr.containing_id FROM groups_relations gr INNER JOIN ascestors a ON a.parent_id = gr.sub_id + ) + SELECT p.parent_id FROM ascestors p WHERE p.parent_id IN (:ids); + */ + table := qb.table() + const ascestors = "ancestors" + const parentID = "parent_id" + q := dialect.From(ascestors).Prepared(true). + WithRecursive(ascestors, + dialect.From(qb.table()).Select(table.Col(idColumn).As(parentID)). + Where(table.Col(idColumn).In(ascestorIDs)). + Union( + dialect.From(groupRelationsJoinTable).InnerJoin( + goqu.I(ascestors), goqu.On(goqu.I("parent_id").Eq(goqu.I("sub_id"))), + ).Select("containing_id"), + ), + ).Select(parentID).Where(goqu.I(parentID).In(ids)) + + const single = false + var ret []int + if err := queryFunc(ctx, q, single, func(r *sqlx.Rows) error { + var id int + if err := r.Scan(&id); err != nil { + return err + } + + ret = append(ret, id) + return nil + }); err != nil { + return nil, err + } + + return ret, nil +} diff --git a/pkg/sqlite/group_filter.go b/pkg/sqlite/group_filter.go index 97bde1f2474..dcb7bcdfc94 100644 --- a/pkg/sqlite/group_filter.go +++ b/pkg/sqlite/group_filter.go @@ -51,6 +51,14 @@ func (qb *groupFilterHandler) handle(ctx context.Context, f *filterBuilder) { f.handleCriterion(ctx, qb.criterionHandler()) } +var groupHierarchyHandler = hierarchicalRelationshipHandler{ + primaryTable: groupTable, + relationTable: groupRelationsTable, + aliasPrefix: groupTable, + parentIDCol: "containing_id", + childIDCol: "sub_id", +} + func (qb *groupFilterHandler) criterionHandler() criterionHandler { groupFilter := qb.groupFilter return compoundHandler{ @@ -66,6 +74,10 @@ func (qb *groupFilterHandler) criterionHandler() criterionHandler { qb.tagsCriterionHandler(groupFilter.Tags), qb.tagCountCriterionHandler(groupFilter.TagCount), &dateCriterionHandler{groupFilter.Date, "groups.date", nil}, + groupHierarchyHandler.ParentsCriterionHandler(groupFilter.ContainingGroups), + groupHierarchyHandler.ChildrenCriterionHandler(groupFilter.SubGroups), + groupHierarchyHandler.ParentCountCriterionHandler(groupFilter.ContainingGroupCount), + groupHierarchyHandler.ChildCountCriterionHandler(groupFilter.SubGroupCount), ×tampCriterionHandler{groupFilter.CreatedAt, "groups.created_at", nil}, ×tampCriterionHandler{groupFilter.UpdatedAt, "groups.updated_at", nil}, diff --git a/pkg/sqlite/group_relationships.go b/pkg/sqlite/group_relationships.go new file mode 100644 index 00000000000..fe94394f905 --- /dev/null +++ b/pkg/sqlite/group_relationships.go @@ -0,0 +1,457 @@ +package sqlite + +import ( + "context" + "fmt" + + "github.com/doug-martin/goqu/v9" + "github.com/doug-martin/goqu/v9/exp" + "github.com/jmoiron/sqlx" + "github.com/stashapp/stash/pkg/models" + "gopkg.in/guregu/null.v4" + "gopkg.in/guregu/null.v4/zero" +) + +type groupRelationshipRow struct { + ContainingID int `db:"containing_id"` + SubID int `db:"sub_id"` + OrderIndex int `db:"order_index"` + Description zero.String `db:"description"` +} + +func (r groupRelationshipRow) resolve(useContainingID bool) models.GroupIDDescription { + id := r.ContainingID + if !useContainingID { + id = r.SubID + } + + return models.GroupIDDescription{ + GroupID: id, + Description: r.Description.String, + } +} + +type groupRelationshipStore struct { + table *table +} + +func (s *groupRelationshipStore) GetContainingGroupDescriptions(ctx context.Context, id int) ([]models.GroupIDDescription, error) { + const idIsContaining = false + return s.getGroupRelationships(ctx, id, idIsContaining) +} + +func (s *groupRelationshipStore) GetSubGroupDescriptions(ctx context.Context, id int) ([]models.GroupIDDescription, error) { + const idIsContaining = true + return s.getGroupRelationships(ctx, id, idIsContaining) +} + +func (s *groupRelationshipStore) getGroupRelationships(ctx context.Context, id int, idIsContaining bool) ([]models.GroupIDDescription, error) { + col := "containing_id" + if !idIsContaining { + col = "sub_id" + } + + table := s.table.table + q := dialect.Select(table.All()). + From(table). + Where(table.Col(col).Eq(id)). + Order(table.Col("order_index").Asc()) + + const single = false + var ret []models.GroupIDDescription + if err := queryFunc(ctx, q, single, func(rows *sqlx.Rows) error { + var row groupRelationshipRow + if err := rows.StructScan(&row); err != nil { + return err + } + + ret = append(ret, row.resolve(!idIsContaining)) + + return nil + }); err != nil { + return nil, fmt.Errorf("getting group relationships from %s: %w", table.GetTable(), err) + } + + return ret, nil +} + +// getMaxOrderIndex gets the maximum order index for the containing group with the given id +func (s *groupRelationshipStore) getMaxOrderIndex(ctx context.Context, containingID int) (int, error) { + idColumn := s.table.table.Col("containing_id") + + q := dialect.Select(goqu.MAX("order_index")). + From(s.table.table). + Where(idColumn.Eq(containingID)) + + var maxOrderIndex zero.Int + if err := querySimple(ctx, q, &maxOrderIndex); err != nil { + return 0, fmt.Errorf("getting max order index: %w", err) + } + + return int(maxOrderIndex.Int64), nil +} + +// createRelationships creates relationships between a group and other groups. +// If idIsContaining is true, the provided id is the containing group. +func (s *groupRelationshipStore) createRelationships(ctx context.Context, id int, d models.RelatedGroupDescriptions, idIsContaining bool) error { + if d.Loaded() { + for i, v := range d.List() { + orderIndex := i + 1 + + r := groupRelationshipRow{ + ContainingID: id, + SubID: v.GroupID, + OrderIndex: orderIndex, + Description: zero.StringFrom(v.Description), + } + + if !idIsContaining { + // get the max order index of the containing groups sub groups + containingID := v.GroupID + maxOrderIndex, err := s.getMaxOrderIndex(ctx, containingID) + if err != nil { + return err + } + + r.ContainingID = v.GroupID + r.SubID = id + r.OrderIndex = maxOrderIndex + 1 + } + + _, err := s.table.insert(ctx, r) + if err != nil { + return fmt.Errorf("inserting into %s: %w", s.table.table.GetTable(), err) + } + } + + return nil + } + + return nil +} + +// createRelationships creates relationships between a group and other groups. +// If idIsContaining is true, the provided id is the containing group. +func (s *groupRelationshipStore) createContainingRelationships(ctx context.Context, id int, d models.RelatedGroupDescriptions) error { + const idIsContaining = false + return s.createRelationships(ctx, id, d, idIsContaining) +} + +// createRelationships creates relationships between a group and other groups. +// If idIsContaining is true, the provided id is the containing group. +func (s *groupRelationshipStore) createSubRelationships(ctx context.Context, id int, d models.RelatedGroupDescriptions) error { + const idIsContaining = true + return s.createRelationships(ctx, id, d, idIsContaining) +} + +func (s *groupRelationshipStore) replaceRelationships(ctx context.Context, id int, d models.RelatedGroupDescriptions, idIsContaining bool) error { + // always destroy the existing relationships even if the new list is empty + if err := s.destroyAllJoins(ctx, id, idIsContaining); err != nil { + return err + } + + return s.createRelationships(ctx, id, d, idIsContaining) +} + +func (s *groupRelationshipStore) replaceContainingRelationships(ctx context.Context, id int, d models.RelatedGroupDescriptions) error { + const idIsContaining = false + return s.replaceRelationships(ctx, id, d, idIsContaining) +} + +func (s *groupRelationshipStore) replaceSubRelationships(ctx context.Context, id int, d models.RelatedGroupDescriptions) error { + const idIsContaining = true + return s.replaceRelationships(ctx, id, d, idIsContaining) +} + +func (s *groupRelationshipStore) modifyRelationships(ctx context.Context, id int, v *models.UpdateGroupDescriptions, idIsContaining bool) error { + if v == nil { + return nil + } + + switch v.Mode { + case models.RelationshipUpdateModeSet: + return s.replaceJoins(ctx, id, *v, idIsContaining) + case models.RelationshipUpdateModeAdd: + return s.addJoins(ctx, id, v.Groups, idIsContaining) + case models.RelationshipUpdateModeRemove: + toRemove := make([]int, len(v.Groups)) + for i, vv := range v.Groups { + toRemove[i] = vv.GroupID + } + return s.destroyJoins(ctx, id, toRemove, idIsContaining) + } + + return nil +} + +func (s *groupRelationshipStore) modifyContainingRelationships(ctx context.Context, id int, v *models.UpdateGroupDescriptions) error { + const idIsContaining = false + return s.modifyRelationships(ctx, id, v, idIsContaining) +} + +func (s *groupRelationshipStore) modifySubRelationships(ctx context.Context, id int, v *models.UpdateGroupDescriptions) error { + const idIsContaining = true + return s.modifyRelationships(ctx, id, v, idIsContaining) +} + +func (s *groupRelationshipStore) addJoins(ctx context.Context, id int, groups []models.GroupIDDescription, idIsContaining bool) error { + // if we're adding to a containing group, get the max order index first + var maxOrderIndex int + if idIsContaining { + var err error + maxOrderIndex, err = s.getMaxOrderIndex(ctx, id) + if err != nil { + return err + } + } + + for i, vv := range groups { + r := groupRelationshipRow{ + Description: zero.StringFrom(vv.Description), + } + + if idIsContaining { + r.ContainingID = id + r.SubID = vv.GroupID + r.OrderIndex = maxOrderIndex + (i + 1) + } else { + // get the max order index of the containing groups sub groups + containingMaxOrderIndex, err := s.getMaxOrderIndex(ctx, vv.GroupID) + if err != nil { + return err + } + + r.ContainingID = vv.GroupID + r.SubID = id + r.OrderIndex = containingMaxOrderIndex + 1 + } + + _, err := s.table.insert(ctx, r) + if err != nil { + return fmt.Errorf("inserting into %s: %w", s.table.table.GetTable(), err) + } + } + + return nil +} + +func (s *groupRelationshipStore) destroyAllJoins(ctx context.Context, id int, idIsContaining bool) error { + table := s.table.table + idColumn := table.Col("containing_id") + if !idIsContaining { + idColumn = table.Col("sub_id") + } + + q := dialect.Delete(table).Where(idColumn.Eq(id)) + + if _, err := exec(ctx, q); err != nil { + return fmt.Errorf("destroying %s: %w", table.GetTable(), err) + } + + return nil +} + +func (s *groupRelationshipStore) replaceJoins(ctx context.Context, id int, v models.UpdateGroupDescriptions, idIsContaining bool) error { + if err := s.destroyAllJoins(ctx, id, idIsContaining); err != nil { + return err + } + + // convert to RelatedGroupDescriptions + rgd := models.NewRelatedGroupDescriptions(v.Groups) + return s.createRelationships(ctx, id, rgd, idIsContaining) +} + +func (s *groupRelationshipStore) destroyJoins(ctx context.Context, id int, toRemove []int, idIsContaining bool) error { + table := s.table.table + idColumn := table.Col("containing_id") + fkColumn := table.Col("sub_id") + if !idIsContaining { + idColumn = table.Col("sub_id") + fkColumn = table.Col("containing_id") + } + + q := dialect.Delete(table).Where(idColumn.Eq(id), fkColumn.In(toRemove)) + + if _, err := exec(ctx, q); err != nil { + return fmt.Errorf("destroying %s: %w", table.GetTable(), err) + } + + return nil +} + +func (s *groupRelationshipStore) getOrderIndexOfSubGroup(ctx context.Context, containingGroupID int, subGroupID int) (int, error) { + table := s.table.table + q := dialect.Select("order_index"). + From(table). + Where( + table.Col("containing_id").Eq(containingGroupID), + table.Col("sub_id").Eq(subGroupID), + ) + + var orderIndex null.Int + if err := querySimple(ctx, q, &orderIndex); err != nil { + return 0, fmt.Errorf("getting order index: %w", err) + } + + if !orderIndex.Valid { + return 0, fmt.Errorf("sub-group %d not found in containing group %d", subGroupID, containingGroupID) + } + + return int(orderIndex.Int64), nil +} + +func (s *groupRelationshipStore) getGroupIDAtOrderIndex(ctx context.Context, containingGroupID int, orderIndex int) (*int, error) { + table := s.table.table + q := dialect.Select(table.Col("sub_id")).From(table).Where( + table.Col("containing_id").Eq(containingGroupID), + table.Col("order_index").Eq(orderIndex), + ) + + var ret null.Int + if err := querySimple(ctx, q, &ret); err != nil { + return nil, fmt.Errorf("getting sub id for order index: %w", err) + } + + if !ret.Valid { + return nil, nil + } + + intRet := int(ret.Int64) + return &intRet, nil +} + +func (s *groupRelationshipStore) getOrderIndexAfterOrderIndex(ctx context.Context, containingGroupID int, orderIndex int) (int, error) { + table := s.table.table + q := dialect.Select(goqu.MIN("order_index")).From(table).Where( + table.Col("containing_id").Eq(containingGroupID), + table.Col("order_index").Gt(orderIndex), + ) + + var ret null.Int + if err := querySimple(ctx, q, &ret); err != nil { + return 0, fmt.Errorf("getting order index: %w", err) + } + + if !ret.Valid { + return orderIndex + 1, nil + } + + return int(ret.Int64), nil +} + +// incrementOrderIndexes increments the order_index value of all sub-groups in the containing group at or after the given index +func (s *groupRelationshipStore) incrementOrderIndexes(ctx context.Context, groupID int, indexBefore int) error { + table := s.table.table + + // WORKAROUND - sqlite won't allow incrementing the value directly since it causes a + // unique constraint violation. + // Instead, we first set the order index to a negative value temporarily + // see https://stackoverflow.com/a/7703239/695786 + q := dialect.Update(table).Set(exp.Record{ + "order_index": goqu.L("-order_index"), + }).Where( + table.Col("containing_id").Eq(groupID), + table.Col("order_index").Gte(indexBefore), + ) + + if _, err := exec(ctx, q); err != nil { + return fmt.Errorf("updating %s: %w", table.GetTable(), err) + } + + q = dialect.Update(table).Set(exp.Record{ + "order_index": goqu.L("1-order_index"), + }).Where( + table.Col("containing_id").Eq(groupID), + table.Col("order_index").Lt(0), + ) + + if _, err := exec(ctx, q); err != nil { + return fmt.Errorf("updating %s: %w", table.GetTable(), err) + } + + return nil +} + +func (s *groupRelationshipStore) reorderSubGroup(ctx context.Context, groupID int, subGroupID int, insertPointID int, insertAfter bool) error { + insertPointIndex, err := s.getOrderIndexOfSubGroup(ctx, groupID, insertPointID) + if err != nil { + return err + } + + // if we're setting before + if insertAfter { + insertPointIndex, err = s.getOrderIndexAfterOrderIndex(ctx, groupID, insertPointIndex) + if err != nil { + return err + } + } + + // increment the order index of all sub-groups after and including the insertion point + if err := s.incrementOrderIndexes(ctx, groupID, int(insertPointIndex)); err != nil { + return err + } + + // set the order index of the sub-group to the insertion point + table := s.table.table + q := dialect.Update(table).Set(exp.Record{ + "order_index": insertPointIndex, + }).Where( + table.Col("containing_id").Eq(groupID), + table.Col("sub_id").Eq(subGroupID), + ) + + if _, err := exec(ctx, q); err != nil { + return fmt.Errorf("updating %s: %w", table.GetTable(), err) + } + + return nil +} + +func (s *groupRelationshipStore) AddSubGroups(ctx context.Context, groupID int, subGroups []models.GroupIDDescription, insertIndex *int) error { + const idIsContaining = true + + if err := s.addJoins(ctx, groupID, subGroups, idIsContaining); err != nil { + return err + } + + ids := make([]int, len(subGroups)) + for i, v := range subGroups { + ids[i] = v.GroupID + } + + if insertIndex != nil { + // get the id of the sub-group at the insert index + insertPointID, err := s.getGroupIDAtOrderIndex(ctx, groupID, *insertIndex) + if err != nil { + return err + } + + if insertPointID == nil { + // if the insert index is out of bounds, just assume adding to the end + return nil + } + + // reorder the sub-groups + const insertAfter = false + if err := s.ReorderSubGroups(ctx, groupID, ids, *insertPointID, insertAfter); err != nil { + return err + } + } + + return nil +} + +func (s *groupRelationshipStore) RemoveSubGroups(ctx context.Context, groupID int, subGroupIDs []int) error { + const idIsContaining = true + return s.destroyJoins(ctx, groupID, subGroupIDs, idIsContaining) +} + +func (s *groupRelationshipStore) ReorderSubGroups(ctx context.Context, groupID int, subGroupIDs []int, insertPointID int, insertAfter bool) error { + for _, id := range subGroupIDs { + if err := s.reorderSubGroup(ctx, groupID, id, insertPointID, insertAfter); err != nil { + return err + } + } + + return nil +} diff --git a/pkg/sqlite/group_test.go b/pkg/sqlite/group_test.go index 45171337970..1d3637c8611 100644 --- a/pkg/sqlite/group_test.go +++ b/pkg/sqlite/group_test.go @@ -14,6 +14,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/sliceutil" + "github.com/stashapp/stash/pkg/sliceutil/intslice" ) func loadGroupRelationships(ctx context.Context, expected models.Group, actual *models.Group) error { @@ -27,22 +29,34 @@ func loadGroupRelationships(ctx context.Context, expected models.Group, actual * return err } } + if expected.ContainingGroups.Loaded() { + if err := actual.LoadContainingGroupIDs(ctx, db.Group); err != nil { + return err + } + } + if expected.SubGroups.Loaded() { + if err := actual.LoadSubGroupIDs(ctx, db.Group); err != nil { + return err + } + } return nil } func Test_GroupStore_Create(t *testing.T) { var ( - name = "name" - url = "url" - aliases = "alias1, alias2" - director = "director" - rating = 60 - duration = 34 - synopsis = "synopsis" - date, _ = models.ParseDate("2003-02-01") - createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) - updatedAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) + name = "name" + url = "url" + aliases = "alias1, alias2" + director = "director" + rating = 60 + duration = 34 + synopsis = "synopsis" + date, _ = models.ParseDate("2003-02-01") + containingGroupDescription = "containingGroupDescription" + subGroupDescription = "subGroupDescription" + createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) + updatedAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) ) tests := []struct { @@ -53,15 +67,21 @@ func Test_GroupStore_Create(t *testing.T) { { "full", models.Group{ - Name: name, - Duration: &duration, - Date: &date, - Rating: &rating, - StudioID: &studioIDs[studioIdxWithGroup], - Director: director, - Synopsis: synopsis, - URLs: models.NewRelatedStrings([]string{url}), - TagIDs: models.NewRelatedIDs([]int{tagIDs[tagIdx1WithDupName], tagIDs[tagIdx1WithGroup]}), + Name: name, + Duration: &duration, + Date: &date, + Rating: &rating, + StudioID: &studioIDs[studioIdxWithGroup], + Director: director, + Synopsis: synopsis, + URLs: models.NewRelatedStrings([]string{url}), + TagIDs: models.NewRelatedIDs([]int{tagIDs[tagIdx1WithDupName], tagIDs[tagIdx1WithGroup]}), + ContainingGroups: models.NewRelatedGroupDescriptions([]models.GroupIDDescription{ + {GroupID: groupIDs[groupIdxWithScene], Description: containingGroupDescription}, + }), + SubGroups: models.NewRelatedGroupDescriptions([]models.GroupIDDescription{ + {GroupID: groupIDs[groupIdxWithStudio], Description: subGroupDescription}, + }), Aliases: aliases, CreatedAt: createdAt, UpdatedAt: updatedAt, @@ -76,6 +96,22 @@ func Test_GroupStore_Create(t *testing.T) { }, true, }, + { + "invalid containing group id", + models.Group{ + Name: name, + ContainingGroups: models.NewRelatedGroupDescriptions([]models.GroupIDDescription{{GroupID: invalidID}}), + }, + true, + }, + { + "invalid sub group id", + models.Group{ + Name: name, + SubGroups: models.NewRelatedGroupDescriptions([]models.GroupIDDescription{{GroupID: invalidID}}), + }, + true, + }, } qb := db.Group @@ -131,36 +167,44 @@ func Test_GroupStore_Create(t *testing.T) { func Test_groupQueryBuilder_Update(t *testing.T) { var ( - name = "name" - url = "url" - aliases = "alias1, alias2" - director = "director" - rating = 60 - duration = 34 - synopsis = "synopsis" - date, _ = models.ParseDate("2003-02-01") - createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) - updatedAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) + name = "name" + url = "url" + aliases = "alias1, alias2" + director = "director" + rating = 60 + duration = 34 + synopsis = "synopsis" + date, _ = models.ParseDate("2003-02-01") + containingGroupDescription = "containingGroupDescription" + subGroupDescription = "subGroupDescription" + createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) + updatedAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) ) tests := []struct { name string - updatedObject *models.Group + updatedObject models.Group wantErr bool }{ { "full", - &models.Group{ - ID: groupIDs[groupIdxWithTag], - Name: name, - Duration: &duration, - Date: &date, - Rating: &rating, - StudioID: &studioIDs[studioIdxWithGroup], - Director: director, - Synopsis: synopsis, - URLs: models.NewRelatedStrings([]string{url}), - TagIDs: models.NewRelatedIDs([]int{tagIDs[tagIdx1WithDupName], tagIDs[tagIdx1WithGroup]}), + models.Group{ + ID: groupIDs[groupIdxWithTag], + Name: name, + Duration: &duration, + Date: &date, + Rating: &rating, + StudioID: &studioIDs[studioIdxWithGroup], + Director: director, + Synopsis: synopsis, + URLs: models.NewRelatedStrings([]string{url}), + TagIDs: models.NewRelatedIDs([]int{tagIDs[tagIdx1WithDupName], tagIDs[tagIdx1WithGroup]}), + ContainingGroups: models.NewRelatedGroupDescriptions([]models.GroupIDDescription{ + {GroupID: groupIDs[groupIdxWithScene], Description: containingGroupDescription}, + }), + SubGroups: models.NewRelatedGroupDescriptions([]models.GroupIDDescription{ + {GroupID: groupIDs[groupIdxWithStudio], Description: subGroupDescription}, + }), Aliases: aliases, CreatedAt: createdAt, UpdatedAt: updatedAt, @@ -169,16 +213,34 @@ func Test_groupQueryBuilder_Update(t *testing.T) { }, { "clear tag ids", - &models.Group{ + models.Group{ ID: groupIDs[groupIdxWithTag], Name: name, TagIDs: models.NewRelatedIDs([]int{}), }, false, }, + { + "clear containing ids", + models.Group{ + ID: groupIDs[groupIdxWithParent], + Name: name, + ContainingGroups: models.NewRelatedGroupDescriptions([]models.GroupIDDescription{}), + }, + false, + }, + { + "clear sub ids", + models.Group{ + ID: groupIDs[groupIdxWithChild], + Name: name, + SubGroups: models.NewRelatedGroupDescriptions([]models.GroupIDDescription{}), + }, + false, + }, { "invalid studio id", - &models.Group{ + models.Group{ ID: groupIDs[groupIdxWithScene], Name: name, StudioID: &invalidID, @@ -187,13 +249,31 @@ func Test_groupQueryBuilder_Update(t *testing.T) { }, { "invalid tag id", - &models.Group{ + models.Group{ ID: groupIDs[groupIdxWithScene], Name: name, TagIDs: models.NewRelatedIDs([]int{invalidID}), }, true, }, + { + "invalid containing group id", + models.Group{ + ID: groupIDs[groupIdxWithScene], + Name: name, + ContainingGroups: models.NewRelatedGroupDescriptions([]models.GroupIDDescription{{GroupID: invalidID}}), + }, + true, + }, + { + "invalid sub group id", + models.Group{ + ID: groupIDs[groupIdxWithScene], + Name: name, + SubGroups: models.NewRelatedGroupDescriptions([]models.GroupIDDescription{{GroupID: invalidID}}), + }, + true, + }, } qb := db.Group @@ -201,9 +281,10 @@ func Test_groupQueryBuilder_Update(t *testing.T) { runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { assert := assert.New(t) - copy := *tt.updatedObject + actual := tt.updatedObject + expected := tt.updatedObject - if err := qb.Update(ctx, tt.updatedObject); (err != nil) != tt.wantErr { + if err := qb.Update(ctx, &actual); (err != nil) != tt.wantErr { t.Errorf("groupQueryBuilder.Update() error = %v, wantErr %v", err, tt.wantErr) } @@ -211,49 +292,61 @@ func Test_groupQueryBuilder_Update(t *testing.T) { return } - s, err := qb.Find(ctx, tt.updatedObject.ID) + s, err := qb.Find(ctx, actual.ID) if err != nil { t.Errorf("groupQueryBuilder.Find() error = %v", err) } // load relationships - if err := loadGroupRelationships(ctx, copy, s); err != nil { + if err := loadGroupRelationships(ctx, expected, s); err != nil { t.Errorf("loadGroupRelationships() error = %v", err) return } - assert.Equal(copy, *s) + assert.Equal(expected, *s) }) } } -func clearGroupPartial() models.GroupPartial { +var clearGroupPartial = models.GroupPartial{ // leave mandatory fields - return models.GroupPartial{ - Aliases: models.OptionalString{Set: true, Null: true}, - Synopsis: models.OptionalString{Set: true, Null: true}, - Director: models.OptionalString{Set: true, Null: true}, - Duration: models.OptionalInt{Set: true, Null: true}, - URLs: &models.UpdateStrings{Mode: models.RelationshipUpdateModeSet}, - Date: models.OptionalDate{Set: true, Null: true}, - Rating: models.OptionalInt{Set: true, Null: true}, - StudioID: models.OptionalInt{Set: true, Null: true}, - TagIDs: &models.UpdateIDs{Mode: models.RelationshipUpdateModeSet}, + Aliases: models.OptionalString{Set: true, Null: true}, + Synopsis: models.OptionalString{Set: true, Null: true}, + Director: models.OptionalString{Set: true, Null: true}, + Duration: models.OptionalInt{Set: true, Null: true}, + URLs: &models.UpdateStrings{Mode: models.RelationshipUpdateModeSet}, + Date: models.OptionalDate{Set: true, Null: true}, + Rating: models.OptionalInt{Set: true, Null: true}, + StudioID: models.OptionalInt{Set: true, Null: true}, + TagIDs: &models.UpdateIDs{Mode: models.RelationshipUpdateModeSet}, + ContainingGroups: &models.UpdateGroupDescriptions{Mode: models.RelationshipUpdateModeSet}, + SubGroups: &models.UpdateGroupDescriptions{Mode: models.RelationshipUpdateModeSet}, +} + +func emptyGroup(idx int) models.Group { + return models.Group{ + ID: groupIDs[idx], + Name: groupNames[idx], + TagIDs: models.NewRelatedIDs([]int{}), + ContainingGroups: models.NewRelatedGroupDescriptions([]models.GroupIDDescription{}), + SubGroups: models.NewRelatedGroupDescriptions([]models.GroupIDDescription{}), } } func Test_groupQueryBuilder_UpdatePartial(t *testing.T) { var ( - name = "name" - url = "url" - aliases = "alias1, alias2" - director = "director" - rating = 60 - duration = 34 - synopsis = "synopsis" - date, _ = models.ParseDate("2003-02-01") - createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) - updatedAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) + name = "name" + url = "url" + aliases = "alias1, alias2" + director = "director" + rating = 60 + duration = 34 + synopsis = "synopsis" + date, _ = models.ParseDate("2003-02-01") + containingGroupDescription = "containingGroupDescription" + subGroupDescription = "subGroupDescription" + createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) + updatedAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC) ) tests := []struct { @@ -285,6 +378,20 @@ func Test_groupQueryBuilder_UpdatePartial(t *testing.T) { IDs: []int{tagIDs[tagIdx1WithGroup], tagIDs[tagIdx1WithDupName]}, Mode: models.RelationshipUpdateModeSet, }, + ContainingGroups: &models.UpdateGroupDescriptions{ + Groups: []models.GroupIDDescription{ + {GroupID: groupIDs[groupIdxWithStudio], Description: containingGroupDescription}, + {GroupID: groupIDs[groupIdxWithThreeTags], Description: containingGroupDescription}, + }, + Mode: models.RelationshipUpdateModeSet, + }, + SubGroups: &models.UpdateGroupDescriptions{ + Groups: []models.GroupIDDescription{ + {GroupID: groupIDs[groupIdxWithTag], Description: subGroupDescription}, + {GroupID: groupIDs[groupIdxWithDupName], Description: subGroupDescription}, + }, + Mode: models.RelationshipUpdateModeSet, + }, }, models.Group{ ID: groupIDs[groupIdxWithScene], @@ -300,17 +407,113 @@ func Test_groupQueryBuilder_UpdatePartial(t *testing.T) { CreatedAt: createdAt, UpdatedAt: updatedAt, TagIDs: models.NewRelatedIDs([]int{tagIDs[tagIdx1WithDupName], tagIDs[tagIdx1WithGroup]}), + ContainingGroups: models.NewRelatedGroupDescriptions([]models.GroupIDDescription{ + {GroupID: groupIDs[groupIdxWithStudio], Description: containingGroupDescription}, + {GroupID: groupIDs[groupIdxWithThreeTags], Description: containingGroupDescription}, + }), + SubGroups: models.NewRelatedGroupDescriptions([]models.GroupIDDescription{ + {GroupID: groupIDs[groupIdxWithTag], Description: subGroupDescription}, + {GroupID: groupIDs[groupIdxWithDupName], Description: subGroupDescription}, + }), }, false, }, { "clear all", groupIDs[groupIdxWithScene], - clearGroupPartial(), + clearGroupPartial, + emptyGroup(groupIdxWithScene), + false, + }, + { + "clear tag ids", + groupIDs[groupIdxWithTag], + clearGroupPartial, + emptyGroup(groupIdxWithTag), + false, + }, + { + "clear group relationships", + groupIDs[groupIdxWithParentAndChild], + clearGroupPartial, + emptyGroup(groupIdxWithParentAndChild), + false, + }, + { + "add containing group", + groupIDs[groupIdxWithParent], + models.GroupPartial{ + ContainingGroups: &models.UpdateGroupDescriptions{ + Groups: []models.GroupIDDescription{ + {GroupID: groupIDs[groupIdxWithScene], Description: containingGroupDescription}, + }, + Mode: models.RelationshipUpdateModeAdd, + }, + }, models.Group{ - ID: groupIDs[groupIdxWithScene], - Name: groupNames[groupIdxWithScene], - TagIDs: models.NewRelatedIDs([]int{}), + ID: groupIDs[groupIdxWithParent], + Name: groupNames[groupIdxWithParent], + ContainingGroups: models.NewRelatedGroupDescriptions([]models.GroupIDDescription{ + {GroupID: groupIDs[groupIdxWithChild]}, + {GroupID: groupIDs[groupIdxWithScene], Description: containingGroupDescription}, + }), + }, + false, + }, + { + "add sub group", + groupIDs[groupIdxWithChild], + models.GroupPartial{ + SubGroups: &models.UpdateGroupDescriptions{ + Groups: []models.GroupIDDescription{ + {GroupID: groupIDs[groupIdxWithScene], Description: subGroupDescription}, + }, + Mode: models.RelationshipUpdateModeAdd, + }, + }, + models.Group{ + ID: groupIDs[groupIdxWithChild], + Name: groupNames[groupIdxWithChild], + SubGroups: models.NewRelatedGroupDescriptions([]models.GroupIDDescription{ + {GroupID: groupIDs[groupIdxWithParent]}, + {GroupID: groupIDs[groupIdxWithScene], Description: subGroupDescription}, + }), + }, + false, + }, + { + "remove containing group", + groupIDs[groupIdxWithParent], + models.GroupPartial{ + ContainingGroups: &models.UpdateGroupDescriptions{ + Groups: []models.GroupIDDescription{ + {GroupID: groupIDs[groupIdxWithChild]}, + }, + Mode: models.RelationshipUpdateModeRemove, + }, + }, + models.Group{ + ID: groupIDs[groupIdxWithParent], + Name: groupNames[groupIdxWithParent], + ContainingGroups: models.NewRelatedGroupDescriptions([]models.GroupIDDescription{}), + }, + false, + }, + { + "remove sub group", + groupIDs[groupIdxWithChild], + models.GroupPartial{ + SubGroups: &models.UpdateGroupDescriptions{ + Groups: []models.GroupIDDescription{ + {GroupID: groupIDs[groupIdxWithParent]}, + }, + Mode: models.RelationshipUpdateModeRemove, + }, + }, + models.Group{ + ID: groupIDs[groupIdxWithChild], + Name: groupNames[groupIdxWithChild], + SubGroups: models.NewRelatedGroupDescriptions([]models.GroupIDDescription{}), }, false, }, @@ -784,7 +987,38 @@ func TestGroupQuerySorting(t *testing.T) { groups = queryGroups(ctx, t, nil, &findFilter) lastGroup := groups[len(groups)-1] - assert.Equal(t, groupIDs[groupIdxWithScene], lastGroup.ID) + assert.Equal(t, groupIDs[groupIdxWithParentAndScene], lastGroup.ID) + + return nil + }) +} + +func TestGroupQuerySortOrderIndex(t *testing.T) { + sort := "sub_group_order" + direction := models.SortDirectionEnumDesc + findFilter := models.FindFilterType{ + Sort: &sort, + Direction: &direction, + } + + groupFilter := models.GroupFilterType{ + ContainingGroups: &models.HierarchicalMultiCriterionInput{ + Value: intslice.IntSliceToStringSlice([]int{groupIdxWithChild}), + Modifier: models.CriterionModifierIncludes, + }, + } + + withTxn(func(ctx context.Context) error { + // just ensure there are no errors + _, _, err := db.Group.Query(ctx, &groupFilter, &findFilter) + if err != nil { + t.Errorf("Error querying group: %s", err.Error()) + } + + _, _, err = db.Group.Query(ctx, nil, &findFilter) + if err != nil { + t.Errorf("Error querying group: %s", err.Error()) + } return nil }) @@ -830,6 +1064,832 @@ func TestGroupUpdateBackImage(t *testing.T) { } } +func TestGroupQueryContainingGroups(t *testing.T) { + const nameField = "Name" + + type criterion struct { + valueIdxs []int + modifier models.CriterionModifier + depth int + } + + tests := []struct { + name string + c criterion + q string + includeIdxs []int + }{ + { + "includes", + criterion{ + []int{groupIdxWithChild}, + models.CriterionModifierIncludes, + 0, + }, + "", + []int{groupIdxWithParent}, + }, + { + "excludes", + criterion{ + []int{groupIdxWithChild}, + models.CriterionModifierExcludes, + 0, + }, + getGroupStringValue(groupIdxWithParent, nameField), + nil, + }, + { + "includes (all levels)", + criterion{ + []int{groupIdxWithGrandChild}, + models.CriterionModifierIncludes, + -1, + }, + "", + []int{groupIdxWithParentAndChild, groupIdxWithGrandParent}, + }, + { + "includes (1 level)", + criterion{ + []int{groupIdxWithGrandChild}, + models.CriterionModifierIncludes, + 1, + }, + "", + []int{groupIdxWithParentAndChild, groupIdxWithGrandParent}, + }, + { + "is null", + criterion{ + nil, + models.CriterionModifierIsNull, + 0, + }, + getGroupStringValue(groupIdxWithParent, nameField), + nil, + }, + { + "not null", + criterion{ + nil, + models.CriterionModifierNotNull, + 0, + }, + "", + []int{groupIdxWithParentAndChild, groupIdxWithParent, groupIdxWithGrandParent, groupIdxWithParentAndScene}, + }, + } + + qb := db.Group + + for _, tt := range tests { + valueIDs := indexesToIDs(groupIDs, tt.c.valueIdxs) + expectedIDs := indexesToIDs(groupIDs, tt.includeIdxs) + + runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { + groupFilter := &models.GroupFilterType{ + ContainingGroups: &models.HierarchicalMultiCriterionInput{ + Value: intslice.IntSliceToStringSlice(valueIDs), + Modifier: tt.c.modifier, + }, + } + + if tt.c.depth != 0 { + groupFilter.ContainingGroups.Depth = &tt.c.depth + } + + findFilter := models.FindFilterType{} + if tt.q != "" { + findFilter.Q = &tt.q + } + + groups, _, err := qb.Query(ctx, groupFilter, &findFilter) + if err != nil { + t.Errorf("GroupStore.Query() error = %v", err) + return + } + + // get ids of groups + groupIDs := sliceutil.Map(groups, func(g *models.Group) int { return g.ID }) + assert.ElementsMatch(t, expectedIDs, groupIDs) + }) + } +} + +func TestGroupQuerySubGroups(t *testing.T) { + const nameField = "Name" + + type criterion struct { + valueIdxs []int + modifier models.CriterionModifier + depth int + } + + tests := []struct { + name string + c criterion + q string + expectedIdxs []int + }{ + { + "includes", + criterion{ + []int{groupIdxWithParent}, + models.CriterionModifierIncludes, + 0, + }, + "", + []int{groupIdxWithChild}, + }, + { + "excludes", + criterion{ + []int{groupIdxWithParent}, + models.CriterionModifierExcludes, + 0, + }, + getGroupStringValue(groupIdxWithChild, nameField), + nil, + }, + { + "includes (all levels)", + criterion{ + []int{groupIdxWithGrandParent}, + models.CriterionModifierIncludes, + -1, + }, + "", + []int{groupIdxWithGrandChild, groupIdxWithParentAndChild}, + }, + { + "includes (1 level)", + criterion{ + []int{groupIdxWithGrandParent}, + models.CriterionModifierIncludes, + 1, + }, + "", + []int{groupIdxWithGrandChild, groupIdxWithParentAndChild}, + }, + { + "is null", + criterion{ + nil, + models.CriterionModifierIsNull, + 0, + }, + getGroupStringValue(groupIdxWithChild, nameField), + nil, + }, + { + "not null", + criterion{ + nil, + models.CriterionModifierNotNull, + 0, + }, + "", + []int{groupIdxWithGrandChild, groupIdxWithChild, groupIdxWithParentAndChild, groupIdxWithChildWithScene}, + }, + } + + qb := db.Group + + for _, tt := range tests { + valueIDs := indexesToIDs(groupIDs, tt.c.valueIdxs) + expectedIDs := indexesToIDs(groupIDs, tt.expectedIdxs) + + runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { + groupFilter := &models.GroupFilterType{ + SubGroups: &models.HierarchicalMultiCriterionInput{ + Value: intslice.IntSliceToStringSlice(valueIDs), + Modifier: tt.c.modifier, + }, + } + + if tt.c.depth != 0 { + groupFilter.SubGroups.Depth = &tt.c.depth + } + + findFilter := models.FindFilterType{} + if tt.q != "" { + findFilter.Q = &tt.q + } + + groups, _, err := qb.Query(ctx, groupFilter, &findFilter) + if err != nil { + t.Errorf("GroupStore.Query() error = %v", err) + return + } + + // get ids of groups + groupIDs := sliceutil.Map(groups, func(g *models.Group) int { return g.ID }) + assert.ElementsMatch(t, expectedIDs, groupIDs) + }) + } +} + +func TestGroupQueryContainingGroupCount(t *testing.T) { + const nameField = "Name" + + tests := []struct { + name string + value int + modifier models.CriterionModifier + q string + expectedIdxs []int + }{ + { + "equals", + 1, + models.CriterionModifierEquals, + "", + []int{groupIdxWithParent, groupIdxWithGrandParent, groupIdxWithParentAndChild, groupIdxWithParentAndScene}, + }, + { + "not equals", + 1, + models.CriterionModifierNotEquals, + getGroupStringValue(groupIdxWithParent, nameField), + nil, + }, + { + "less than", + 1, + models.CriterionModifierLessThan, + getGroupStringValue(groupIdxWithParent, nameField), + nil, + }, + { + "greater than", + 0, + models.CriterionModifierGreaterThan, + "", + []int{groupIdxWithParent, groupIdxWithGrandParent, groupIdxWithParentAndChild, groupIdxWithParentAndScene}, + }, + } + + qb := db.Group + + for _, tt := range tests { + expectedIDs := indexesToIDs(groupIDs, tt.expectedIdxs) + + runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { + groupFilter := &models.GroupFilterType{ + ContainingGroupCount: &models.IntCriterionInput{ + Value: tt.value, + Modifier: tt.modifier, + }, + } + + findFilter := models.FindFilterType{} + if tt.q != "" { + findFilter.Q = &tt.q + } + + groups, _, err := qb.Query(ctx, groupFilter, &findFilter) + if err != nil { + t.Errorf("GroupStore.Query() error = %v", err) + return + } + + // get ids of groups + groupIDs := sliceutil.Map(groups, func(g *models.Group) int { return g.ID }) + assert.ElementsMatch(t, expectedIDs, groupIDs) + }) + } +} + +func TestGroupQuerySubGroupCount(t *testing.T) { + const nameField = "Name" + + tests := []struct { + name string + value int + modifier models.CriterionModifier + q string + expectedIdxs []int + }{ + { + "equals", + 1, + models.CriterionModifierEquals, + "", + []int{groupIdxWithChild, groupIdxWithGrandChild, groupIdxWithParentAndChild, groupIdxWithChildWithScene}, + }, + { + "not equals", + 1, + models.CriterionModifierNotEquals, + getGroupStringValue(groupIdxWithChild, nameField), + nil, + }, + { + "less than", + 1, + models.CriterionModifierLessThan, + getGroupStringValue(groupIdxWithChild, nameField), + nil, + }, + { + "greater than", + 0, + models.CriterionModifierGreaterThan, + "", + []int{groupIdxWithChild, groupIdxWithGrandChild, groupIdxWithParentAndChild, groupIdxWithChildWithScene}, + }, + } + + qb := db.Group + + for _, tt := range tests { + expectedIDs := indexesToIDs(groupIDs, tt.expectedIdxs) + + runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { + groupFilter := &models.GroupFilterType{ + SubGroupCount: &models.IntCriterionInput{ + Value: tt.value, + Modifier: tt.modifier, + }, + } + + findFilter := models.FindFilterType{} + if tt.q != "" { + findFilter.Q = &tt.q + } + + groups, _, err := qb.Query(ctx, groupFilter, &findFilter) + if err != nil { + t.Errorf("GroupStore.Query() error = %v", err) + return + } + + // get ids of groups + groupIDs := sliceutil.Map(groups, func(g *models.Group) int { return g.ID }) + assert.ElementsMatch(t, expectedIDs, groupIDs) + }) + } +} + +func TestGroupFindInAncestors(t *testing.T) { + tests := []struct { + name string + ancestorIdxs []int + idxs []int + expectedIdxs []int + }{ + { + "basic", + []int{groupIdxWithGrandParent}, + []int{groupIdxWithGrandChild}, + []int{groupIdxWithGrandChild}, + }, + { + "same", + []int{groupIdxWithScene}, + []int{groupIdxWithScene}, + []int{groupIdxWithScene}, + }, + { + "no matches", + []int{groupIdxWithGrandParent}, + []int{groupIdxWithScene}, + nil, + }, + } + + qb := db.Group + + for _, tt := range tests { + ancestorIDs := indexesToIDs(groupIDs, tt.ancestorIdxs) + ids := indexesToIDs(groupIDs, tt.idxs) + expectedIDs := indexesToIDs(groupIDs, tt.expectedIdxs) + + runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { + found, err := qb.FindInAncestors(ctx, ancestorIDs, ids) + if err != nil { + t.Errorf("GroupStore.FindInAncestors() error = %v", err) + return + } + + // get ids of groups + assert.ElementsMatch(t, found, expectedIDs) + }) + } +} + +func TestGroupReorderSubGroups(t *testing.T) { + tests := []struct { + name string + subGroupLen int + idxsToMove []int + insertLoc int + insertAfter bool + // order of elements, using original indexes + expectedIdxs []int + }{ + { + "move single back before", + 5, + []int{2}, + 1, + false, + []int{0, 2, 1, 3, 4}, + }, + { + "move single forward before", + 5, + []int{2}, + 4, + false, + []int{0, 1, 3, 2, 4}, + }, + { + "move multiple back before", + 5, + []int{3, 2, 4}, + 0, + false, + []int{3, 2, 4, 0, 1}, + }, + { + "move multiple forward before", + 5, + []int{2, 1, 0}, + 4, + false, + []int{3, 2, 1, 0, 4}, + }, + { + "move single back after", + 5, + []int{2}, + 0, + true, + []int{0, 2, 1, 3, 4}, + }, + { + "move single forward after", + 5, + []int{2}, + 4, + true, + []int{0, 1, 3, 4, 2}, + }, + { + "move multiple back after", + 5, + []int{3, 2, 4}, + 0, + false, + []int{0, 3, 2, 4, 1}, + }, + { + "move multiple forward after", + 5, + []int{2, 1, 0}, + 4, + false, + []int{3, 4, 2, 1, 0}, + }, + } + + qb := db.Group + + for _, tt := range tests { + runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { + // create the group + group := models.Group{ + Name: "TestGroupReorderSubGroups", + } + + if err := qb.Create(ctx, &group); err != nil { + t.Errorf("GroupStore.Create() error = %v", err) + return + } + + // and sub-groups + idxToId := make([]int, tt.subGroupLen) + + for i := 0; i < tt.subGroupLen; i++ { + subGroup := models.Group{ + Name: fmt.Sprintf("SubGroup %d", i), + ContainingGroups: models.NewRelatedGroupDescriptions([]models.GroupIDDescription{ + {GroupID: group.ID}, + }), + } + + if err := qb.Create(ctx, &subGroup); err != nil { + t.Errorf("GroupStore.Create() error = %v", err) + return + } + + idxToId[i] = subGroup.ID + } + + // reorder + idsToMove := indexesToIDs(idxToId, tt.idxsToMove) + insertID := idxToId[tt.insertLoc] + if err := qb.ReorderSubGroups(ctx, group.ID, idsToMove, insertID, tt.insertAfter); err != nil { + t.Errorf("GroupStore.ReorderSubGroups() error = %v", err) + return + } + + // validate the new order + gd, err := qb.GetSubGroupDescriptions(ctx, group.ID) + if err != nil { + t.Errorf("GroupStore.GetSubGroupDescriptions() error = %v", err) + return + } + + // get ids of groups + newIDs := sliceutil.Map(gd, func(gd models.GroupIDDescription) int { return gd.GroupID }) + newIdxs := sliceutil.Map(newIDs, func(id int) int { return sliceutil.Index(idxToId, id) }) + + assert.ElementsMatch(t, tt.expectedIdxs, newIdxs) + }) + } +} + +func TestGroupAddSubGroups(t *testing.T) { + tests := []struct { + name string + existingSubGroupLen int + insertGroupsLen int + insertLoc int + // order of elements, using original indexes + expectedIdxs []int + }{ + { + "append single", + 4, + 1, + 999, + []int{0, 1, 2, 3, 4}, + }, + { + "insert single middle", + 4, + 1, + 2, + []int{0, 1, 4, 2, 3}, + }, + { + "insert single start", + 4, + 1, + 0, + []int{4, 0, 1, 2, 3}, + }, + { + "append multiple", + 4, + 2, + 999, + []int{0, 1, 2, 3, 4, 5}, + }, + { + "insert multiple middle", + 4, + 2, + 2, + []int{0, 1, 4, 5, 2, 3}, + }, + { + "insert multiple start", + 4, + 2, + 0, + []int{4, 5, 0, 1, 2, 3}, + }, + } + + qb := db.Group + + for _, tt := range tests { + runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { + // create the group + group := models.Group{ + Name: "TestGroupReorderSubGroups", + } + + if err := qb.Create(ctx, &group); err != nil { + t.Errorf("GroupStore.Create() error = %v", err) + return + } + + // and sub-groups + idxToId := make([]int, tt.existingSubGroupLen+tt.insertGroupsLen) + + for i := 0; i < tt.existingSubGroupLen; i++ { + subGroup := models.Group{ + Name: fmt.Sprintf("Existing SubGroup %d", i), + ContainingGroups: models.NewRelatedGroupDescriptions([]models.GroupIDDescription{ + {GroupID: group.ID}, + }), + } + + if err := qb.Create(ctx, &subGroup); err != nil { + t.Errorf("GroupStore.Create() error = %v", err) + return + } + + idxToId[i] = subGroup.ID + } + + // and sub-groups to insert + for i := 0; i < tt.insertGroupsLen; i++ { + subGroup := models.Group{ + Name: fmt.Sprintf("Inserted SubGroup %d", i), + } + + if err := qb.Create(ctx, &subGroup); err != nil { + t.Errorf("GroupStore.Create() error = %v", err) + return + } + + idxToId[i+tt.existingSubGroupLen] = subGroup.ID + } + + // convert ids to description + idDescriptions := make([]models.GroupIDDescription, tt.insertGroupsLen) + for i, id := range idxToId[tt.existingSubGroupLen:] { + idDescriptions[i] = models.GroupIDDescription{GroupID: id} + } + + // add + if err := qb.AddSubGroups(ctx, group.ID, idDescriptions, &tt.insertLoc); err != nil { + t.Errorf("GroupStore.AddSubGroups() error = %v", err) + return + } + + // validate the new order + gd, err := qb.GetSubGroupDescriptions(ctx, group.ID) + if err != nil { + t.Errorf("GroupStore.GetSubGroupDescriptions() error = %v", err) + return + } + + // get ids of groups + newIDs := sliceutil.Map(gd, func(gd models.GroupIDDescription) int { return gd.GroupID }) + newIdxs := sliceutil.Map(newIDs, func(id int) int { return sliceutil.Index(idxToId, id) }) + + assert.ElementsMatch(t, tt.expectedIdxs, newIdxs) + }) + } +} + +func TestGroupRemoveSubGroups(t *testing.T) { + tests := []struct { + name string + subGroupLen int + removeIdxs []int + // order of elements, using original indexes + expectedIdxs []int + }{ + { + "remove last", + 4, + []int{3}, + []int{0, 1, 2}, + }, + { + "remove first", + 4, + []int{0}, + []int{1, 2, 3}, + }, + { + "remove middle", + 4, + []int{2}, + []int{0, 1, 3}, + }, + { + "remove multiple", + 4, + []int{1, 3}, + []int{0, 2}, + }, + { + "remove all", + 4, + []int{0, 1, 2, 3}, + []int{}, + }, + } + + qb := db.Group + + for _, tt := range tests { + runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { + // create the group + group := models.Group{ + Name: "TestGroupReorderSubGroups", + } + + if err := qb.Create(ctx, &group); err != nil { + t.Errorf("GroupStore.Create() error = %v", err) + return + } + + // and sub-groups + idxToId := make([]int, tt.subGroupLen) + + for i := 0; i < tt.subGroupLen; i++ { + subGroup := models.Group{ + Name: fmt.Sprintf("Existing SubGroup %d", i), + ContainingGroups: models.NewRelatedGroupDescriptions([]models.GroupIDDescription{ + {GroupID: group.ID}, + }), + } + + if err := qb.Create(ctx, &subGroup); err != nil { + t.Errorf("GroupStore.Create() error = %v", err) + return + } + + idxToId[i] = subGroup.ID + } + + idsToRemove := indexesToIDs(idxToId, tt.removeIdxs) + if err := qb.RemoveSubGroups(ctx, group.ID, idsToRemove); err != nil { + t.Errorf("GroupStore.RemoveSubGroups() error = %v", err) + return + } + + // validate the new order + gd, err := qb.GetSubGroupDescriptions(ctx, group.ID) + if err != nil { + t.Errorf("GroupStore.GetSubGroupDescriptions() error = %v", err) + return + } + + // get ids of groups + newIDs := sliceutil.Map(gd, func(gd models.GroupIDDescription) int { return gd.GroupID }) + newIdxs := sliceutil.Map(newIDs, func(id int) int { return sliceutil.Index(idxToId, id) }) + + assert.ElementsMatch(t, tt.expectedIdxs, newIdxs) + }) + } +} + +func TestGroupFindSubGroupIDs(t *testing.T) { + tests := []struct { + name string + containingGroupIdx int + subIdxs []int + expectedIdxs []int + }{ + { + "overlap", + groupIdxWithGrandChild, + []int{groupIdxWithParentAndChild, groupIdxWithGrandParent}, + []int{groupIdxWithParentAndChild}, + }, + { + "non-overlap", + groupIdxWithGrandChild, + []int{groupIdxWithGrandParent}, + []int{}, + }, + { + "none", + groupIdxWithScene, + []int{groupIdxWithDupName}, + []int{}, + }, + { + "invalid", + invalidID, + []int{invalidID}, + []int{}, + }, + } + + qb := db.Group + + for _, tt := range tests { + runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { + subIDs := indexesToIDs(groupIDs, tt.subIdxs) + + id := indexToID(groupIDs, tt.containingGroupIdx) + + found, err := qb.FindSubGroupIDs(ctx, id, subIDs) + if err != nil { + t.Errorf("GroupStore.FindSubGroupIDs() error = %v", err) + return + } + + // get ids of groups + foundIdxs := sliceutil.Map(found, func(id int) int { return sliceutil.Index(groupIDs, id) }) + + assert.ElementsMatch(t, tt.expectedIdxs, foundIdxs) + }) + } +} + // TODO Update // TODO Destroy - ensure image is destroyed // TODO Find diff --git a/pkg/sqlite/migrations/67_group_relationships.up.sql b/pkg/sqlite/migrations/67_group_relationships.up.sql new file mode 100644 index 00000000000..76ac29cc83f --- /dev/null +++ b/pkg/sqlite/migrations/67_group_relationships.up.sql @@ -0,0 +1,13 @@ +CREATE TABLE `groups_relations` ( + `containing_id` integer not null, + `sub_id` integer not null, + `order_index` integer not null, + `description` varchar(255), + primary key (`containing_id`, `sub_id`), + foreign key (`containing_id`) references `groups`(`id`) on delete cascade, + foreign key (`sub_id`) references `groups`(`id`) on delete cascade, + check (`containing_id` != `sub_id`) +); + +CREATE INDEX `index_groups_relations_sub_id` ON `groups_relations` (`sub_id`); +CREATE UNIQUE INDEX `index_groups_relations_order_index_unique` ON `groups_relations` (`containing_id`, `order_index`); diff --git a/pkg/sqlite/query.go b/pkg/sqlite/query.go index 597ab66b98f..9c09d8beaed 100644 --- a/pkg/sqlite/query.go +++ b/pkg/sqlite/query.go @@ -110,6 +110,16 @@ func (qb *queryBuilder) addArg(args ...interface{}) { qb.args = append(qb.args, args...) } +func (qb *queryBuilder) hasJoin(alias string) bool { + for _, j := range qb.joins { + if j.alias() == alias { + return true + } + } + + return false +} + func (qb *queryBuilder) join(table, as, onClause string) { newJoin := join{ table: table, diff --git a/pkg/sqlite/scene.go b/pkg/sqlite/scene.go index 9b8bd73157b..c950be4d160 100644 --- a/pkg/sqlite/scene.go +++ b/pkg/sqlite/scene.go @@ -791,13 +791,6 @@ func (qb *SceneStore) FindByGroupID(ctx context.Context, groupID int) ([]*models return ret, nil } -func (qb *SceneStore) CountByGroupID(ctx context.Context, groupID int) (int, error) { - joinTable := scenesGroupsJoinTable - - q := dialect.Select(goqu.COUNT("*")).From(joinTable).Where(joinTable.Col(groupIDColumn).Eq(groupID)) - return count(ctx, q) -} - func (qb *SceneStore) Count(ctx context.Context) (int, error) { q := dialect.Select(goqu.COUNT("*")).From(qb.table()) return count(ctx, q) @@ -858,6 +851,7 @@ func (qb *SceneStore) PlayDuration(ctx context.Context) (float64, error) { return ret, nil } +// TODO - currently only used by unit test func (qb *SceneStore) CountByStudioID(ctx context.Context, studioID int) (int, error) { table := qb.table() @@ -865,13 +859,6 @@ func (qb *SceneStore) CountByStudioID(ctx context.Context, studioID int) (int, e return count(ctx, q) } -func (qb *SceneStore) CountByTagID(ctx context.Context, tagID int) (int, error) { - joinTable := scenesTagsJoinTable - - q := dialect.Select(goqu.COUNT("*")).From(joinTable).Where(joinTable.Col(tagIDColumn).Eq(tagID)) - return count(ctx, q) -} - func (qb *SceneStore) countMissingFingerprints(ctx context.Context, fpType string) (int, error) { fpTable := fingerprintTableMgr.table.As("fingerprints_temp") diff --git a/pkg/sqlite/scene_filter.go b/pkg/sqlite/scene_filter.go index 3f2233395fa..2e63dad975f 100644 --- a/pkg/sqlite/scene_filter.go +++ b/pkg/sqlite/scene_filter.go @@ -149,7 +149,7 @@ func (qb *sceneFilterHandler) criterionHandler() criterionHandler { studioCriterionHandler(sceneTable, sceneFilter.Studios), qb.groupsCriterionHandler(sceneFilter.Groups), - qb.groupsCriterionHandler(sceneFilter.Movies), + qb.moviesCriterionHandler(sceneFilter.Movies), qb.galleriesCriterionHandler(sceneFilter.Galleries), qb.performerTagsCriterionHandler(sceneFilter.PerformerTags), @@ -483,7 +483,8 @@ func (qb *sceneFilterHandler) performerAgeCriterionHandler(performerAge *models. } } -func (qb *sceneFilterHandler) groupsCriterionHandler(movies *models.MultiCriterionInput) criterionHandlerFunc { +// legacy handler +func (qb *sceneFilterHandler) moviesCriterionHandler(movies *models.MultiCriterionInput) criterionHandlerFunc { addJoinsFunc := func(f *filterBuilder) { sceneRepository.groups.join(f, "", "scenes.id") f.addLeftJoin("groups", "", "groups_scenes.group_id = groups.id") @@ -492,6 +493,23 @@ func (qb *sceneFilterHandler) groupsCriterionHandler(movies *models.MultiCriteri return h.handler(movies) } +func (qb *sceneFilterHandler) groupsCriterionHandler(groups *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { + h := joinedHierarchicalMultiCriterionHandlerBuilder{ + primaryTable: sceneTable, + foreignTable: groupTable, + foreignFK: "group_id", + + relationsTable: groupRelationsTable, + parentFK: "containing_id", + childFK: "sub_id", + joinAs: "scene_group", + joinTable: groupsScenesTable, + primaryFK: sceneIDColumn, + } + + return h.handler(groups) +} + func (qb *sceneFilterHandler) galleriesCriterionHandler(galleries *models.MultiCriterionInput) criterionHandlerFunc { addJoinsFunc := func(f *filterBuilder) { sceneRepository.galleries.join(f, "", "scenes.id") diff --git a/pkg/sqlite/scene_test.go b/pkg/sqlite/scene_test.go index 9116158fc9f..a3174d7278d 100644 --- a/pkg/sqlite/scene_test.go +++ b/pkg/sqlite/scene_test.go @@ -16,6 +16,7 @@ import ( "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/sliceutil" + "github.com/stashapp/stash/pkg/sliceutil/intslice" "github.com/stretchr/testify/assert" ) @@ -2217,7 +2218,7 @@ func TestSceneQuery(t *testing.T) { }, }) if (err != nil) != tt.wantErr { - t.Errorf("PerformerStore.Query() error = %v, wantErr %v", err, tt.wantErr) + t.Errorf("SceneStore.Query() error = %v, wantErr %v", err, tt.wantErr) return } @@ -3873,6 +3874,100 @@ func TestSceneQueryStudioDepth(t *testing.T) { }) } +func TestSceneGroups(t *testing.T) { + type criterion struct { + valueIdxs []int + modifier models.CriterionModifier + depth int + } + + tests := []struct { + name string + c criterion + q string + includeIdxs []int + excludeIdxs []int + }{ + { + "includes", + criterion{ + []int{groupIdxWithScene}, + models.CriterionModifierIncludes, + 0, + }, + "", + []int{sceneIdxWithGroup}, + nil, + }, + { + "excludes", + criterion{ + []int{groupIdxWithScene}, + models.CriterionModifierExcludes, + 0, + }, + getSceneStringValue(sceneIdxWithGroup, titleField), + nil, + []int{sceneIdxWithGroup}, + }, + { + "includes (depth = 1)", + criterion{ + []int{groupIdxWithChildWithScene}, + models.CriterionModifierIncludes, + 1, + }, + "", + []int{sceneIdxWithGroupWithParent}, + nil, + }, + } + + for _, tt := range tests { + valueIDs := indexesToIDs(groupIDs, tt.c.valueIdxs) + + runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { + assert := assert.New(t) + + sceneFilter := &models.SceneFilterType{ + Groups: &models.HierarchicalMultiCriterionInput{ + Value: intslice.IntSliceToStringSlice(valueIDs), + Modifier: tt.c.modifier, + }, + } + + if tt.c.depth != 0 { + sceneFilter.Groups.Depth = &tt.c.depth + } + + findFilter := &models.FindFilterType{} + if tt.q != "" { + findFilter.Q = &tt.q + } + + results, err := db.Scene.Query(ctx, models.SceneQueryOptions{ + SceneFilter: sceneFilter, + QueryOptions: models.QueryOptions{ + FindFilter: findFilter, + }, + }) + if err != nil { + t.Errorf("SceneStore.Query() error = %v", err) + return + } + + include := indexesToIDs(sceneIDs, tt.includeIdxs) + exclude := indexesToIDs(sceneIDs, tt.excludeIdxs) + + assert.Subset(results.IDs, include) + + for _, e := range exclude { + assert.NotContains(results.IDs, e) + } + }) + } +} + func TestSceneQueryMovies(t *testing.T) { withTxn(func(ctx context.Context) error { sqb := db.Scene @@ -4188,78 +4283,6 @@ func verifyScenesPerformerCount(t *testing.T, performerCountCriterion models.Int }) } -func TestSceneCountByTagID(t *testing.T) { - withTxn(func(ctx context.Context) error { - sqb := db.Scene - - sceneCount, err := sqb.CountByTagID(ctx, tagIDs[tagIdxWithScene]) - - if err != nil { - t.Errorf("error calling CountByTagID: %s", err.Error()) - } - - assert.Equal(t, 1, sceneCount) - - sceneCount, err = sqb.CountByTagID(ctx, 0) - - if err != nil { - t.Errorf("error calling CountByTagID: %s", err.Error()) - } - - assert.Equal(t, 0, sceneCount) - - return nil - }) -} - -func TestSceneCountByGroupID(t *testing.T) { - withTxn(func(ctx context.Context) error { - sqb := db.Scene - - sceneCount, err := sqb.CountByGroupID(ctx, groupIDs[groupIdxWithScene]) - - if err != nil { - t.Errorf("error calling CountByGroupID: %s", err.Error()) - } - - assert.Equal(t, 1, sceneCount) - - sceneCount, err = sqb.CountByGroupID(ctx, 0) - - if err != nil { - t.Errorf("error calling CountByGroupID: %s", err.Error()) - } - - assert.Equal(t, 0, sceneCount) - - return nil - }) -} - -func TestSceneCountByStudioID(t *testing.T) { - withTxn(func(ctx context.Context) error { - sqb := db.Scene - - sceneCount, err := sqb.CountByStudioID(ctx, studioIDs[studioIdxWithScene]) - - if err != nil { - t.Errorf("error calling CountByStudioID: %s", err.Error()) - } - - assert.Equal(t, 1, sceneCount) - - sceneCount, err = sqb.CountByStudioID(ctx, 0) - - if err != nil { - t.Errorf("error calling CountByStudioID: %s", err.Error()) - } - - assert.Equal(t, 0, sceneCount) - - return nil - }) -} - func TestFindByMovieID(t *testing.T) { withTxn(func(ctx context.Context) error { sqb := db.Scene diff --git a/pkg/sqlite/setup_test.go b/pkg/sqlite/setup_test.go index aa6af73c4c9..624ffb4e222 100644 --- a/pkg/sqlite/setup_test.go +++ b/pkg/sqlite/setup_test.go @@ -78,6 +78,7 @@ const ( sceneIdxWithGrandChildStudio sceneIdxMissingPhash sceneIdxWithPerformerParentTag + sceneIdxWithGroupWithParent // new indexes above lastSceneIdx @@ -153,9 +154,15 @@ const ( groupIdxWithTag groupIdxWithTwoTags groupIdxWithThreeTags + groupIdxWithGrandChild + groupIdxWithChild + groupIdxWithParentAndChild + groupIdxWithParent + groupIdxWithGrandParent + groupIdxWithParentAndScene + groupIdxWithChildWithScene // groups with dup names start from the end - // create 7 more basic groups (can remove this if we add more indexes) - groupIdxWithDupName = groupIdxWithStudio + 7 + groupIdxWithDupName groupsNameCase = groupIdxWithDupName groupsNameNoCase = 1 @@ -390,7 +397,8 @@ var ( } sceneGroups = linkMap{ - sceneIdxWithGroup: {groupIdxWithScene}, + sceneIdxWithGroup: {groupIdxWithScene}, + sceneIdxWithGroupWithParent: {groupIdxWithParentAndScene}, } sceneStudios = map[int]int{ @@ -541,15 +549,31 @@ var ( } ) +var ( + groupParentLinks = [][2]int{ + {groupIdxWithChild, groupIdxWithParent}, + {groupIdxWithGrandChild, groupIdxWithParentAndChild}, + {groupIdxWithParentAndChild, groupIdxWithGrandParent}, + {groupIdxWithChildWithScene, groupIdxWithParentAndScene}, + } +) + func indexesToIDs(ids []int, indexes []int) []int { ret := make([]int, len(indexes)) for i, idx := range indexes { - ret[i] = ids[idx] + ret[i] = indexToID(ids, idx) } return ret } +func indexToID(ids []int, idx int) int { + if idx < 0 { + return invalidID + } + return ids[idx] +} + func indexFromID(ids []int, id int) int { for i, v := range ids { if v == id { @@ -697,6 +721,10 @@ func populateDB() error { return fmt.Errorf("error linking tags parent: %s", err.Error()) } + if err := linkGroupsParent(ctx, db.Group); err != nil { + return fmt.Errorf("error linking tags parent: %s", err.Error()) + } + for _, ms := range markerSpecs { if err := createMarker(ctx, db.SceneMarker, ms); err != nil { return fmt.Errorf("error creating scene marker: %s", err.Error()) @@ -1885,6 +1913,24 @@ func linkTagsParent(ctx context.Context, qb models.TagReaderWriter) error { }) } +func linkGroupsParent(ctx context.Context, qb models.GroupReaderWriter) error { + return doLinks(groupParentLinks, func(parentIndex, childIndex int) error { + groupID := groupIDs[childIndex] + + p := models.GroupPartial{ + ContainingGroups: &models.UpdateGroupDescriptions{ + Groups: []models.GroupIDDescription{ + {GroupID: groupIDs[parentIndex]}, + }, + Mode: models.RelationshipUpdateModeAdd, + }, + } + + _, err := qb.UpdatePartial(ctx, groupID, p) + return err + }) +} + func addTagImage(ctx context.Context, qb models.TagWriter, tagIndex int) error { return qb.UpdateImage(ctx, tagIDs[tagIndex], []byte("image")) } diff --git a/pkg/sqlite/tables.go b/pkg/sqlite/tables.go index 365abe81292..74a5ebe698c 100644 --- a/pkg/sqlite/tables.go +++ b/pkg/sqlite/tables.go @@ -37,8 +37,9 @@ var ( studiosTagsJoinTable = goqu.T(studiosTagsTable) studiosStashIDsJoinTable = goqu.T("studio_stash_ids") - groupsURLsJoinTable = goqu.T(groupURLsTable) - groupsTagsJoinTable = goqu.T(groupsTagsTable) + groupsURLsJoinTable = goqu.T(groupURLsTable) + groupsTagsJoinTable = goqu.T(groupsTagsTable) + groupRelationsJoinTable = goqu.T(groupRelationsTable) tagsAliasesJoinTable = goqu.T(tagAliasesTable) tagRelationsJoinTable = goqu.T(tagRelationsTable) @@ -361,6 +362,10 @@ var ( foreignTable: tagTableMgr, orderBy: tagTableMgr.table.Col("name").Asc(), } + + groupRelationshipTableMgr = &table{ + table: groupRelationsJoinTable, + } ) var ( diff --git a/pkg/sqlite/tag_filter.go b/pkg/sqlite/tag_filter.go index 26e33c36c6e..ba9e9bb08ec 100644 --- a/pkg/sqlite/tag_filter.go +++ b/pkg/sqlite/tag_filter.go @@ -2,7 +2,6 @@ package sqlite import ( "context" - "fmt" "github.com/stashapp/stash/pkg/models" ) @@ -51,6 +50,14 @@ func (qb *tagFilterHandler) handle(ctx context.Context, f *filterBuilder) { f.handleCriterion(ctx, qb.criterionHandler()) } +var tagHierarchyHandler = hierarchicalRelationshipHandler{ + primaryTable: tagTable, + relationTable: tagRelationsTable, + aliasPrefix: tagTable, + parentIDCol: "parent_id", + childIDCol: "child_id", +} + func (qb *tagFilterHandler) criterionHandler() criterionHandler { tagFilter := qb.tagFilter return compoundHandler{ @@ -72,10 +79,10 @@ func (qb *tagFilterHandler) criterionHandler() criterionHandler { qb.groupCountCriterionHandler(tagFilter.MovieCount), qb.markerCountCriterionHandler(tagFilter.MarkerCount), - qb.parentsCriterionHandler(tagFilter.Parents), - qb.childrenCriterionHandler(tagFilter.Children), - qb.parentCountCriterionHandler(tagFilter.ParentCount), - qb.childCountCriterionHandler(tagFilter.ChildCount), + tagHierarchyHandler.ParentsCriterionHandler(tagFilter.Parents), + tagHierarchyHandler.ChildrenCriterionHandler(tagFilter.Children), + tagHierarchyHandler.ParentCountCriterionHandler(tagFilter.ParentCount), + tagHierarchyHandler.ChildCountCriterionHandler(tagFilter.ChildCount), ×tampCriterionHandler{tagFilter.CreatedAt, "tags.created_at", nil}, ×tampCriterionHandler{tagFilter.UpdatedAt, "tags.updated_at", nil}, @@ -212,213 +219,3 @@ func (qb *tagFilterHandler) markerCountCriterionHandler(markerCount *models.IntC } } } - -func (qb *tagFilterHandler) parentsCriterionHandler(criterion *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if criterion != nil { - tags := criterion.CombineExcludes() - - // validate the modifier - switch tags.Modifier { - case models.CriterionModifierIncludesAll, models.CriterionModifierIncludes, models.CriterionModifierExcludes, models.CriterionModifierIsNull, models.CriterionModifierNotNull: - // valid - default: - f.setError(fmt.Errorf("invalid modifier %s for tag parent/children", criterion.Modifier)) - } - - if tags.Modifier == models.CriterionModifierIsNull || tags.Modifier == models.CriterionModifierNotNull { - var notClause string - if tags.Modifier == models.CriterionModifierNotNull { - notClause = "NOT" - } - - f.addLeftJoin("tags_relations", "parent_relations", "tags.id = parent_relations.child_id") - - f.addWhere(fmt.Sprintf("parent_relations.parent_id IS %s NULL", notClause)) - return - } - - if len(tags.Value) == 0 && len(tags.Excludes) == 0 { - return - } - - if len(tags.Value) > 0 { - var args []interface{} - for _, val := range tags.Value { - args = append(args, val) - } - - depthVal := 0 - if tags.Depth != nil { - depthVal = *tags.Depth - } - - var depthCondition string - if depthVal != -1 { - depthCondition = fmt.Sprintf("WHERE depth < %d", depthVal) - } - - query := `parents AS ( - SELECT parent_id AS root_id, child_id AS item_id, 0 AS depth FROM tags_relations WHERE parent_id IN` + getInBinding(len(tags.Value)) + ` - UNION - SELECT root_id, child_id, depth + 1 FROM tags_relations INNER JOIN parents ON item_id = parent_id ` + depthCondition + ` - )` - - f.addRecursiveWith(query, args...) - - f.addLeftJoin("parents", "", "parents.item_id = tags.id") - - addHierarchicalConditionClauses(f, tags, "parents", "root_id") - } - - if len(tags.Excludes) > 0 { - var args []interface{} - for _, val := range tags.Excludes { - args = append(args, val) - } - - depthVal := 0 - if tags.Depth != nil { - depthVal = *tags.Depth - } - - var depthCondition string - if depthVal != -1 { - depthCondition = fmt.Sprintf("WHERE depth < %d", depthVal) - } - - query := `parents2 AS ( - SELECT parent_id AS root_id, child_id AS item_id, 0 AS depth FROM tags_relations WHERE parent_id IN` + getInBinding(len(tags.Excludes)) + ` - UNION - SELECT root_id, child_id, depth + 1 FROM tags_relations INNER JOIN parents2 ON item_id = parent_id ` + depthCondition + ` - )` - - f.addRecursiveWith(query, args...) - - f.addLeftJoin("parents2", "", "parents2.item_id = tags.id") - - addHierarchicalConditionClauses(f, models.HierarchicalMultiCriterionInput{ - Value: tags.Excludes, - Depth: tags.Depth, - Modifier: models.CriterionModifierExcludes, - }, "parents2", "root_id") - } - } - } -} - -func (qb *tagFilterHandler) childrenCriterionHandler(criterion *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if criterion != nil { - tags := criterion.CombineExcludes() - - // validate the modifier - switch tags.Modifier { - case models.CriterionModifierIncludesAll, models.CriterionModifierIncludes, models.CriterionModifierExcludes, models.CriterionModifierIsNull, models.CriterionModifierNotNull: - // valid - default: - f.setError(fmt.Errorf("invalid modifier %s for tag parent/children", criterion.Modifier)) - } - - if tags.Modifier == models.CriterionModifierIsNull || tags.Modifier == models.CriterionModifierNotNull { - var notClause string - if tags.Modifier == models.CriterionModifierNotNull { - notClause = "NOT" - } - - f.addLeftJoin("tags_relations", "child_relations", "tags.id = child_relations.parent_id") - - f.addWhere(fmt.Sprintf("child_relations.child_id IS %s NULL", notClause)) - return - } - - if len(tags.Value) == 0 && len(tags.Excludes) == 0 { - return - } - - if len(tags.Value) > 0 { - var args []interface{} - for _, val := range tags.Value { - args = append(args, val) - } - - depthVal := 0 - if tags.Depth != nil { - depthVal = *tags.Depth - } - - var depthCondition string - if depthVal != -1 { - depthCondition = fmt.Sprintf("WHERE depth < %d", depthVal) - } - - query := `children AS ( - SELECT child_id AS root_id, parent_id AS item_id, 0 AS depth FROM tags_relations WHERE child_id IN` + getInBinding(len(tags.Value)) + ` - UNION - SELECT root_id, parent_id, depth + 1 FROM tags_relations INNER JOIN children ON item_id = child_id ` + depthCondition + ` - )` - - f.addRecursiveWith(query, args...) - - f.addLeftJoin("children", "", "children.item_id = tags.id") - - addHierarchicalConditionClauses(f, tags, "children", "root_id") - } - - if len(tags.Excludes) > 0 { - var args []interface{} - for _, val := range tags.Excludes { - args = append(args, val) - } - - depthVal := 0 - if tags.Depth != nil { - depthVal = *tags.Depth - } - - var depthCondition string - if depthVal != -1 { - depthCondition = fmt.Sprintf("WHERE depth < %d", depthVal) - } - - query := `children2 AS ( - SELECT child_id AS root_id, parent_id AS item_id, 0 AS depth FROM tags_relations WHERE child_id IN` + getInBinding(len(tags.Excludes)) + ` - UNION - SELECT root_id, parent_id, depth + 1 FROM tags_relations INNER JOIN children2 ON item_id = child_id ` + depthCondition + ` - )` - - f.addRecursiveWith(query, args...) - - f.addLeftJoin("children2", "", "children2.item_id = tags.id") - - addHierarchicalConditionClauses(f, models.HierarchicalMultiCriterionInput{ - Value: tags.Excludes, - Depth: tags.Depth, - Modifier: models.CriterionModifierExcludes, - }, "children2", "root_id") - } - } - } -} - -func (qb *tagFilterHandler) parentCountCriterionHandler(parentCount *models.IntCriterionInput) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if parentCount != nil { - f.addLeftJoin("tags_relations", "parents_count", "parents_count.child_id = tags.id") - clause, args := getIntCriterionWhereClause("count(distinct parents_count.parent_id)", *parentCount) - - f.addHaving(clause, args...) - } - } -} - -func (qb *tagFilterHandler) childCountCriterionHandler(childCount *models.IntCriterionInput) criterionHandlerFunc { - return func(ctx context.Context, f *filterBuilder) { - if childCount != nil { - f.addLeftJoin("tags_relations", "children_count", "children_count.parent_id = tags.id") - clause, args := getIntCriterionWhereClause("count(distinct children_count.child_id)", *childCount) - - f.addHaving(clause, args...) - } - } -} diff --git a/pkg/sqlite/tag_test.go b/pkg/sqlite/tag_test.go index f673567f82e..5359be78517 100644 --- a/pkg/sqlite/tag_test.go +++ b/pkg/sqlite/tag_test.go @@ -712,7 +712,7 @@ func TestTagQueryParent(t *testing.T) { assert.Len(t, tags, 1) // ensure id is correct - assert.Equal(t, sceneIDs[tagIdxWithParentTag], tags[0].ID) + assert.Equal(t, tagIDs[tagIdxWithParentTag], tags[0].ID) tagCriterion.Modifier = models.CriterionModifierExcludes diff --git a/ui/v2.5/graphql/data/group.graphql b/ui/v2.5/graphql/data/group.graphql index 60f55e30948..963e8d6e672 100644 --- a/ui/v2.5/graphql/data/group.graphql +++ b/ui/v2.5/graphql/data/group.graphql @@ -15,11 +15,21 @@ fragment GroupData on Group { ...SlimTagData } + containing_groups { + group { + ...SlimGroupData + } + description + } + synopsis urls front_image_path back_image_path scene_count + scene_count_all: scene_count(depth: -1) + sub_group_count + sub_group_count_all: sub_group_count(depth: -1) scenes { id diff --git a/ui/v2.5/graphql/mutations/group.graphql b/ui/v2.5/graphql/mutations/group.graphql index fb739e84009..8065e4adbb7 100644 --- a/ui/v2.5/graphql/mutations/group.graphql +++ b/ui/v2.5/graphql/mutations/group.graphql @@ -23,3 +23,15 @@ mutation GroupDestroy($id: ID!) { mutation GroupsDestroy($ids: [ID!]!) { groupsDestroy(ids: $ids) } + +mutation AddGroupSubGroups($input: GroupSubGroupAddInput!) { + addGroupSubGroups(input: $input) +} + +mutation RemoveGroupSubGroups($input: GroupSubGroupRemoveInput!) { + removeGroupSubGroups(input: $input) +} + +mutation ReorderSubGroups($input: ReorderSubGroupsInput!) { + reorderSubGroups(input: $input) +} diff --git a/ui/v2.5/src/components/Groups/ContainingGroupsMultiSet.tsx b/ui/v2.5/src/components/Groups/ContainingGroupsMultiSet.tsx new file mode 100644 index 00000000000..25ad0be4a3e --- /dev/null +++ b/ui/v2.5/src/components/Groups/ContainingGroupsMultiSet.tsx @@ -0,0 +1,61 @@ +import React from "react"; +import * as GQL from "src/core/generated-graphql"; +import { MultiSetModeButtons } from "../Shared/MultiSet"; +import { + IRelatedGroupEntry, + RelatedGroupTable, +} from "./GroupDetails/RelatedGroupTable"; +import { Group, GroupSelect } from "./GroupSelect"; + +export const ContainingGroupsMultiSet: React.FC<{ + existingValue?: IRelatedGroupEntry[]; + value: IRelatedGroupEntry[]; + mode: GQL.BulkUpdateIdMode; + disabled?: boolean; + onUpdate: (value: IRelatedGroupEntry[]) => void; + onSetMode: (mode: GQL.BulkUpdateIdMode) => void; +}> = (props) => { + const { mode, onUpdate, existingValue } = props; + + function onSetMode(m: GQL.BulkUpdateIdMode) { + if (m === mode) { + return; + } + + // if going to Set, set the existing ids + if (m === GQL.BulkUpdateIdMode.Set && existingValue) { + onUpdate(existingValue); + // if going from Set, wipe the ids + } else if ( + m !== GQL.BulkUpdateIdMode.Set && + mode === GQL.BulkUpdateIdMode.Set + ) { + onUpdate([]); + } + + props.onSetMode(m); + } + + function onRemoveSet(items: Group[]) { + onUpdate(items.map((group) => ({ group }))); + } + + return ( +
    + + {mode !== GQL.BulkUpdateIdMode.Remove ? ( + + ) : ( + onRemoveSet(items)} + values={[]} + isDisabled={props.disabled} + /> + )} +
    + ); +}; diff --git a/ui/v2.5/src/components/Groups/EditGroupsDialog.tsx b/ui/v2.5/src/components/Groups/EditGroupsDialog.tsx index 5e0360d6964..d404ccf9c33 100644 --- a/ui/v2.5/src/components/Groups/EditGroupsDialog.tsx +++ b/ui/v2.5/src/components/Groups/EditGroupsDialog.tsx @@ -9,6 +9,7 @@ import { useToast } from "src/hooks/Toast"; import * as FormUtils from "src/utils/form"; import { RatingSystem } from "../Shared/Rating/RatingSystem"; import { + getAggregateIds, getAggregateInputIDs, getAggregateInputValue, getAggregateRating, @@ -18,12 +19,54 @@ import { import { faPencilAlt } from "@fortawesome/free-solid-svg-icons"; import { isEqual } from "lodash-es"; import { MultiSet } from "../Shared/MultiSet"; +import { ContainingGroupsMultiSet } from "./ContainingGroupsMultiSet"; +import { IRelatedGroupEntry } from "./GroupDetails/RelatedGroupTable"; interface IListOperationProps { selected: GQL.GroupDataFragment[]; onClose: (applied: boolean) => void; } +export function getAggregateContainingGroups( + state: Pick[] +) { + const sortedLists: IRelatedGroupEntry[][] = state.map((o) => + o.containing_groups + .map((oo) => ({ + group: oo.group, + description: oo.description, + })) + .sort((a, b) => a.group.id.localeCompare(b.group.id)) + ); + + return getAggregateIds(sortedLists); +} + +function getAggregateContainingGroupInput( + mode: GQL.BulkUpdateIdMode, + input: IRelatedGroupEntry[] | undefined, + aggregateValues: IRelatedGroupEntry[] +): GQL.BulkUpdateGroupDescriptionsInput | undefined { + if (mode === GQL.BulkUpdateIdMode.Set && (!input || input.length === 0)) { + // and all scenes have the same ids, + if (aggregateValues.length > 0) { + // then unset, otherwise ignore + return { mode, groups: [] }; + } + } else { + // if input non-empty, then we are setting them + return { + mode, + groups: + input?.map((e) => { + return { group_id: e.group.id, description: e.description }; + }) || [], + }; + } + + return undefined; +} + export const EditGroupsDialog: React.FC = ( props: IListOperationProps ) => { @@ -39,6 +82,12 @@ export const EditGroupsDialog: React.FC = ( const [tagIds, setTagIds] = useState(); const [existingTagIds, setExistingTagIds] = useState(); + const [containingGroupsMode, setGroupMode] = + React.useState(GQL.BulkUpdateIdMode.Add); + const [containingGroups, setGroups] = useState(); + const [existingContainingGroups, setExistingContainingGroups] = + useState(); + const [updateGroups] = useBulkGroupUpdate(getGroupInput()); const [isUpdating, setIsUpdating] = useState(false); @@ -47,17 +96,23 @@ export const EditGroupsDialog: React.FC = ( const aggregateRating = getAggregateRating(props.selected); const aggregateStudioId = getAggregateStudioId(props.selected); const aggregateTagIds = getAggregateTagIds(props.selected); + const aggregateGroups = getAggregateContainingGroups(props.selected); const groupInput: GQL.BulkGroupUpdateInput = { ids: props.selected.map((group) => group.id), director, }; - // if rating is undefined groupInput.rating100 = getAggregateInputValue(rating100, aggregateRating); groupInput.studio_id = getAggregateInputValue(studioId, aggregateStudioId); groupInput.tag_ids = getAggregateInputIDs(tagMode, tagIds, aggregateTagIds); + groupInput.containing_groups = getAggregateContainingGroupInput( + containingGroupsMode, + containingGroups, + aggregateGroups + ); + return groupInput; } @@ -85,17 +140,22 @@ export const EditGroupsDialog: React.FC = ( let updateRating: number | undefined; let updateStudioId: string | undefined; let updateTagIds: string[] = []; + let updateContainingGroupIds: IRelatedGroupEntry[] = []; let updateDirector: string | undefined; let first = true; state.forEach((group: GQL.GroupDataFragment) => { const groupTagIDs = (group.tags ?? []).map((p) => p.id).sort(); + const groupContainingGroupIDs = (group.containing_groups ?? []).sort( + (a, b) => a.group.id.localeCompare(b.group.id) + ); if (first) { first = false; updateRating = group.rating100 ?? undefined; updateStudioId = group.studio?.id ?? undefined; updateTagIds = groupTagIDs; + updateContainingGroupIds = groupContainingGroupIDs; updateDirector = group.director ?? undefined; } else { if (group.rating100 !== updateRating) { @@ -110,12 +170,16 @@ export const EditGroupsDialog: React.FC = ( if (!isEqual(groupTagIDs, updateTagIds)) { updateTagIds = []; } + if (!isEqual(groupContainingGroupIDs, updateContainingGroupIds)) { + updateTagIds = []; + } } }); setRating(updateRating); setStudioId(updateStudioId); setExistingTagIds(updateTagIds); + setExistingContainingGroups(updateContainingGroupIds); setDirector(updateDirector); }, [props.selected]); @@ -166,6 +230,19 @@ export const EditGroupsDialog: React.FC = ( /> + + + + + setGroups(v)} + onSetMode={(newMode) => setGroupMode(newMode)} + existingValue={existingContainingGroups ?? []} + value={containingGroups ?? []} + mode={containingGroupsMode} + /> + diff --git a/ui/v2.5/src/components/Groups/GroupCard.tsx b/ui/v2.5/src/components/Groups/GroupCard.tsx index ff84262533f..85fa9ed077e 100644 --- a/ui/v2.5/src/components/Groups/GroupCard.tsx +++ b/ui/v2.5/src/components/Groups/GroupCard.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from "react"; +import React, { useEffect, useMemo, useState } from "react"; import { Button, ButtonGroup } from "react-bootstrap"; import * as GQL from "src/core/generated-graphql"; import { GridCard, calculateCardWidth } from "../Shared/GridCard/GridCard"; @@ -10,26 +10,66 @@ import { FormattedMessage } from "react-intl"; import { RatingBanner } from "../Shared/RatingBanner"; import { faPlayCircle, faTag } from "@fortawesome/free-solid-svg-icons"; import ScreenUtils from "src/utils/screen"; +import { RelatedGroupPopoverButton } from "./RelatedGroupPopover"; + +const Description: React.FC<{ + sceneNumber?: number; + description?: string; +}> = ({ sceneNumber, description }) => { + if (!sceneNumber && !description) return null; + + return ( + <> +
    + {sceneNumber !== undefined && ( + + #{sceneNumber} + + )} + {description !== undefined && ( + + {description} + + )} + + ); +}; interface IProps { group: GQL.GroupDataFragment; containerWidth?: number; - sceneIndex?: number; + sceneNumber?: number; selecting?: boolean; selected?: boolean; onSelectedChanged?: (selected: boolean, shiftKey: boolean) => void; + fromGroupId?: string; + onMove?: (srcIds: string[], targetId: string, after: boolean) => void; } export const GroupCard: React.FC = ({ group, - sceneIndex, + sceneNumber, containerWidth, selecting, selected, onSelectedChanged, + fromGroupId, + onMove, }) => { const [cardWidth, setCardWidth] = useState(); + const groupDescription = useMemo(() => { + if (!fromGroupId) { + return undefined; + } + + const containingGroup = group.containing_groups.find( + (cg) => cg.group.id === fromGroupId + ); + + return containingGroup?.description ?? undefined; + }, [fromGroupId, group.containing_groups]); + useEffect(() => { if (!containerWidth || ScreenUtils.isMobile()) return; @@ -41,19 +81,6 @@ export const GroupCard: React.FC = ({ setCardWidth(fittedCardWidth); }, [containerWidth]); - function maybeRenderSceneNumber() { - if (!sceneIndex) return; - - return ( - <> -
    - - #{sceneIndex} - - - ); - } - function maybeRenderScenesPopoverButton() { if (group.scenes.length === 0) return; @@ -93,14 +120,28 @@ export const GroupCard: React.FC = ({ } function maybeRenderPopoverButtonGroup() { - if (sceneIndex || group.scenes.length > 0 || group.tags.length > 0) { + if ( + sceneNumber || + groupDescription || + group.scenes.length > 0 || + group.tags.length > 0 || + group.containing_groups.length > 0 || + group.sub_group_count > 0 + ) { return ( <> - {maybeRenderSceneNumber()} +
    {maybeRenderScenesPopoverButton()} {maybeRenderTagPopoverButton()} + {(group.sub_group_count > 0 || + group.containing_groups.length > 0) && ( + + )} ); @@ -110,6 +151,8 @@ export const GroupCard: React.FC = ({ return ( ; onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void; + fromGroupId?: string; + onMove?: (srcIds: string[], targetId: string, after: boolean) => void; } export const GroupCardGrid: React.FC = ({ groups, selectedIds, onSelectChange, + fromGroupId, + onMove, }) => { const [componentRef, { width }] = useContainerDimensions(); return ( @@ -27,6 +31,8 @@ export const GroupCardGrid: React.FC = ({ onSelectedChanged={(selected: boolean, shiftKey: boolean) => onSelectChange(p.id, selected, shiftKey) } + fromGroupId={fromGroupId} + onMove={onMove} /> ))}
    diff --git a/ui/v2.5/src/components/Groups/GroupDetails/AddGroupsDialog.tsx b/ui/v2.5/src/components/Groups/GroupDetails/AddGroupsDialog.tsx new file mode 100644 index 00000000000..b893568101d --- /dev/null +++ b/ui/v2.5/src/components/Groups/GroupDetails/AddGroupsDialog.tsx @@ -0,0 +1,121 @@ +import React, { useCallback, useMemo, useState } from "react"; +import { Form } from "react-bootstrap"; +import { useIntl } from "react-intl"; +import * as GQL from "src/core/generated-graphql"; +import { useToast } from "src/hooks/Toast"; +import { faPlus } from "@fortawesome/free-solid-svg-icons"; +import { RelatedGroupTable, IRelatedGroupEntry } from "./RelatedGroupTable"; +import { ModalComponent } from "src/components/Shared/Modal"; +import { useAddSubGroups } from "src/core/StashService"; +import { ListFilterModel } from "src/models/list-filter/filter"; +import { + ContainingGroupsCriterionOption, + GroupsCriterion, +} from "src/models/list-filter/criteria/groups"; + +interface IListOperationProps { + containingGroup: GQL.GroupDataFragment; + onClose: (applied: boolean) => void; +} + +export const AddSubGroupsDialog: React.FC = ( + props: IListOperationProps +) => { + const intl = useIntl(); + const [isUpdating, setIsUpdating] = useState(false); + + const addSubGroups = useAddSubGroups(); + + const Toast = useToast(); + + const [entries, setEntries] = useState([]); + + const excludeIDs = useMemo( + () => [ + ...props.containingGroup.containing_groups.map((m) => m.group.id), + props.containingGroup.id, + ], + [props.containingGroup] + ); + + const filterHook = useCallback( + (f: ListFilterModel) => { + const groupValue = { + id: props.containingGroup.id, + label: props.containingGroup.name, + }; + + // filter out sub groups that are already in the containing group + const criterion = new GroupsCriterion(ContainingGroupsCriterionOption); + criterion.value = { + items: [groupValue], + depth: 1, + excluded: [], + }; + criterion.modifier = GQL.CriterionModifier.Excludes; + f.criteria.push(criterion); + + return f; + }, + [props.containingGroup] + ); + + const onSave = async () => { + setIsUpdating(true); + try { + // add the sub groups + await addSubGroups( + props.containingGroup.id, + entries.map((m) => ({ + group_id: m.group.id, + description: m.description, + })) + ); + + const imageCount = entries.length; + Toast.success( + intl.formatMessage( + { id: "toast.added_entity" }, + { + count: imageCount, + singularEntity: intl.formatMessage({ id: "group" }), + pluralEntity: intl.formatMessage({ id: "groups" }), + } + ) + ); + + props.onClose(true); + } catch (err) { + Toast.error(err); + } finally { + setIsUpdating(false); + } + }; + + return ( + props.onClose(false), + text: intl.formatMessage({ id: "actions.cancel" }), + variant: "secondary", + }} + isRunning={isUpdating} + > +
    + setEntries(input)} + excludeIDs={excludeIDs} + filterHook={filterHook} + /> + +
    + ); +}; diff --git a/ui/v2.5/src/components/Groups/GroupDetails/Group.tsx b/ui/v2.5/src/components/Groups/GroupDetails/Group.tsx index 7ef2ca8e5fd..0aa4dbd5472 100644 --- a/ui/v2.5/src/components/Groups/GroupDetails/Group.tsx +++ b/ui/v2.5/src/components/Groups/GroupDetails/Group.tsx @@ -9,7 +9,7 @@ import { useGroupUpdate, useGroupDestroy, } from "src/core/StashService"; -import { useHistory, RouteComponentProps } from "react-router-dom"; +import { useHistory, RouteComponentProps, Redirect } from "react-router-dom"; import { DetailsEditNavbar } from "src/components/Shared/DetailsEditNavbar"; import { ErrorMessage } from "src/components/Shared/ErrorMessage"; import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; @@ -35,16 +35,89 @@ import { ExpandCollapseButton } from "src/components/Shared/CollapseButton"; import { AliasList } from "src/components/Shared/DetailsPage/AliasList"; import { HeaderImage } from "src/components/Shared/DetailsPage/HeaderImage"; import { LightboxLink } from "src/hooks/Lightbox/LightboxLink"; +import { + TabTitleCounter, + useTabKey, +} from "src/components/Shared/DetailsPage/Tabs"; +import { Tab, Tabs } from "react-bootstrap"; +import { GroupSubGroupsPanel } from "./GroupSubGroupsPanel"; + +const validTabs = ["default", "scenes", "subgroups"] as const; +type TabKey = (typeof validTabs)[number]; + +function isTabKey(tab: string): tab is TabKey { + return validTabs.includes(tab as TabKey); +} + +const GroupTabs: React.FC<{ + tabKey?: TabKey; + group: GQL.GroupDataFragment; + abbreviateCounter: boolean; +}> = ({ tabKey, group, abbreviateCounter }) => { + const { scene_count: sceneCount, sub_group_count: groupCount } = group; + + const populatedDefaultTab = useMemo(() => { + if (sceneCount == 0 && groupCount !== 0) { + return "subgroups"; + } + + return "scenes"; + }, [sceneCount, groupCount]); + + const { setTabKey } = useTabKey({ + tabKey, + validTabs, + defaultTabKey: populatedDefaultTab, + baseURL: `/groups/${group.id}`, + }); + + return ( + + + } + > + + + + } + > + + + + ); +}; interface IProps { group: GQL.GroupDataFragment; + tabKey?: TabKey; } interface IGroupParams { id: string; + tab?: string; } -const GroupPage: React.FC = ({ group }) => { +const GroupPage: React.FC = ({ group, tabKey }) => { const intl = useIntl(); const history = useHistory(); const Toast = useToast(); @@ -55,6 +128,7 @@ const GroupPage: React.FC = ({ group }) => { const enableBackgroundImage = uiConfig?.enableMovieBackgroundImage ?? false; const compactExpandedDetails = uiConfig?.compactExpandedDetails ?? false; const showAllDetails = uiConfig?.showAllDetails ?? true; + const abbreviateCounter = uiConfig?.abbreviateCounters ?? false; const [collapsed, setCollapsed] = useState(!showAllDetails); const loadStickyHeader = useLoadStickyHeader(); @@ -230,14 +304,6 @@ const GroupPage: React.FC = ({ group }) => { } } - const renderTabs = () => ; - - function maybeRenderTab() { - if (!isEditing) { - return renderTabs(); - } - } - if (updating || deleting) return ; const headerClassName = cx("detail-header", { @@ -335,7 +401,15 @@ const GroupPage: React.FC = ({ group }) => {
    -
    {maybeRenderTab()}
    +
    + {!isEditing && ( + + )} +
    {renderDeleteAlert()} @@ -344,19 +418,33 @@ const GroupPage: React.FC = ({ group }) => { }; const GroupLoader: React.FC> = ({ + location, match, }) => { - const { id } = match.params; + const { id, tab } = match.params; const { data, loading, error } = useFindGroup(id); useScrollToTopOnMount(); + if (tab && !isTabKey(tab)) { + return ( + + ); + } + if (loading) return ; if (error) return ; if (!data?.findGroup) return ; - return ; + return ( + + ); }; export default GroupLoader; diff --git a/ui/v2.5/src/components/Groups/GroupDetails/GroupDetailsPanel.tsx b/ui/v2.5/src/components/Groups/GroupDetails/GroupDetailsPanel.tsx index eb3696550fe..6a20eb9081a 100644 --- a/ui/v2.5/src/components/Groups/GroupDetails/GroupDetailsPanel.tsx +++ b/ui/v2.5/src/components/Groups/GroupDetails/GroupDetailsPanel.tsx @@ -5,7 +5,28 @@ import TextUtils from "src/utils/text"; import { DetailItem } from "src/components/Shared/DetailItem"; import { Link } from "react-router-dom"; import { DirectorLink } from "src/components/Shared/Link"; -import { TagLink } from "src/components/Shared/TagLink"; +import { GroupLink, TagLink } from "src/components/Shared/TagLink"; + +interface IGroupDescription { + group: GQL.SlimGroupDataFragment; + description?: string | null; +} + +const GroupsList: React.FC<{ groups: IGroupDescription[] }> = ({ groups }) => { + if (!groups.length) { + return null; + } + + return ( +
      + {groups.map((entry) => ( +
    • + +
    • + ))} +
    + ); +}; interface IGroupDetailsPanel { group: GQL.GroupDataFragment; @@ -48,6 +69,13 @@ export const GroupDetailsPanel: React.FC = ({ value={renderTagsField()} fullWidth={fullWidth} /> + {group.containing_groups.length > 0 && ( + } + fullWidth={fullWidth} + /> + )} ); } diff --git a/ui/v2.5/src/components/Groups/GroupDetails/GroupEditPanel.tsx b/ui/v2.5/src/components/Groups/GroupDetails/GroupEditPanel.tsx index a3ccf5b8b33..0b94baf2791 100644 --- a/ui/v2.5/src/components/Groups/GroupDetails/GroupEditPanel.tsx +++ b/ui/v2.5/src/components/Groups/GroupDetails/GroupEditPanel.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from "react"; +import React, { useEffect, useMemo, useState } from "react"; import { FormattedMessage, useIntl } from "react-intl"; import * as GQL from "src/core/generated-graphql"; import * as yup from "yup"; @@ -26,6 +26,8 @@ import { } from "src/utils/yup"; import { Studio, StudioSelect } from "src/components/Studios/StudioSelect"; import { useTagsEdit } from "src/hooks/tagsEdit"; +import { Group } from "src/components/Groups/GroupSelect"; +import { RelatedGroupTable, IRelatedGroupEntry } from "./RelatedGroupTable"; interface IGroupEditPanel { group: Partial; @@ -60,6 +62,7 @@ export const GroupEditPanel: React.FC = ({ const [scrapedGroup, setScrapedGroup] = useState(); const [studio, setStudio] = useState(null); + const [containingGroups, setContainingGroups] = useState([]); const schema = yup.object({ name: yup.string().required(), @@ -68,6 +71,14 @@ export const GroupEditPanel: React.FC = ({ date: yupDateString(intl), studio_id: yup.string().required().nullable(), tag_ids: yup.array(yup.string().required()).defined(), + containing_groups: yup + .array( + yup.object({ + group_id: yup.string().required(), + description: yup.string().nullable().ensure(), + }) + ) + .defined(), director: yup.string().ensure(), urls: yupUniqueStringList(intl), synopsis: yup.string().ensure(), @@ -82,6 +93,9 @@ export const GroupEditPanel: React.FC = ({ date: group?.date ?? "", studio_id: group?.studio?.id ?? null, tag_ids: (group?.tags ?? []).map((t) => t.id), + containing_groups: (group?.containing_groups ?? []).map((m) => { + return { group_id: m.group.id, description: m.description ?? "" }; + }), director: group?.director ?? "", urls: group?.urls ?? [], synopsis: group?.synopsis ?? "", @@ -101,6 +115,17 @@ export const GroupEditPanel: React.FC = ({ (ids) => formik.setFieldValue("tag_ids", ids) ); + const containingGroupEntries = useMemo(() => { + return formik.values.containing_groups + .map((m) => { + return { + group: containingGroups.find((mm) => mm.id === m.group_id), + description: m.description, + }; + }) + .filter((m) => m.group !== undefined) as IRelatedGroupEntry[]; + }, [formik.values.containing_groups, containingGroups]); + function onSetStudio(item: Studio | null) { setStudio(item); formik.setFieldValue("studio_id", item ? item.id : null); @@ -110,6 +135,10 @@ export const GroupEditPanel: React.FC = ({ setStudio(group.studio ?? null); }, [group.studio]); + useEffect(() => { + setContainingGroups(group.containing_groups?.map((m) => m.group) ?? []); + }, [group.containing_groups]); + // set up hotkeys useEffect(() => { // Mousetrap.bind("u", (e) => { @@ -366,6 +395,30 @@ export const GroupEditPanel: React.FC = ({ return renderField("tag_ids", title, tagsControl()); } + function onSetContainingGroupEntries(input: IRelatedGroupEntry[]) { + setContainingGroups(input.map((m) => m.group)); + + const newGroups = input.map((m) => ({ + group_id: m.group.id, + description: m.description, + })); + + formik.setFieldValue("containing_groups", newGroups); + } + + function renderContainingGroupsField() { + const title = intl.formatMessage({ id: "containing_groups" }); + const control = ( + + ); + + return renderField("containing_groups", title, control); + } + // TODO: CSS class return (
    @@ -394,6 +447,7 @@ export const GroupEditPanel: React.FC = ({ {renderInputField("aliases")} {renderDurationField("duration")} {renderDateField("date")} + {renderContainingGroupsField()} {renderStudioField()} {renderInputField("director")} {renderURLListField("urls", onScrapeGroupURL, urlScrapable)} diff --git a/ui/v2.5/src/components/Groups/GroupDetails/GroupScenesPanel.tsx b/ui/v2.5/src/components/Groups/GroupDetails/GroupScenesPanel.tsx index acca9f0aae1..ab9ec3fea24 100644 --- a/ui/v2.5/src/components/Groups/GroupDetails/GroupScenesPanel.tsx +++ b/ui/v2.5/src/components/Groups/GroupDetails/GroupScenesPanel.tsx @@ -1,6 +1,9 @@ import React from "react"; import * as GQL from "src/core/generated-graphql"; -import { GroupsCriterion } from "src/models/list-filter/criteria/groups"; +import { + GroupsCriterion, + GroupsCriterionOption, +} from "src/models/list-filter/criteria/groups"; import { ListFilterModel } from "src/models/list-filter/filter"; import { SceneList } from "src/components/Scenes/SceneList"; import { View } from "src/components/List/views"; @@ -8,13 +11,14 @@ import { View } from "src/components/List/views"; interface IGroupScenesPanel { active: boolean; group: GQL.GroupDataFragment; + showSubGroupContent?: boolean; } -export const GroupScenesPanel: React.FC = ({ - active, - group, -}) => { - function filterHook(filter: ListFilterModel) { +function useFilterHook( + group: Pick, + showSubGroupContent?: boolean +) { + return (filter: ListFilterModel) => { const groupValue = { id: group.id, label: group.name }; // if group is already present, then we modify it, otherwise add let groupCriterion = filter.criteria.find((c) => { @@ -28,23 +32,35 @@ export const GroupScenesPanel: React.FC = ({ ) { // add the group if not present if ( - !groupCriterion.value.find((p) => { + !groupCriterion.value.items.find((p) => { return p.id === group.id; }) ) { - groupCriterion.value.push(groupValue); + groupCriterion.value.items.push(groupValue); } groupCriterion.modifier = GQL.CriterionModifier.IncludesAll; } else { // overwrite - groupCriterion = new GroupsCriterion(); - groupCriterion.value = [groupValue]; + groupCriterion = new GroupsCriterion(GroupsCriterionOption); + groupCriterion.value = { + items: [groupValue], + depth: showSubGroupContent ? -1 : 0, + excluded: [], + }; filter.criteria.push(groupCriterion); } return filter; - } + }; +} + +export const GroupScenesPanel: React.FC = ({ + active, + group, + showSubGroupContent, +}) => { + const filterHook = useFilterHook(group, showSubGroupContent); if (group && group.id) { return ( @@ -53,6 +69,7 @@ export const GroupScenesPanel: React.FC = ({ defaultSort="group_scene_number" alterQuery={active} view={View.GroupScenes} + fromGroupId={group.id} /> ); } diff --git a/ui/v2.5/src/components/Groups/GroupDetails/GroupSubGroupsPanel.tsx b/ui/v2.5/src/components/Groups/GroupDetails/GroupSubGroupsPanel.tsx new file mode 100644 index 00000000000..a2bb26e9511 --- /dev/null +++ b/ui/v2.5/src/components/Groups/GroupDetails/GroupSubGroupsPanel.tsx @@ -0,0 +1,204 @@ +import React, { useMemo } from "react"; +import * as GQL from "src/core/generated-graphql"; +import { GroupList } from "../GroupList"; +import { ListFilterModel } from "src/models/list-filter/filter"; +import { + ContainingGroupsCriterionOption, + GroupsCriterion, +} from "src/models/list-filter/criteria/groups"; +import { + useRemoveSubGroups, + useReorderSubGroupsMutation, +} from "src/core/StashService"; +import { ButtonToolbar } from "react-bootstrap"; +import { ListOperationButtons } from "src/components/List/ListOperationButtons"; +import { useListContext } from "src/components/List/ListProvider"; +import { + PageSizeSelector, + SearchTermInput, +} from "src/components/List/ListFilter"; +import { useFilter } from "src/components/List/FilterProvider"; +import { IFilteredListToolbar } from "src/components/List/FilteredListToolbar"; +import { + showWhenNoneSelected, + showWhenSelected, +} from "src/components/List/ItemList"; +import { faMinus, faPlus } from "@fortawesome/free-solid-svg-icons"; +import { useIntl } from "react-intl"; +import { useToast } from "src/hooks/Toast"; +import { useModal } from "src/hooks/modal"; +import { AddSubGroupsDialog } from "./AddGroupsDialog"; + +const useContainingGroupFilterHook = ( + group: Pick, + showSubGroupContent?: boolean +) => { + return (filter: ListFilterModel) => { + const groupValue = { id: group.id, label: group.name }; + // if studio is already present, then we modify it, otherwise add + let groupCriterion = filter.criteria.find((c) => { + return c.criterionOption.type === "containing_groups"; + }) as GroupsCriterion | undefined; + + if (groupCriterion) { + // add the group if not present + if ( + !groupCriterion.value.items.find((p) => { + return p.id === group.id; + }) + ) { + groupCriterion.value.items.push(groupValue); + } + } else { + groupCriterion = new GroupsCriterion(ContainingGroupsCriterionOption); + groupCriterion.value = { + items: [groupValue], + excluded: [], + depth: showSubGroupContent ? -1 : 0, + }; + groupCriterion.modifier = GQL.CriterionModifier.Includes; + filter.criteria.push(groupCriterion); + } + + filter.sortBy = "sub_group_order"; + filter.sortDirection = GQL.SortDirectionEnum.Asc; + + return filter; + }; +}; + +const Toolbar: React.FC = ({ + onEdit, + onDelete, + operations, +}) => { + const { getSelected, onSelectAll, onSelectNone } = useListContext(); + const { filter, setFilter } = useFilter(); + + return ( + +
    + +
    + setFilter(filter.setPageSize(size))} + /> + 0} + otherOperations={operations} + onEdit={onEdit} + onDelete={onDelete} + /> +
    + ); +}; + +interface IGroupSubGroupsPanel { + active: boolean; + group: GQL.GroupDataFragment; +} + +export const GroupSubGroupsPanel: React.FC = ({ + active, + group, +}) => { + const intl = useIntl(); + const Toast = useToast(); + const { modal, showModal, closeModal } = useModal(); + + const [reorderSubGroups] = useReorderSubGroupsMutation(); + const mutateRemoveSubGroups = useRemoveSubGroups(); + + const filterHook = useContainingGroupFilterHook(group); + + const defaultFilter = useMemo(() => { + const sortBy = "sub_group_order"; + const ret = new ListFilterModel(GQL.FilterMode.Groups, undefined, { + defaultSortBy: sortBy, + }); + + // unset the sort by so that its not included in the URL + ret.sortBy = undefined; + + return ret; + }, []); + + async function removeSubGroups( + result: GQL.FindGroupsQueryResult, + filter: ListFilterModel, + selectedIds: Set + ) { + try { + await mutateRemoveSubGroups(group.id, Array.from(selectedIds.values())); + + Toast.success( + intl.formatMessage( + { id: "toast.removed_entity" }, + { + count: selectedIds.size, + singularEntity: intl.formatMessage({ id: "group" }), + pluralEntity: intl.formatMessage({ id: "groups" }), + } + ) + ); + } catch (e) { + Toast.error(e); + } + } + + async function onAddSubGroups() { + showModal( + + ); + } + + const otherOperations = [ + { + text: intl.formatMessage({ id: "actions.add_sub_groups" }), + onClick: onAddSubGroups, + isDisplayed: showWhenNoneSelected, + postRefetch: true, + icon: faPlus, + buttonVariant: "secondary", + }, + { + text: intl.formatMessage({ id: "actions.remove_from_containing_group" }), + onClick: removeSubGroups, + isDisplayed: showWhenSelected, + postRefetch: true, + icon: faMinus, + buttonVariant: "danger", + }, + ]; + + function onMove(srcIds: string[], targetId: string, after: boolean) { + reorderSubGroups({ + variables: { + input: { + group_id: group.id, + sub_group_ids: srcIds, + insert_at_id: targetId, + insert_after: after, + }, + }, + }); + } + + return ( + <> + {modal} + } + /> + + ); +}; diff --git a/ui/v2.5/src/components/Groups/GroupDetails/RelatedGroupTable.tsx b/ui/v2.5/src/components/Groups/GroupDetails/RelatedGroupTable.tsx new file mode 100644 index 00000000000..feed49ad09b --- /dev/null +++ b/ui/v2.5/src/components/Groups/GroupDetails/RelatedGroupTable.tsx @@ -0,0 +1,137 @@ +import React, { useMemo } from "react"; +import { FormattedMessage } from "react-intl"; +import * as GQL from "src/core/generated-graphql"; +import { Form, Row, Col } from "react-bootstrap"; +import { Group, GroupSelect } from "src/components/Groups/GroupSelect"; +import cx from "classnames"; +import { ListFilterModel } from "src/models/list-filter/filter"; + +export type GroupSceneIndexMap = Map; + +export interface IRelatedGroupEntry { + group: Group; + description?: GQL.InputMaybe | undefined; +} + +export const RelatedGroupTable: React.FC<{ + value: IRelatedGroupEntry[]; + onUpdate: (input: IRelatedGroupEntry[]) => void; + excludeIDs?: string[]; + filterHook?: (f: ListFilterModel) => ListFilterModel; + disabled?: boolean; +}> = (props) => { + const { value, onUpdate } = props; + + const groupIDs = useMemo(() => value.map((m) => m.group.id), [value]); + + const excludeIDs = useMemo( + () => [...groupIDs, ...(props.excludeIDs ?? [])], + [props.excludeIDs, groupIDs] + ); + + const updateFieldChanged = (index: number, description: string | null) => { + const newValues = value.map((existing, i) => { + if (i === index) { + return { + ...existing, + description, + }; + } + return existing; + }); + + onUpdate(newValues); + }; + + function onGroupSet(index: number, groups: Group[]) { + if (!groups.length) { + // remove this entry + const newValues = value.filter((_, i) => i !== index); + onUpdate(newValues); + return; + } + + const group = groups[0]; + + const newValues = value.map((existing, i) => { + if (i === index) { + return { + ...existing, + group: group, + }; + } + return existing; + }); + + onUpdate(newValues); + } + + function onNewGroupSet(groups: Group[]) { + if (!groups.length) { + return; + } + + const group = groups[0]; + + const newValues = [ + ...value, + { + group: group, + scene_index: null, + }, + ]; + + onUpdate(newValues); + } + + return ( +
    + + + + + + + {value.map((m, i) => ( + + + onGroupSet(i, items)} + values={[m.group!]} + excludeIds={excludeIDs} + filterHook={props.filterHook} + isDisabled={props.disabled} + /> + + + ) => { + updateFieldChanged( + i, + e.currentTarget.value === "" ? null : e.currentTarget.value + ); + }} + disabled={props.disabled} + /> + + + ))} + + + onNewGroupSet(items)} + values={[]} + excludeIds={excludeIDs} + filterHook={props.filterHook} + isDisabled={props.disabled} + /> + + +
    + ); +}; diff --git a/ui/v2.5/src/components/Groups/GroupList.tsx b/ui/v2.5/src/components/Groups/GroupList.tsx index ba45912762b..d3f395037d1 100644 --- a/ui/v2.5/src/components/Groups/GroupList.tsx +++ b/ui/v2.5/src/components/Groups/GroupList.tsx @@ -1,4 +1,4 @@ -import React, { useState } from "react"; +import React, { PropsWithChildren, useState } from "react"; import { useIntl } from "react-intl"; import cloneDeep from "lodash-es/cloneDeep"; import Mousetrap from "mousetrap"; @@ -17,6 +17,35 @@ import { DeleteEntityDialog } from "../Shared/DeleteEntityDialog"; import { GroupCardGrid } from "./GroupCardGrid"; import { EditGroupsDialog } from "./EditGroupsDialog"; import { View } from "../List/views"; +import { + IFilteredListToolbar, + IItemListOperation, +} from "../List/FilteredListToolbar"; + +const GroupExportDialog: React.FC<{ + open?: boolean; + selectedIds: Set; + isExportAll?: boolean; + onClose: () => void; +}> = ({ open = false, selectedIds, isExportAll = false, onClose }) => { + if (!open) { + return null; + } + + return ( + + ); +}; + +const filterMode = GQL.FilterMode.Groups; function getItems(result: GQL.FindGroupsQueryResult) { return result?.data?.findGroups?.groups ?? []; @@ -26,24 +55,57 @@ function getCount(result: GQL.FindGroupsQueryResult) { return result?.data?.findGroups?.count ?? 0; } -interface IGroupList { +interface IGroupListContext { filterHook?: (filter: ListFilterModel) => ListFilterModel; + defaultFilter?: ListFilterModel; view?: View; alterQuery?: boolean; + selectable?: boolean; +} + +export const GroupListContext: React.FC< + PropsWithChildren +> = ({ alterQuery, filterHook, defaultFilter, view, selectable, children }) => { + return ( + + {children} + + ); +}; + +interface IGroupList extends IGroupListContext { + fromGroupId?: string; + onMove?: (srcIds: string[], targetId: string, after: boolean) => void; + renderToolbar?: (props: IFilteredListToolbar) => React.ReactNode; + otherOperations?: IItemListOperation[]; } export const GroupList: React.FC = ({ filterHook, alterQuery, + defaultFilter, view, + fromGroupId, + onMove, + selectable, + renderToolbar, + otherOperations: providedOperations = [], }) => { const intl = useIntl(); const history = useHistory(); const [isExportDialogOpen, setIsExportDialogOpen] = useState(false); const [isExportAll, setIsExportAll] = useState(false); - const filterMode = GQL.FilterMode.Groups; - const otherOperations = [ { text: intl.formatMessage({ id: "actions.view_random" }), @@ -58,6 +120,7 @@ export const GroupList: React.FC = ({ text: intl.formatMessage({ id: "actions.export_all" }), onClick: onExportAll, }, + ...providedOperations, ]; function addKeybinds( @@ -110,42 +173,23 @@ export const GroupList: React.FC = ({ selectedIds: Set, onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void ) { - function maybeRenderGroupExportDialog() { - if (isExportDialogOpen) { - return ( - setIsExportDialogOpen(false)} - /> - ); - } - } - - function renderGroups() { - if (!result.data?.findGroups) return; - - if (filter.displayMode === DisplayMode.Grid) { - return ( + return ( + <> + setIsExportDialogOpen(false)} + /> + {filter.displayMode === DisplayMode.Grid && ( - ); - } - if (filter.displayMode === DisplayMode.List) { - return

    TODO

    ; - } - } - return ( - <> - {maybeRenderGroupExportDialog()} - {renderGroups()} + )} ); } @@ -173,15 +217,12 @@ export const GroupList: React.FC = ({ } return ( - = ({ renderContent={renderContent} renderEditDialog={renderEditDialog} renderDeleteDialog={renderDeleteDialog} + renderToolbar={renderToolbar} /> - + ); }; diff --git a/ui/v2.5/src/components/Groups/GroupSelect.tsx b/ui/v2.5/src/components/Groups/GroupSelect.tsx index 4f611e5e3b4..dd16088e9e7 100644 --- a/ui/v2.5/src/components/Groups/GroupSelect.tsx +++ b/ui/v2.5/src/components/Groups/GroupSelect.tsx @@ -56,13 +56,14 @@ const groupSelectSort = PatchFunction( sortGroupsByRelevance ); -const _GroupSelect: React.FC< +export const GroupSelect: React.FC< IFilterProps & IFilterValueProps & { hoverPlacement?: Placement; excludeIds?: string[]; + filterHook?: (f: ListFilterModel) => ListFilterModel; } -> = (props) => { +> = PatchComponent("GroupSelect", (props) => { const [createGroup] = useGroupCreate(); const { configuration } = React.useContext(ConfigurationContext); @@ -75,12 +76,17 @@ const _GroupSelect: React.FC< const exclude = useMemo(() => props.excludeIds ?? [], [props.excludeIds]); async function loadGroups(input: string): Promise { - const filter = new ListFilterModel(GQL.FilterMode.Groups); + let filter = new ListFilterModel(GQL.FilterMode.Groups); filter.searchTerm = input; filter.currentPage = 1; filter.itemsPerPage = maxOptionsShown; filter.sortBy = "name"; filter.sortDirection = GQL.SortDirectionEnum.Asc; + + if (props.filterHook) { + filter = props.filterHook(filter); + } + const query = await queryFindGroupsForSelect(filter); let ret = query.data.findGroups.groups.filter((group) => { // HACK - we should probably exclude these in the backend query, but @@ -255,9 +261,7 @@ const _GroupSelect: React.FC< closeMenuOnSelect={!props.isMulti} /> ); -}; - -export const GroupSelect = PatchComponent("GroupSelect", _GroupSelect); +}); const _GroupIDSelect: React.FC> = ( props diff --git a/ui/v2.5/src/components/Groups/GroupTag.tsx b/ui/v2.5/src/components/Groups/GroupTag.tsx new file mode 100644 index 00000000000..3443d18dfef --- /dev/null +++ b/ui/v2.5/src/components/Groups/GroupTag.tsx @@ -0,0 +1,28 @@ +import React from "react"; +import { Link } from "react-router-dom"; +import * as GQL from "src/core/generated-graphql"; +import { GroupLink } from "../Shared/TagLink"; + +export const GroupTag: React.FC<{ + group: Pick; + linkType?: "scene" | "sub_group" | "details"; + description?: string; +}> = ({ group, linkType, description }) => { + return ( +
    + + {group.name + + +
    + ); +}; diff --git a/ui/v2.5/src/components/Groups/RelatedGroupPopover.tsx b/ui/v2.5/src/components/Groups/RelatedGroupPopover.tsx new file mode 100644 index 00000000000..03095f284a4 --- /dev/null +++ b/ui/v2.5/src/components/Groups/RelatedGroupPopover.tsx @@ -0,0 +1,110 @@ +import { + faFilm, + faArrowUpLong, + faArrowDownLong, +} from "@fortawesome/free-solid-svg-icons"; +import React, { useMemo } from "react"; +import { Button, OverlayTrigger, Tooltip } from "react-bootstrap"; +import { Count } from "../Shared/PopoverCountButton"; +import { Icon } from "../Shared/Icon"; +import { HoverPopover } from "../Shared/HoverPopover"; +import { Link } from "react-router-dom"; +import NavUtils from "src/utils/navigation"; +import * as GQL from "src/core/generated-graphql"; +import { useIntl } from "react-intl"; +import { GroupTag } from "./GroupTag"; + +interface IProps { + group: Pick< + GQL.GroupDataFragment, + "id" | "name" | "containing_groups" | "sub_group_count" + >; +} + +const ContainingGroupsCount: React.FC = ({ group }) => { + const { containing_groups: containingGroups } = group; + + const popoverContent = useMemo(() => { + if (!containingGroups.length) { + return []; + } + + return containingGroups.map((entry) => ( + + )); + }, [containingGroups]); + + if (!containingGroups.length) { + return null; + } + + return ( + + + + + + + ); +}; + +const SubGroupCount: React.FC = ({ group }) => { + const intl = useIntl(); + + const count = group.sub_group_count; + + if (!count) { + return null; + } + + function getTitle() { + const pluralCategory = intl.formatPlural(count); + const options = { + one: "sub_group", + other: "sub_groups", + }; + const plural = intl.formatMessage({ + id: options[pluralCategory as "one"] || options.other, + }); + return `${count} ${plural}`; + } + + return ( + {getTitle()}} + placement="bottom" + > + + + + + + ); +}; + +export const RelatedGroupPopoverButton: React.FC = ({ group }) => { + return ( + + + + ); +}; diff --git a/ui/v2.5/src/components/Groups/styles.scss b/ui/v2.5/src/components/Groups/styles.scss index 3d1868fb815..1b80045c73d 100644 --- a/ui/v2.5/src/components/Groups/styles.scss +++ b/ui/v2.5/src/components/Groups/styles.scss @@ -14,7 +14,8 @@ width: 100%; } - .group-scene-number { + .group-scene-number, + .group-containing-group-description { text-align: center; } @@ -89,3 +90,24 @@ } } } + +.groups-list { + list-style-type: none; + padding-inline-start: 0; + + li { + display: inline; + } +} + +.related-group-popover-button { + .containing-group-count { + display: inline-block; + } + + .related-group-count .fa-icon { + color: $text-muted; + margin-left: 0; + margin-right: 0.25rem; + } +} diff --git a/ui/v2.5/src/components/List/FilteredListToolbar.tsx b/ui/v2.5/src/components/List/FilteredListToolbar.tsx index d6887c51de0..6018dd836f6 100644 --- a/ui/v2.5/src/components/List/FilteredListToolbar.tsx +++ b/ui/v2.5/src/components/List/FilteredListToolbar.tsx @@ -31,14 +31,16 @@ export interface IItemListOperation { buttonVariant?: string; } -export const FilteredListToolbar: React.FC<{ - showEditFilter: (editingCriterion?: string) => void; +export interface IFilteredListToolbar { + showEditFilter?: (editingCriterion?: string) => void; view?: View; onEdit?: () => void; onDelete?: () => void; operations?: IListFilterOperation[]; zoomable?: boolean; -}> = ({ +} + +export const FilteredListToolbar: React.FC = ({ showEditFilter, view, onEdit, @@ -60,13 +62,15 @@ export const FilteredListToolbar: React.FC<{ } return ( - - showEditFilter()} - view={view} - /> + + {showEditFilter && ( + showEditFilter()} + view={view} + /> + )} { @@ -59,6 +63,7 @@ interface IItemListProps { filter: ListFilterModel, selectedIds: Set ) => () => void; + renderToolbar?: (props: IFilteredListToolbar) => React.ReactNode; } export const ItemList = ( @@ -73,6 +78,7 @@ export const ItemList = ( renderDeleteDialog, renderMetadataByline, addKeybinds, + renderToolbar: providedToolbar, } = props; const { filter, setFilter: updateFilter } = useFilter(); @@ -142,28 +148,30 @@ export const ItemList = ( } }, [addKeybinds, result, effectiveFilter, selectedIds]); - async function onOperationClicked(o: IItemListOperation) { - await o.onClick(result, effectiveFilter, selectedIds); - if (o.postRefetch) { - result.refetch(); - } - } - - const operations = otherOperations?.map((o) => ({ - text: o.text, - onClick: () => { - onOperationClicked(o); - }, - isDisplayed: () => { - if (o.isDisplayed) { - return o.isDisplayed(result, effectiveFilter, selectedIds); + const operations = useMemo(() => { + async function onOperationClicked(o: IItemListOperation) { + await o.onClick(result, effectiveFilter, selectedIds); + if (o.postRefetch) { + result.refetch(); } + } - return true; - }, - icon: o.icon, - buttonVariant: o.buttonVariant, - })); + return otherOperations?.map((o) => ({ + text: o.text, + onClick: () => { + onOperationClicked(o); + }, + isDisplayed: () => { + if (o.isDisplayed) { + return o.isDisplayed(result, effectiveFilter, selectedIds); + } + + return true; + }, + icon: o.icon, + buttonVariant: o.buttonVariant, + })); + }, [result, effectiveFilter, selectedIds, otherOperations]); function onEdit() { if (!renderEditDialog) { @@ -215,16 +223,22 @@ export const ItemList = ( updateFilter(filter.clearCriteria()); } + const filterListToolbarProps = { + showEditFilter, + view: view, + operations: operations, + zoomable: zoomable, + onEdit: renderEditDialog ? onEdit : undefined, + onDelete: renderDeleteDialog ? onDelete : undefined, + }; + return (
    - + {providedToolbar ? ( + providedToolbar(filterListToolbarProps) + ) : ( + + )} showEditFilter(c.criterionOption.type)} @@ -258,6 +272,7 @@ export const ItemList = ( interface IItemListContextProps { filterMode: GQL.FilterMode; defaultSort?: string; + defaultFilter?: ListFilterModel; useResult: (filter: ListFilterModel) => T; getCount: (data: T) => number; getItems: (data: T) => E[]; @@ -275,6 +290,7 @@ export const ItemListContext = ( const { filterMode, defaultSort, + defaultFilter: providedDefaultFilter, useResult, getCount, getItems, @@ -287,10 +303,11 @@ export const ItemListContext = ( const emptyFilter = useMemo( () => + providedDefaultFilter?.clone() ?? new ListFilterModel(filterMode, undefined, { defaultSortBy: defaultSort, }), - [filterMode, defaultSort] + [filterMode, defaultSort, providedDefaultFilter] ); const [filter, setFilterState] = useState( @@ -343,3 +360,11 @@ export const showWhenSingleSelection = ( ) => { return selectedIds.size == 1; }; + +export const showWhenNoneSelected = ( + result: T, + filter: ListFilterModel, + selectedIds: Set +) => { + return selectedIds.size === 0; +}; diff --git a/ui/v2.5/src/components/List/ListFilter.tsx b/ui/v2.5/src/components/List/ListFilter.tsx index bff14336cfd..24ea02af1d4 100644 --- a/ui/v2.5/src/components/List/ListFilter.tsx +++ b/ui/v2.5/src/components/List/ListFilter.tsx @@ -1,5 +1,11 @@ import cloneDeep from "lodash-es/cloneDeep"; -import React, { useCallback, useEffect, useRef, useState } from "react"; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import Mousetrap from "mousetrap"; import { SortDirectionEnum } from "src/core/generated-graphql"; import { @@ -102,36 +108,17 @@ export const SearchTermInput: React.FC<{ ); }; -interface IListFilterProps { - onFilterUpdate: (newFilter: ListFilterModel) => void; - filter: ListFilterModel; - view?: View; - openFilterDialog: () => void; -} - const PAGE_SIZE_OPTIONS = ["20", "40", "60", "120", "250", "500", "1000"]; -export const ListFilter: React.FC = ({ - onFilterUpdate, - filter, - openFilterDialog, - view, -}) => { - const [customPageSizeShowing, setCustomPageSizeShowing] = useState(false); - const perPageSelect = useRef(null); - const [perPageInput, perPageFocus] = useFocus(); - - const filterOptions = filter.options; - +export const PageSizeSelector: React.FC<{ + pageSize: number; + setPageSize: (pageSize: number) => void; +}> = ({ pageSize, setPageSize }) => { const intl = useIntl(); - useEffect(() => { - Mousetrap.bind("r", () => onReshuffleRandomSort()); - - return () => { - Mousetrap.unbind("r"); - }; - }); + const perPageSelect = useRef(null); + const [perPageInput, perPageFocus] = useFocus(); + const [customPageSizeShowing, setCustomPageSizeShowing] = useState(false); useEffect(() => { if (customPageSizeShowing) { @@ -139,6 +126,27 @@ export const ListFilter: React.FC = ({ } }, [customPageSizeShowing, perPageFocus]); + const pageSizeOptions = useMemo(() => { + const ret = PAGE_SIZE_OPTIONS.map((o) => { + return { + label: o, + value: o, + }; + }); + const currentPerPage = pageSize.toString(); + if (!ret.find((o) => o.value === currentPerPage)) { + ret.push({ label: currentPerPage, value: currentPerPage }); + ret.sort((a, b) => parseInt(a.value, 10) - parseInt(b.value, 10)); + } + + ret.push({ + label: `${intl.formatMessage({ id: "custom" })}...`, + value: "custom", + }); + + return ret; + }, [intl, pageSize]); + function onChangePageSize(val: string) { if (val === "custom") { // added timeout since Firefox seems to trigger the rootClose immediately @@ -154,6 +162,94 @@ export const ListFilter: React.FC = ({ return; } + setPageSize(pp); + } + + return ( +
    + onChangePageSize(e.target.value)} + value={pageSize.toString()} + className="btn-secondary" + > + {pageSizeOptions.map((s) => ( + + ))} + + setCustomPageSizeShowing(false)} + > + +
    + + ) => { + if (e.key === "Enter") { + onChangePageSize( + (perPageInput.current as HTMLInputElement)?.value ?? "" + ); + e.preventDefault(); + } + }} + /> + + + + +
    +
    +
    +
    + ); +}; + +interface IListFilterProps { + onFilterUpdate: (newFilter: ListFilterModel) => void; + filter: ListFilterModel; + view?: View; + openFilterDialog: () => void; +} + +export const ListFilter: React.FC = ({ + onFilterUpdate, + filter, + openFilterDialog, + view, +}) => { + const filterOptions = filter.options; + + const intl = useIntl(); + + useEffect(() => { + Mousetrap.bind("r", () => onReshuffleRandomSort()); + + return () => { + Mousetrap.unbind("r"); + }; + }); + + function onChangePageSize(pp: number) { const newFilter = cloneDeep(filter); newFilter.itemsPerPage = pp; newFilter.currentPage = 1; @@ -211,25 +307,6 @@ export const ListFilter: React.FC = ({ (o) => o.value === filter.sortBy ); - const pageSizeOptions = PAGE_SIZE_OPTIONS.map((o) => { - return { - label: o, - value: o, - }; - }); - const currentPerPage = filter.itemsPerPage.toString(); - if (!pageSizeOptions.find((o) => o.value === currentPerPage)) { - pageSizeOptions.push({ label: currentPerPage, value: currentPerPage }); - pageSizeOptions.sort( - (a, b) => parseInt(a.value, 10) - parseInt(b.value, 10) - ); - } - - pageSizeOptions.push({ - label: `${intl.formatMessage({ id: "custom" })}...`, - value: "custom", - }); - return ( <>
    @@ -301,63 +378,10 @@ export const ListFilter: React.FC = ({ )} -
    - onChangePageSize(e.target.value)} - value={filter.itemsPerPage.toString()} - className="btn-secondary" - > - {pageSizeOptions.map((s) => ( - - ))} - - setCustomPageSizeShowing(false)} - > - -
    - - ) => { - if (e.key === "Enter") { - onChangePageSize( - (perPageInput.current as HTMLInputElement)?.value ?? - "" - ); - e.preventDefault(); - } - }} - /> - - - - -
    -
    -
    -
    + ); } diff --git a/ui/v2.5/src/components/List/ListOperationButtons.tsx b/ui/v2.5/src/components/List/ListOperationButtons.tsx index 4373d933847..92bcf9ebcd2 100644 --- a/ui/v2.5/src/components/List/ListOperationButtons.tsx +++ b/ui/v2.5/src/components/List/ListOperationButtons.tsx @@ -1,4 +1,4 @@ -import React, { useEffect } from "react"; +import React, { PropsWithChildren, useEffect } from "react"; import { Button, ButtonGroup, @@ -16,6 +16,23 @@ import { faTrash, } from "@fortawesome/free-solid-svg-icons"; +export const OperationDropdown: React.FC> = ({ + children, +}) => { + if (!children) return null; + + return ( + + + + + + {children} + + + ); +}; + export interface IListFilterOperation { text: string; onClick: () => void; @@ -154,6 +171,11 @@ export const ListOperationButtons: React.FC = ({ if (otherOperations) { otherOperations .filter((o) => { + // buttons with icons are rendered in the button group + if (o.icon) { + return false; + } + if (!o.isDisplayed) { return true; } @@ -173,18 +195,11 @@ export const ListOperationButtons: React.FC = ({ }); } - if (options.length > 0) { - return ( - - - - - - {options} - - - ); - } + return ( + + {options.length > 0 ? options : undefined} + + ); } return ( diff --git a/ui/v2.5/src/components/List/ListProvider.tsx b/ui/v2.5/src/components/List/ListProvider.tsx index a3a41a93d9f..6ef0d5055c0 100644 --- a/ui/v2.5/src/components/List/ListProvider.tsx +++ b/ui/v2.5/src/components/List/ListProvider.tsx @@ -66,6 +66,26 @@ export function useListContext() { return context as IListContextState; } +const emptyState: IListContextState = { + selectable: false, + selectedIds: new Set(), + getSelected: () => [], + onSelectChange: () => {}, + onSelectAll: () => {}, + onSelectNone: () => {}, + items: [], +}; + +export function useListContextOptional() { + const context = React.useContext(ListStateContext); + + if (context === null) { + return emptyState as IListContextState; + } + + return context as IListContextState; +} + interface IQueryResultContextOptions< T extends QueryResult, E extends IHasID = IHasID diff --git a/ui/v2.5/src/components/List/styles.scss b/ui/v2.5/src/components/List/styles.scss index edfb9d2a791..632ac9533fc 100644 --- a/ui/v2.5/src/components/List/styles.scss +++ b/ui/v2.5/src/components/List/styles.scss @@ -572,6 +572,10 @@ input[type="range"].zoom-slider { } } +.filtered-list-toolbar { + justify-content: center; +} + .search-term-input { margin-right: 0.5rem; } diff --git a/ui/v2.5/src/components/List/util.ts b/ui/v2.5/src/components/List/util.ts index 69d3528bd4b..3b85b666d86 100644 --- a/ui/v2.5/src/components/List/util.ts +++ b/ui/v2.5/src/components/List/util.ts @@ -7,6 +7,7 @@ import { QueryResult } from "@apollo/client"; import { IHasID } from "src/utils/data"; import { ConfigurationContext } from "src/hooks/Config"; import { View } from "./views"; +import { usePrevious } from "src/hooks/state"; export function useFilterURL( filter: ListFilterModel, @@ -180,6 +181,25 @@ export function useListSelect(items: T[]) { const [selectedIds, setSelectedIds] = useState>(new Set()); const [lastClickedId, setLastClickedId] = useState(); + const prevItems = usePrevious(items); + + useEffect(() => { + if (prevItems === items) { + return; + } + + // filter out any selectedIds that are no longer in the list + const newSelectedIds = new Set(); + + selectedIds.forEach((id) => { + if (items.some((item) => item.id === id)) { + newSelectedIds.add(id); + } + }); + + setSelectedIds(newSelectedIds); + }, [prevItems, items, selectedIds]); + function singleSelect(id: string, selected: boolean) { setLastClickedId(id); diff --git a/ui/v2.5/src/components/List/views.ts b/ui/v2.5/src/components/List/views.ts index 2b4179014e1..bb36a4c4ea6 100644 --- a/ui/v2.5/src/components/List/views.ts +++ b/ui/v2.5/src/components/List/views.ts @@ -31,4 +31,5 @@ export enum View { StudioChildren = "studio_children", GroupScenes = "group_scenes", + GroupSubGroups = "group_sub_groups", } diff --git a/ui/v2.5/src/components/Scenes/SceneCard.tsx b/ui/v2.5/src/components/Scenes/SceneCard.tsx index cbe3ee64e0c..b5d053c67e7 100644 --- a/ui/v2.5/src/components/Scenes/SceneCard.tsx +++ b/ui/v2.5/src/components/Scenes/SceneCard.tsx @@ -1,15 +1,10 @@ import React, { useEffect, useMemo, useRef, useState } from "react"; import { Button, ButtonGroup, OverlayTrigger, Tooltip } from "react-bootstrap"; -import { Link, useHistory } from "react-router-dom"; +import { useHistory } from "react-router-dom"; import cx from "classnames"; import * as GQL from "src/core/generated-graphql"; import { Icon } from "../Shared/Icon"; -import { - GalleryLink, - TagLink, - GroupLink, - SceneMarkerLink, -} from "../Shared/TagLink"; +import { GalleryLink, TagLink, SceneMarkerLink } from "../Shared/TagLink"; import { HoverPopover } from "../Shared/HoverPopover"; import { SweatDrops } from "../Shared/SweatDrops"; import { TruncatedText } from "../Shared/TruncatedText"; @@ -20,7 +15,7 @@ import { ConfigurationContext } from "src/hooks/Config"; import { PerformerPopoverButton } from "../Shared/PerformerPopoverButton"; import { GridCard, calculateCardWidth } from "../Shared/GridCard/GridCard"; import { RatingBanner } from "../Shared/RatingBanner"; -import { FormattedNumber } from "react-intl"; +import { FormattedMessage, FormattedNumber } from "react-intl"; import { faBox, faCopy, @@ -34,6 +29,7 @@ import { PreviewScrubber } from "./PreviewScrubber"; import { PatchComponent } from "src/patch"; import ScreenUtils from "src/utils/screen"; import { StudioOverlay } from "../Shared/GridCard/StudioOverlay"; +import { GroupTag } from "../Groups/GroupTag"; interface IScenePreviewProps { isPortrait: boolean; @@ -106,8 +102,26 @@ interface ISceneCardProps { selected?: boolean | undefined; zoomIndex?: number; onSelectedChanged?: (selected: boolean, shiftKey: boolean) => void; + fromGroupId?: string; } +const Description: React.FC<{ + sceneNumber?: number; +}> = ({ sceneNumber }) => { + if (!sceneNumber) return null; + + return ( + <> +
    + {sceneNumber !== undefined && ( + + #{sceneNumber} + + )} + + ); +}; + const SceneCardPopovers = PatchComponent( "SceneCard.Popovers", (props: ISceneCardProps) => { @@ -116,6 +130,17 @@ const SceneCardPopovers = PatchComponent( [props.scene] ); + const sceneNumber = useMemo(() => { + if (!props.fromGroupId) { + return undefined; + } + + const group = props.scene.groups.find( + (g) => g.group.id === props.fromGroupId + ); + return group?.scene_index ?? undefined; + }, [props.fromGroupId, props.scene.groups]); + function maybeRenderTagPopoverButton() { if (props.scene.tags.length <= 0) return; @@ -147,23 +172,7 @@ const SceneCardPopovers = PatchComponent( if (props.scene.groups.length <= 0) return; const popoverContent = props.scene.groups.map((sceneGroup) => ( -
    - - {sceneGroup.group.name - - -
    + )); return ( @@ -283,10 +292,12 @@ const SceneCardPopovers = PatchComponent( props.scene.scene_markers.length > 0 || props.scene?.o_counter || props.scene.galleries.length > 0 || - props.scene.organized) + props.scene.organized || + sceneNumber !== undefined) ) { return ( <> +
    {maybeRenderTagPopoverButton()} diff --git a/ui/v2.5/src/components/Scenes/SceneCardsGrid.tsx b/ui/v2.5/src/components/Scenes/SceneCardsGrid.tsx index b882b5ec543..9884e37a06f 100644 --- a/ui/v2.5/src/components/Scenes/SceneCardsGrid.tsx +++ b/ui/v2.5/src/components/Scenes/SceneCardsGrid.tsx @@ -10,6 +10,7 @@ interface ISceneCardsGrid { selectedIds: Set; zoomIndex: number; onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void; + fromGroupId?: string; } export const SceneCardsGrid: React.FC = ({ @@ -18,6 +19,7 @@ export const SceneCardsGrid: React.FC = ({ selectedIds, zoomIndex, onSelectChange, + fromGroupId, }) => { const [componentRef, { width }] = useContainerDimensions(); return ( @@ -35,6 +37,7 @@ export const SceneCardsGrid: React.FC = ({ onSelectedChanged={(selected: boolean, shiftKey: boolean) => onSelectChange(scene.id, selected, shiftKey) } + fromGroupId={fromGroupId} /> ))}
    diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneGroupPanel.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneGroupPanel.tsx index 6f58b504cf9..53a5e174d30 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneGroupPanel.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneGroupPanel.tsx @@ -13,7 +13,7 @@ export const SceneGroupPanel: React.FC = ( )); diff --git a/ui/v2.5/src/components/Scenes/SceneList.tsx b/ui/v2.5/src/components/Scenes/SceneList.tsx index 6fa7c5dbd18..e78e31d2cbd 100644 --- a/ui/v2.5/src/components/Scenes/SceneList.tsx +++ b/ui/v2.5/src/components/Scenes/SceneList.tsx @@ -75,6 +75,7 @@ interface ISceneList { defaultSort?: string; view?: View; alterQuery?: boolean; + fromGroupId?: string; } export const SceneList: React.FC = ({ @@ -82,6 +83,7 @@ export const SceneList: React.FC = ({ defaultSort, view, alterQuery, + fromGroupId, }) => { const intl = useIntl(); const history = useHistory(); @@ -297,6 +299,7 @@ export const SceneList: React.FC = ({ zoomIndex={filter.zoomIndex} selectedIds={selectedIds} onSelectChange={onSelectChange} + fromGroupId={fromGroupId} /> ); } diff --git a/ui/v2.5/src/components/Scenes/styles.scss b/ui/v2.5/src/components/Scenes/styles.scss index 57d68f94105..b9df2f7b5c6 100644 --- a/ui/v2.5/src/components/Scenes/styles.scss +++ b/ui/v2.5/src/components/Scenes/styles.scss @@ -208,6 +208,10 @@ textarea.scene-description { &-preview { aspect-ratio: 16/9; } + + .scene-group-scene-number { + text-align: center; + } } .scene-card, diff --git a/ui/v2.5/src/components/Shared/GridCard/GridCard.tsx b/ui/v2.5/src/components/Shared/GridCard/GridCard.tsx index 1d1a37528d9..33aa24e32cd 100644 --- a/ui/v2.5/src/components/Shared/GridCard/GridCard.tsx +++ b/ui/v2.5/src/components/Shared/GridCard/GridCard.tsx @@ -1,10 +1,18 @@ -import React, { MutableRefObject, useRef, useState } from "react"; +import React, { + MutableRefObject, + PropsWithChildren, + useRef, + useState, +} from "react"; import { Card, Form } from "react-bootstrap"; import { Link } from "react-router-dom"; import cx from "classnames"; import { TruncatedText } from "../TruncatedText"; import ScreenUtils from "src/utils/screen"; import useResizeObserver from "@react-hook/resize-observer"; +import { Icon } from "../Icon"; +import { faGripLines } from "@fortawesome/free-solid-svg-icons"; +import { DragSide, useDragMoveSelect } from "./dragMoveSelect"; interface ICardProps { className?: string; @@ -24,6 +32,10 @@ interface ICardProps { resumeTime?: number; duration?: number; interactiveHeatmap?: string; + + // move logic - both of the following are required to enable move dragging + objectId?: string; // required for move dragging + onMove?: (srcIds: string[], targetId: string, after: boolean) => void; } export const calculateCardWidth = ( @@ -66,60 +78,82 @@ export const useContainerDimensions = ( return [target, dimension]; }; -export const GridCard: React.FC = (props: ICardProps) => { - function handleImageClick(event: React.MouseEvent) { - const { shiftKey } = event; +const Checkbox: React.FC<{ + selected?: boolean; + onSelectedChanged?: (selected: boolean, shiftKey: boolean) => void; +}> = ({ selected = false, onSelectedChanged }) => { + let shiftKey = false; - if (!props.onSelectedChanged) { - return; - } + return ( + onSelectedChanged!(!selected, shiftKey)} + onClick={(event: React.MouseEvent) => { + shiftKey = event.shiftKey; + event.stopPropagation(); + }} + /> + ); +}; - if (props.selecting) { - props.onSelectedChanged(!props.selected, shiftKey); - event.preventDefault(); - } +const DragHandle: React.FC<{ + setInHandle: (inHandle: boolean) => void; +}> = ({ setInHandle }) => { + function onMouseEnter() { + setInHandle(true); } - function handleDrag(event: React.DragEvent) { - if (props.selecting) { - event.dataTransfer.setData("text/plain", ""); - event.dataTransfer.setDragImage(new Image(), 0, 0); - } + function onMouseLeave() { + setInHandle(false); } - function handleDragOver(event: React.DragEvent) { - const ev = event; - const shiftKey = false; - - if (!props.onSelectedChanged) { - return; - } + return ( + + + + ); +}; - if (props.selecting && !props.selected) { - props.onSelectedChanged(true, shiftKey); - } +const Controls: React.FC> = ({ children }) => { + return
    {children}
    ; +}; - ev.dataTransfer.dropEffect = "move"; - ev.preventDefault(); +const MoveTarget: React.FC<{ dragSide: DragSide }> = ({ dragSide }) => { + if (dragSide === undefined) { + return null; } - let shiftKey = false; + return ( +
    + ); +}; - function maybeRenderCheckbox() { - if (props.onSelectedChanged) { - return ( - props.onSelectedChanged!(!props.selected, shiftKey)} - onClick={(event: React.MouseEvent) => { - shiftKey = event.shiftKey; - event.stopPropagation(); - }} - /> - ); +export const GridCard: React.FC = (props: ICardProps) => { + const { setInHandle, moveTarget, dragProps } = useDragMoveSelect({ + selecting: props.selecting || false, + selected: props.selected || false, + onSelectedChanged: props.onSelectedChanged, + objectId: props.objectId, + onMove: props.onMove, + }); + + function handleImageClick(event: React.MouseEvent) { + const { shiftKey } = event; + + if (!props.onSelectedChanged) { + return; + } + + if (props.selecting) { + props.onSelectedChanged(!props.selected, shiftKey); + event.preventDefault(); } } @@ -156,16 +190,26 @@ export const GridCard: React.FC = (props: ICardProps) => { - {maybeRenderCheckbox()} + {moveTarget !== undefined && } + + {props.onSelectedChanged && ( + + )} + + {!!props.objectId && props.onMove && ( + + )} +
    void; + objectId?: string; + onMove?: (srcIds: string[], targetId: string, after: boolean) => void; +}) { + const { selectedIds } = useListContextOptional(); + + const [inHandle, setInHandle] = useState(false); + const [moveSrc, setMoveSrc] = useState(false); + const [moveTarget, setMoveTarget] = useState(); + + const canSelect = props.onSelectedChanged && props.selecting; + const canMove = !!props.objectId && props.onMove && inHandle; + const draggable = canSelect || canMove; + + function onDragStart(event: React.DragEvent) { + if (!draggable) { + event.preventDefault(); + return; + } + + if (!inHandle && props.selecting) { + event.dataTransfer.setData("text/plain", ""); + // event.dataTransfer.setDragImage(new Image(), 0, 0); + event.dataTransfer.effectAllowed = "copy"; + event.stopPropagation(); + } else if (inHandle && props.objectId) { + if (selectedIds.size > 1 && selectedIds.has(props.objectId)) { + // moving all selected + const movingIds = Array.from(selectedIds.values()).join(","); + event.dataTransfer.setData("text/plain", movingIds); + } else { + // moving single + setMoveSrc(true); + event.dataTransfer.setData("text/plain", props.objectId); + } + event.dataTransfer.effectAllowed = "move"; + event.stopPropagation(); + } + } + + function doSetMoveTarget(event: React.DragEvent) { + const isBefore = + event.nativeEvent.offsetX < event.currentTarget.clientWidth / 2; + if (isBefore && moveTarget !== DragSide.BEFORE) { + setMoveTarget(DragSide.BEFORE); + } else if (!isBefore && moveTarget !== DragSide.AFTER) { + setMoveTarget(DragSide.AFTER); + } + } + + function onDragEnter(event: React.DragEvent) { + const ev = event; + const shiftKey = false; + + if (ev.dataTransfer.effectAllowed === "copy") { + if (!props.onSelectedChanged) { + return; + } + + if (props.selecting && !props.selected) { + props.onSelectedChanged(true, shiftKey); + } + + ev.dataTransfer.dropEffect = "copy"; + ev.preventDefault(); + } else if (ev.dataTransfer.effectAllowed === "move" && !moveSrc) { + doSetMoveTarget(event); + ev.dataTransfer.dropEffect = "move"; + ev.preventDefault(); + } else { + ev.dataTransfer.dropEffect = "none"; + } + } + + function onDragLeave(event: React.DragEvent) { + if (event.currentTarget.contains(event.relatedTarget as Node)) { + return; + } + + setMoveTarget(undefined); + } + + function onDragOver(event: React.DragEvent) { + if (event.dataTransfer.effectAllowed === "move" && moveSrc) { + return; + } + + doSetMoveTarget(event); + + event.preventDefault(); + } + + function onDragEnd() { + setMoveTarget(undefined); + setMoveSrc(false); + } + + function onDrop(event: React.DragEvent) { + const ev = event; + + if ( + ev.dataTransfer.effectAllowed === "copy" || + !props.onMove || + !props.objectId + ) { + return; + } + + const srcIds = ev.dataTransfer.getData("text/plain").split(","); + const targetId = props.objectId; + const after = moveTarget === DragSide.AFTER; + + props.onMove(srcIds, targetId, after); + + onDragEnd(); + } + + return { + inHandle, + setInHandle, + moveTarget, + dragProps: { + draggable: draggable || undefined, + onDragStart, + onDragEnter, + onDragLeave, + onDragOver, + onDragEnd, + onDrop, + }, + }; +} diff --git a/ui/v2.5/src/components/Shared/GridCard/styles.scss b/ui/v2.5/src/components/Shared/GridCard/styles.scss index fcf699fe234..ece1f280aab 100644 --- a/ui/v2.5/src/components/Shared/GridCard/styles.scss +++ b/ui/v2.5/src/components/Shared/GridCard/styles.scss @@ -57,3 +57,28 @@ transition: opacity 0.5s; } } + +.move-target { + align-items: center; + background-color: $primary; + color: $secondary; + display: flex; + height: 100%; + justify-content: center; + opacity: 0.5; + pointer-events: none; + position: absolute; + width: 10%; + + &.move-target-before { + left: 0; + } + + &.move-target-after { + right: 0; + } +} + +.card-drag-handle { + filter: drop-shadow(1px 1px 1px rgba(0, 0, 0, 0.7)); +} diff --git a/ui/v2.5/src/components/Shared/Icon.tsx b/ui/v2.5/src/components/Shared/Icon.tsx index adbc3dcfdd3..32f4d0259f0 100644 --- a/ui/v2.5/src/components/Shared/Icon.tsx +++ b/ui/v2.5/src/components/Shared/Icon.tsx @@ -1,23 +1,16 @@ import React from "react"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { IconDefinition, SizeProp } from "@fortawesome/fontawesome-svg-core"; +import { + FontAwesomeIcon, + FontAwesomeIconProps, +} from "@fortawesome/react-fontawesome"; import { PatchComponent } from "src/patch"; -interface IIcon { - icon: IconDefinition; - className?: string; - color?: string; - size?: SizeProp; -} - -export const Icon: React.FC = PatchComponent( +export const Icon: React.FC = PatchComponent( "Icon", - ({ icon, className, color, size }) => ( + (props) => ( ) ); diff --git a/ui/v2.5/src/components/Shared/MultiSet.tsx b/ui/v2.5/src/components/Shared/MultiSet.tsx index 521a2577bef..4ed7a99ffe4 100644 --- a/ui/v2.5/src/components/Shared/MultiSet.tsx +++ b/ui/v2.5/src/components/Shared/MultiSet.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { useIntl } from "react-intl"; +import { IntlShape, useIntl } from "react-intl"; import * as GQL from "src/core/generated-graphql"; import { Button, ButtonGroup } from "react-bootstrap"; @@ -52,70 +52,96 @@ const Select: React.FC = (props) => { ); }; -export const MultiSet: React.FC = (props) => { - const intl = useIntl(); - const modes = [ - GQL.BulkUpdateIdMode.Set, - GQL.BulkUpdateIdMode.Add, - GQL.BulkUpdateIdMode.Remove, - ]; - - function getModeText(mode: GQL.BulkUpdateIdMode) { - switch (mode) { - case GQL.BulkUpdateIdMode.Set: - return intl.formatMessage({ - id: "actions.overwrite", - defaultMessage: "Overwrite", - }); - case GQL.BulkUpdateIdMode.Add: - return intl.formatMessage({ id: "actions.add", defaultMessage: "Add" }); - case GQL.BulkUpdateIdMode.Remove: - return intl.formatMessage({ - id: "actions.remove", - defaultMessage: "Remove", - }); - } +function getModeText(intl: IntlShape, mode: GQL.BulkUpdateIdMode) { + switch (mode) { + case GQL.BulkUpdateIdMode.Set: + return intl.formatMessage({ + id: "actions.overwrite", + defaultMessage: "Overwrite", + }); + case GQL.BulkUpdateIdMode.Add: + return intl.formatMessage({ id: "actions.add", defaultMessage: "Add" }); + case GQL.BulkUpdateIdMode.Remove: + return intl.formatMessage({ + id: "actions.remove", + defaultMessage: "Remove", + }); } +} + +export const MultiSetModeButton: React.FC<{ + mode: GQL.BulkUpdateIdMode; + active: boolean; + onClick: () => void; + disabled?: boolean; +}> = ({ mode, active, onClick, disabled }) => { + const intl = useIntl(); + + return ( + + ); +}; + +const modes = [ + GQL.BulkUpdateIdMode.Set, + GQL.BulkUpdateIdMode.Add, + GQL.BulkUpdateIdMode.Remove, +]; - function onSetMode(mode: GQL.BulkUpdateIdMode) { - if (mode === props.mode) { +export const MultiSetModeButtons: React.FC<{ + mode: GQL.BulkUpdateIdMode; + onSetMode: (mode: GQL.BulkUpdateIdMode) => void; + disabled?: boolean; +}> = ({ mode, onSetMode, disabled }) => { + return ( + + {modes.map((m) => ( + onSetMode(m)} + disabled={disabled} + /> + ))} + + ); +}; + +export const MultiSet: React.FC = (props) => { + const { mode, onUpdate, existingIds } = props; + + function onSetMode(m: GQL.BulkUpdateIdMode) { + if (m === mode) { return; } // if going to Set, set the existing ids - if (mode === GQL.BulkUpdateIdMode.Set && props.existingIds) { - props.onUpdate(props.existingIds); + if (m === GQL.BulkUpdateIdMode.Set && existingIds) { + onUpdate(existingIds); // if going from Set, wipe the ids } else if ( - mode !== GQL.BulkUpdateIdMode.Set && - props.mode === GQL.BulkUpdateIdMode.Set + m !== GQL.BulkUpdateIdMode.Set && + mode === GQL.BulkUpdateIdMode.Set ) { - props.onUpdate([]); + onUpdate([]); } - props.onSetMode(mode); - } - - function renderModeButton(mode: GQL.BulkUpdateIdMode) { - return ( - - ); + props.onSetMode(m); } return (
    - - {modes.map((m) => renderModeButton(m))} - +