diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d7e3ac4fb..f3561b7dd 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -13,7 +13,7 @@ concurrency: cancel-in-progress: true env: - COMPILER_IMAGE: stashapp/compiler:5 + COMPILER_IMAGE: stashapp/compiler:6 jobs: build: @@ -91,12 +91,12 @@ jobs: - name: Compile for all supported platforms run: | docker exec -t build /bin/bash -c "make cross-compile-windows" - docker exec -t build /bin/bash -c "make cross-compile-osx-intel" - docker exec -t build /bin/bash -c "make cross-compile-osx-applesilicon" + docker exec -t build /bin/bash -c "make cross-compile-macos-intel" + docker exec -t build /bin/bash -c "make cross-compile-macos-applesilicon" docker exec -t build /bin/bash -c "make cross-compile-linux" docker exec -t build /bin/bash -c "make cross-compile-linux-arm64v8" docker exec -t build /bin/bash -c "make cross-compile-linux-arm32v7" - docker exec -t build /bin/bash -c "make cross-compile-pi" + docker exec -t build /bin/bash -c "make cross-compile-linux-arm32v6" - name: Cleanup build container run: docker rm -f -v build @@ -121,8 +121,8 @@ jobs: if: ${{ github.event_name == 'pull_request' && github.base_ref != 'refs/heads/develop' && github.base_ref != 'refs/heads/master'}} uses: actions/upload-artifact@v2 with: - name: stash-osx - path: dist/stash-osx + name: stash-macos-intel + path: dist/stash-macos-intel - name: Upload Linux binary # only upload binaries for pull requests @@ -145,13 +145,13 @@ jobs: automatic_release_tag: latest_develop title: "${{ env.STASH_VERSION }}: Latest development build" files: | - dist/stash-osx - dist/stash-osx-applesilicon + dist/stash-macos-intel + dist/stash-macos-applesilicon dist/stash-win.exe dist/stash-linux dist/stash-linux-arm64v8 dist/stash-linux-arm32v7 - dist/stash-pi + dist/stash-linux-arm32v6 CHECKSUMS_SHA1 - name: Master release @@ -161,13 +161,13 @@ jobs: token: "${{ secrets.GITHUB_TOKEN }}" allow_override: true files: | - dist/stash-osx - dist/stash-osx-applesilicon + dist/stash-macos-intel + dist/stash-macos-applesilicon dist/stash-win.exe dist/stash-linux dist/stash-linux-arm64v8 dist/stash-linux-arm32v7 - dist/stash-pi + dist/stash-linux-arm32v6 CHECKSUMS_SHA1 gzip: false diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml index ab5c50010..592b6cf68 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:5 + COMPILER_IMAGE: stashapp/compiler:6 jobs: golangci: diff --git a/Makefile b/Makefile index 4e9dea55c..f369b490f 100644 --- a/Makefile +++ b/Makefile @@ -1,12 +1,12 @@ -IS_WIN = +IS_WIN_SHELL = ifeq (${SHELL}, sh.exe) - IS_WIN = true + IS_WIN_SHELL = true endif ifeq (${SHELL}, cmd) - IS_WIN = true + IS_WIN_SHELL = true endif -ifdef IS_WIN +ifdef IS_WIN_SHELL SEPARATOR := && SET := set else @@ -14,6 +14,11 @@ else SET := export endif +IS_WIN_OS = +ifeq ($(OS),Windows_NT) + IS_WIN_OS = true +endif + # set LDFLAGS environment variable to any extra ldflags required # set OUTPUT to generate a specific binary name @@ -46,9 +51,13 @@ ifndef OFFICIAL_BUILD endif build: pre-build +ifdef IS_WIN_OS +PLATFORM_SPECIFIC_LDFLAGS := -H windowsgui +endif +build: $(eval LDFLAGS := $(LDFLAGS) -X 'github.com/stashapp/stash/pkg/api.version=$(STASH_VERSION)' -X 'github.com/stashapp/stash/pkg/api.buildstamp=$(BUILD_DATE)' -X 'github.com/stashapp/stash/pkg/api.githash=$(GITHASH)') - $(eval LDFLAGS := $(LDFLAGS) -X 'github.com/stashapp/stash/pkg/api.officialBuild=$(OFFICIAL_BUILD)') - go build $(OUTPUT) -mod=vendor -v -tags "sqlite_omit_load_extension osusergo netgo" $(GO_BUILD_FLAGS) -ldflags "$(LDFLAGS) $(EXTRA_LDFLAGS)" + $(eval LDFLAGS := $(LDFLAGS) -X 'github.com/stashapp/stash/pkg/manager/config.officialBuild=$(OFFICIAL_BUILD)') + go build $(OUTPUT) -mod=vendor -v -tags "sqlite_omit_load_extension osusergo netgo" $(GO_BUILD_FLAGS) -ldflags "$(LDFLAGS) $(EXTRA_LDFLAGS) $(PLATFORM_SPECIFIC_LDFLAGS)" # strips debug symbols from the release build build-release: EXTRA_LDFLAGS := -s -w @@ -65,23 +74,38 @@ cross-compile-windows: export GOARCH := amd64 cross-compile-windows: export CC := x86_64-w64-mingw32-gcc cross-compile-windows: export CXX := x86_64-w64-mingw32-g++ cross-compile-windows: OUTPUT := -o dist/stash-win.exe +cross-compile-windows: PLATFORM_SPECIFIC_LDFLAGS := -H windowsgui cross-compile-windows: build-release-static -cross-compile-osx-intel: export GOOS := darwin -cross-compile-osx-intel: export GOARCH := amd64 -cross-compile-osx-intel: export CC := o64-clang -cross-compile-osx-intel: export CXX := o64-clang++ -cross-compile-osx-intel: OUTPUT := -o dist/stash-osx +cross-compile-macos-intel: export GOOS := darwin +cross-compile-macos-intel: export GOARCH := amd64 +cross-compile-macos-intel: export CC := o64-clang +cross-compile-macos-intel: export CXX := o64-clang++ +cross-compile-macos-intel: OUTPUT := -o dist/stash-macos-intel # can't use static build for OSX -cross-compile-osx-intel: build-release +cross-compile-macos-intel: build-release -cross-compile-osx-applesilicon: export GOOS := darwin -cross-compile-osx-applesilicon: export GOARCH := arm64 -cross-compile-osx-applesilicon: export CC := oa64e-clang -cross-compile-osx-applesilicon: export CXX := oa64e-clang++ -cross-compile-osx-applesilicon: OUTPUT := -o dist/stash-osx-applesilicon +cross-compile-macos-applesilicon: export GOOS := darwin +cross-compile-macos-applesilicon: export GOARCH := arm64 +cross-compile-macos-applesilicon: export CC := oa64e-clang +cross-compile-macos-applesilicon: export CXX := oa64e-clang++ +cross-compile-macos-applesilicon: OUTPUT := -o dist/stash-macos-applesilicon # can't use static build for OSX -cross-compile-osx-applesilicon: build-release +cross-compile-macos-applesilicon: build-release + +cross-compile-macos: + rm -rf dist/Stash.app dist/Stash-macos.zip + make cross-compile-macos-applesilicon + make cross-compile-macos-intel + # Combine into one universal binary + lipo -create -output dist/stash-macos-universal dist/stash-macos-intel dist/stash-macos-applesilicon + rm dist/stash-macos-intel dist/stash-macos-applesilicon + # Place into bundle and zip up + cp -R scripts/macos-bundle dist/Stash.app + mkdir dist/Stash.app/Contents/MacOS + mv dist/stash-macos-universal dist/Stash.app/Contents/MacOS/stash + cd dist && zip -r Stash-macos.zip Stash.app && cd .. + rm -rf dist/Stash.app cross-compile-linux: export GOOS := linux cross-compile-linux: export GOARCH := amd64 @@ -101,21 +125,20 @@ cross-compile-linux-arm32v7: export CC := arm-linux-gnueabihf-gcc cross-compile-linux-arm32v7: OUTPUT := -o dist/stash-linux-arm32v7 cross-compile-linux-arm32v7: build-release-static -cross-compile-pi: export GOOS := linux -cross-compile-pi: export GOARCH := arm -cross-compile-pi: export GOARM := 6 -cross-compile-pi: export CC := arm-linux-gnueabi-gcc -cross-compile-pi: OUTPUT := -o dist/stash-pi -cross-compile-pi: build-release-static +cross-compile-linux-arm32v6: export GOOS := linux +cross-compile-linux-arm32v6: export GOARCH := arm +cross-compile-linux-arm32v6: export GOARM := 6 +cross-compile-linux-arm32v6: export CC := arm-linux-gnueabi-gcc +cross-compile-linux-arm32v6: OUTPUT := -o dist/stash-linux-arm32v6 +cross-compile-linux-arm32v6: build-release-static cross-compile-all: make cross-compile-windows - make cross-compile-osx-intel - make cross-compile-osx-applesilicon + make cross-compile-macos make cross-compile-linux make cross-compile-linux-arm64v8 make cross-compile-linux-arm32v7 - make cross-compile-pi + make cross-compile-linux-arm32v6 # Regenerates GraphQL files generate: generate-backend generate-frontend diff --git a/README.md b/README.md index 385cccecc..998498ff4 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ https://stashapp.cc [![Build](https://github.com/stashapp/stash/actions/workflows/build.yml/badge.svg?branch=develop&event=push)](https://github.com/stashapp/stash/actions/workflows/build.yml) -[![Docker pulls](https://img.shields.io/docker/pulls/stashapp/stash.svg)](https://hub.docker.com/r/stashapp/Stash 'DockerHub') +[![Docker pulls](https://img.shields.io/docker/pulls/stashapp/stash.svg)](https://hub.docker.com/r/stashapp/stash 'DockerHub') [![Go Report Card](https://goreportcard.com/badge/github.com/stashapp/stash)](https://goreportcard.com/report/github.com/stashapp/stash) [![Discord](https://img.shields.io/discord/559159668438728723.svg?logo=discord)](https://discord.gg/2TsNFKt) @@ -22,30 +22,32 @@ For further information you can [read the in-app manual](ui/v2.5/src/docs/en). Windows | MacOS| Linux | Docker :---:|:---:|:---:|:---: -[Latest Release](https://github.com/stashapp/stash/releases/latest/download/stash-win.exe)
[Development Preview](https://github.com/stashapp/stash/releases/download/latest_develop/stash-win.exe) | [Latest Release (Apple Silicon)](https://github.com/stashapp/stash/releases/latest/download/stash-osx-applesilicon)
[Development Preview (Apple Silicon)](https://github.com/stashapp/stash/releases/download/latest_develop/stash-osx-applesilicon)
[Latest Release (Intel)](https://github.com/stashapp/stash/releases/latest/download/stash-osx)
[Development Preview (Intel)](https://github.com/stashapp/stash/releases/download/latest_develop/stash-osx) | [Latest Release (amd64)](https://github.com/stashapp/stash/releases/latest/download/stash-linux)
[Development Preview (amd64)](https://github.com/stashapp/stash/releases/download/latest_develop/stash-linux)
[More Architectures...](https://github.com/stashapp/stash/releases/latest) | [Instructions](docker/production/README.md)
[Sample docker-compose.yml](docker/production/docker-compose.yml) - -## Getting Started -Run the executable (double click the exe on windows or run `./stash-osx` / `./stash-linux` from the terminal on macOS / Linux) to get started. - -*Note for Windows users:* Running the app might present a security prompt since the binary isn't yet signed. Bypass this by clicking "more info" and then the "run anyway" button. +[Latest Release](https://github.com/stashapp/stash/releases/latest/download/stash-win.exe)
[Development Preview](https://github.com/stashapp/stash/releases/download/latest_develop/stash-win.exe) | [Latest Release (Apple Silicon)](https://github.com/stashapp/stash/releases/latest/download/stash-macos-applesilicon)
[Development Preview (Apple Silicon)](https://github.com/stashapp/stash/releases/download/latest_develop/stash-macos-applesilicon)
[Latest Release (Intel)](https://github.com/stashapp/stash/releases/latest/download/stash-macos-intel)
[Development Preview (Intel)](https://github.com/stashapp/stash/releases/download/latest_develop/stash-macos-intel) | [Latest Release (amd64)](https://github.com/stashapp/stash/releases/latest/download/stash-linux)
[Development Preview (amd64)](https://github.com/stashapp/stash/releases/download/latest_develop/stash-linux)
[More Architectures...](https://github.com/stashapp/stash/releases/latest) | [Instructions](docker/production/README.md)
[Sample docker-compose.yml](docker/production/docker-compose.yml) +## First Run +#### Windows Users: Security Prompt +Running the app might present a security prompt since the binary isn't yet signed. Bypass this by clicking "more info" and then the "run anyway" button. #### FFMPEG Stash requires ffmpeg. If you don't have it installed, Stash will download a copy for you. It is recommended that Linux users install `ffmpeg` from their distro's package manager. # Usage ## Quickstart Guide -Download and run Stash. It will prompt you for some configuration options and a directory to index (you can also do this step afterward) +Stash is a web-based application. Once the application is running, the interface is available (by default) from http://localhost:9999. -**If you'd like to automatically retrieve and organize information about your entire library,** You will need to download some [scrapers](https://github.com/stashapp/stash/tree/develop/ui/v2.5/src/docs/en/Scraping.md). The Stash community has developed scrapers for many popular data sources which can be downloaded and installed from [this repository](https://github.com/stashapp/CommunityScrapers). +On first run, Stash will prompt you for some configuration options and media directories to index, called "Scanning" in Stash. After scanning, your media will be available for browsing, curating, editing, and tagging. -The simplest way to tag a large number of files is by using the [Tagger](https://github.com/stashapp/stash/blob/develop/ui/v2.5/src/docs/en/Tagger.md) which uses filename keywords to help identify the file and pull in scene and performer information from our stash-box database. Note that this data source is not comprehensive and you may need to use the scrapers to identify some of your media. +Stash can pull metadata (performers, tags, descriptions, studios, and more) directly from many sites through the use of [scrapers](https://github.com/stashapp/stash/tree/develop/ui/v2.5/src/docs/en/Scraping.md), which integrate directly into Stash. + +Many community-maintained scrapers are available for download at the [Community Scrapers Collection](https://github.com/stashapp/CommunityScrapers). The community also maintains StashDB, a crowd-sourced repository of scene, studio, and performer information, that can automatically identify much of a typical media collection. Inquire in the Discord for details. Identifying an entire collection will typically require a mix of multiple sources. + +StashDB is the canonical instance of our open source metadata API, [stash-box](https://github.com/stashapp/stash-box). # Translation [![Translate](https://translate.stashapp.cc/widgets/stash/-/stash-desktop-client/svg-badge.svg)](https://translate.stashapp.cc/engage/stash/) -🇧🇷 🇨🇳 🇬🇧 🇫🇮 🇫🇷 🇩🇪 🇮🇹 🇪🇸 🇸🇪 🇹🇼 +🇧🇷 🇨🇳 🇳🇱 🇬🇧 🇫🇮 🇫🇷 🇩🇪 🇮🇹 🇯🇵 🇪🇸 🇸🇪 🇹🇼 🇹🇷 -Stash is available in 10 languages (so far!) and it could be in your language too. If you want to help us translate Stash into your language, you can make an account at [translate.stashapp.cc](https://translate.stashapp.cc/projects/stash/stash-desktop-client/) to get started contributing new languages or improving existing ones. Thanks! +Stash is available in 13 languages (so far!) and it could be in your language too. If you want to help us translate Stash into your language, you can make an account at [translate.stashapp.cc](https://translate.stashapp.cc/projects/stash/stash-desktop-client/) to get started contributing new languages or improving existing ones. Thanks! # Support (FAQ) diff --git a/docker/ci/x86_64/Dockerfile b/docker/ci/x86_64/Dockerfile index 4a209d96c..f58ada900 100644 --- a/docker/ci/x86_64/Dockerfile +++ b/docker/ci/x86_64/Dockerfile @@ -2,7 +2,7 @@ FROM --platform=$BUILDPLATFORM alpine:latest AS binary ARG TARGETPLATFORM WORKDIR / COPY stash-* / -RUN if [ "$TARGETPLATFORM" = "linux/arm/v6" ]; then BIN=stash-pi; \ +RUN if [ "$TARGETPLATFORM" = "linux/arm/v6" ]; then BIN=stash-linux-arm32v6; \ elif [ "$TARGETPLATFORM" = "linux/arm/v7" ]; then BIN=stash-linux-arm32v7; \ elif [ "$TARGETPLATFORM" = "linux/arm64" ]; then BIN=stash-linux-arm64v8; \ elif [ "$TARGETPLATFORM" = "linux/amd64" ]; then BIN=stash-linux; \ diff --git a/docker/compiler/Dockerfile b/docker/compiler/Dockerfile index c7f5e789c..d6d54f0cc 100644 --- a/docker/compiler/Dockerfile +++ b/docker/compiler/Dockerfile @@ -20,7 +20,7 @@ RUN apt-get update && \ gcc-arm-linux-gnueabi libc-dev-armel-cross linux-libc-dev-armel-cross \ gcc-arm-linux-gnueabihf libc-dev-armhf-cross \ gcc-aarch64-linux-gnu libc-dev-arm64-cross \ - nodejs yarn --no-install-recommends || exit 1; \ + nodejs yarn zip --no-install-recommends || exit 1; \ rm -rf /var/lib/apt/lists/*; # Cross compile setup diff --git a/docker/compiler/Makefile b/docker/compiler/Makefile index 5c4ed71ba..978059b94 100644 --- a/docker/compiler/Makefile +++ b/docker/compiler/Makefile @@ -1,6 +1,6 @@ user=stashapp repo=compiler -version=5 +version=6 latest: docker build -t ${user}/${repo}:latest . diff --git a/docker/compiler/README.md b/docker/compiler/README.md index d25b4a6cd..6172d5bd9 100644 --- a/docker/compiler/README.md +++ b/docker/compiler/README.md @@ -1,5 +1,3 @@ Modified from https://github.com/bep/dockerfiles/tree/master/ci-goreleaser -When the dockerfile is changed, the version number should be incremented in the Makefile and the new version tag should be pushed to docker hub. The `scripts/cross-compile.sh` script should also be updated to use the new version number tag, and `.travis.yml` needs to be updated to pull the correct image tag. - -A MacOS univeral binary can be created using `lipo -create -output stash-osx-universal stash-osx stash-osx-applesilicon`, available in the image. \ No newline at end of file +When the dockerfile is changed, the version number should be incremented in the Makefile and the new version tag should be pushed to docker hub. The `scripts/cross-compile.sh` script should also be updated to use the new version number tag, and the github workflow files need to be updated to pull the correct image tag. diff --git a/go.mod b/go.mod index 2a690b116..9fcddc5bf 100644 --- a/go.mod +++ b/go.mod @@ -38,8 +38,8 @@ require ( golang.org/x/crypto v0.0.0-20210817164053-32db794688a5 golang.org/x/image v0.0.0-20210220032944-ac19c3e999fb golang.org/x/net v0.0.0-20211123203042-d83791d6bcd9 - golang.org/x/sys v0.0.0-20210823070655-63515b42dcdf - golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b // indirect + golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e + golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b golang.org/x/text v0.3.7 golang.org/x/tools v0.1.5 // indirect gopkg.in/sourcemap.v1 v1.0.5 // indirect @@ -47,6 +47,11 @@ require ( ) require ( + github.com/apenwarr/fixconsole v0.0.0-20191012055117-5a9f6489cc29 + github.com/go-chi/httplog v0.2.1 + github.com/go-toast/toast v0.0.0-20190211030409-01e6764cf0a4 + github.com/kermieisinthehouse/gosx-notifier v0.1.1 + github.com/kermieisinthehouse/systray v1.2.4 github.com/lucasb-eyer/go-colorful v1.2.0 github.com/vearutop/statigz v1.1.6 github.com/vektah/gqlparser/v2 v2.0.1 @@ -55,10 +60,12 @@ require ( require ( github.com/agnivade/levenshtein v1.1.0 // indirect github.com/antchfx/xpath v1.2.0 // indirect + github.com/apenwarr/w32 v0.0.0-20190407065021-aa00fece76ab // indirect github.com/chromedp/sysutil v1.0.0 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/fsnotify/fsnotify v1.5.1 // indirect + github.com/go-chi/chi/v5 v5.0.0 // indirect github.com/gobwas/httphead v0.1.0 // indirect github.com/gobwas/pool v0.2.1 // indirect github.com/gobwas/ws v1.1.0-rc.5 // indirect @@ -77,10 +84,11 @@ require ( github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.1 // indirect github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect + github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d // indirect github.com/pelletier/go-toml v1.9.4 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/rs/zerolog v1.18.0 // indirect + github.com/rs/zerolog v1.18.1-0.20200514152719-663cbb4c8469 // indirect github.com/russross/blackfriday/v2 v2.0.1 // indirect github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect github.com/spf13/cast v1.4.1 // indirect diff --git a/go.sum b/go.sum index ec2784346..940295660 100644 --- a/go.sum +++ b/go.sum @@ -88,6 +88,10 @@ github.com/antchfx/xpath v1.2.0/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwq github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= github.com/apache/arrow/go/arrow v0.0.0-20200601151325-b2287a20f230/go.mod h1:QNYViu/X0HXDHw7m3KXzWSVXIbfUvJqBFe6Gj8/pYA0= github.com/apache/arrow/go/arrow v0.0.0-20210521153258-78c88a9f517b/go.mod h1:R4hW3Ug0s+n4CUsWHKOj00Pu01ZqU4x/hSF5kXUcXKQ= +github.com/apenwarr/fixconsole v0.0.0-20191012055117-5a9f6489cc29 h1:muXWUcay7DDy1/hEQWrYlBy+g0EuwT70sBHg65SeUc4= +github.com/apenwarr/fixconsole v0.0.0-20191012055117-5a9f6489cc29/go.mod h1:JYWahgHer+Z2xbsgHPtaDYVWzeHDminu+YIBWkxpCAY= +github.com/apenwarr/w32 v0.0.0-20190407065021-aa00fece76ab h1:CMGzRRCjnD50RjUFSArBLuCxiDvdp7b8YPAcikBEQ+k= +github.com/apenwarr/w32 v0.0.0-20190407065021-aa00fece76ab/go.mod h1:nfFtvHn2Hgs9G1u0/J6LHQv//EksNC+7G8vXmd1VTJ8= github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q= github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE= github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= @@ -214,6 +218,10 @@ github.com/glycerine/goconvey v0.0.0-20180728074245-46e3a41ad493/go.mod h1:Ogl1T github.com/go-chi/chi v3.3.2+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ= github.com/go-chi/chi v4.0.2+incompatible h1:maB6vn6FqCxrpz4FqWdh4+lwpyZIQS7YEAUcHlgXVRs= github.com/go-chi/chi v4.0.2+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ= +github.com/go-chi/chi/v5 v5.0.0 h1:DBPx88FjZJH3FsICfDAfIfnb7XxKIYVGG6lOPlhENAg= +github.com/go-chi/chi/v5 v5.0.0/go.mod h1:BBug9lr0cqtdAhsu6R4AAdvufI0/XBzAQSsUqJpoZOs= +github.com/go-chi/httplog v0.2.1 h1:KgCtIUkYNlfIsUPzE3utxd1KDKOvCrnAKaqdo0rmrh0= +github.com/go-chi/httplog v0.2.1/go.mod h1:JyHOFO9twSfGoTin/RoP25Lx2a9Btq10ug+sgxe0+bo= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= @@ -224,6 +232,8 @@ github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG github.com/go-sql-driver/mysql v1.5.0 h1:ozyZYNQW3x3HtqT1jira07DN2PArx2v7/mN66gGcHOs= github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/go-toast/toast v0.0.0-20190211030409-01e6764cf0a4 h1:qZNfIGkIANxGv/OqtnntR4DfOY2+BgwR60cAcu/i3SE= +github.com/go-toast/toast v0.0.0-20190211030409-01e6764cf0a4/go.mod h1:kW3HQ4UdaAyrUCSSDR4xUzBKW6O2iA4uHhk7AtyYp10= github.com/gobuffalo/attrs v0.0.0-20190224210810-a9411de4debd/go.mod h1:4duuawTqi2wkkpB4ePgWMaai6/Kc6WEz83bhFwpHzj0= github.com/gobuffalo/depgen v0.0.0-20190329151759-d478694a28d3/go.mod h1:3STtPUQYuzV0gBVOY3vy6CfMm/ljR4pABfrTeHNLHUY= github.com/gobuffalo/depgen v0.1.0/go.mod h1:+ifsuy7fhi15RWncXQQKjWS9JPkdah5sZvtHc2RXGlg= @@ -484,6 +494,12 @@ github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0/go.mod h1:1NbS8ALr github.com/karrick/godirwalk v1.8.0/go.mod h1:H5KPZjojv4lE+QYImBI8xVtrBRgYrIVsaRPx4tDPEn4= github.com/karrick/godirwalk v1.10.3/go.mod h1:RoGL9dQei4vP9ilrpETWE8CLOZ1kiN0LhBygSwrAsHA= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= +github.com/kermieisinthehouse/gosx-notifier v0.1.1 h1:lVXyKsa1c1RUkckp3KayloNLoI//fUwVYye3RPSPtEw= +github.com/kermieisinthehouse/gosx-notifier v0.1.1/go.mod h1:xyWT07azFtUOcHl96qMVvKhvKzsMcS7rKTHQyv8WTho= +github.com/kermieisinthehouse/systray v1.2.3 h1:tawLahcam/Ccs/F2n6EOQo8qJnSTD2hLzOYqTGsUsbA= +github.com/kermieisinthehouse/systray v1.2.3/go.mod h1:axh6C/jNuSyC0QGtidZJURc9h+h41HNoMySoLVrhVR4= +github.com/kermieisinthehouse/systray v1.2.4 h1:pdH5vnl+KKjRrVCRU4g/2W1/0HVzuuJ6WXHlPPHYY6s= +github.com/kermieisinthehouse/systray v1.2.4/go.mod h1:axh6C/jNuSyC0QGtidZJURc9h+h41HNoMySoLVrhVR4= github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= @@ -576,6 +592,8 @@ github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6 github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d h1:VhgPp6v9qf9Agr/56bj7Y/xa04UccTW04VP0Qed4vnQ= +github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d/go.mod h1:YUTz3bUH2ZwIWBy3CJBeOBEugqcmXREj14T+iG/4k4U= github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.12.0/go.mod h1:oUhWkIvk5aDxtKvDDuw8gItl8pKl42LzjC9KZE0HfGg= @@ -633,8 +651,9 @@ github.com/rs/cors v1.6.0/go.mod h1:gFx+x8UowdsKA9AchylcLynDq+nNFfI8FkUZdN/jGCU= github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ= github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU= github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc= -github.com/rs/zerolog v1.18.0 h1:CbAm3kP2Tptby1i9sYy2MGRg0uxIN9cyDb59Ys7W8z8= github.com/rs/zerolog v1.18.0/go.mod h1:9nvC1axdVrAHcu/s9taAVfBuIdTZLVQmKQyvrUjF5+I= +github.com/rs/zerolog v1.18.1-0.20200514152719-663cbb4c8469 h1:DuXsEWHUTO5lsxxzKM4KUKGDIOi7nawNDs6d+AiulEA= +github.com/rs/zerolog v1.18.1-0.20200514152719-663cbb4c8469/go.mod h1:IzD0RJ65iWH0w97OQQebJEvTZYvsCUm9WVLWBQrJRjo= github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q= github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= @@ -661,6 +680,7 @@ github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6Mwd github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE= github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= +github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog= github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c/go.mod h1:XDJAKZRPZ1CvBcN2aX5YOUTYGHki24fSF0Iv48Ibg0s= github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= @@ -917,6 +937,7 @@ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5h golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190405154228-4b34438f7a67/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190415145633-3fd5a3612ccd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190419153524-e8e3143a4f4a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -984,8 +1005,9 @@ golang.org/x/sys v0.0.0-20210616045830-e2b7044e8c71/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210823070655-63515b42dcdf h1:2ucpDCmfkl8Bd/FsLtiD653Wf96cW37s+iGx93zsu4k= golang.org/x/sys v0.0.0-20210823070655-63515b42dcdf/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e h1:fLOSk5Q00efkSvAm+4xcoXD+RRmLmmulPn5I3Y9F2EM= +golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b h1:9zKuko04nR4gjZ4+DNjHqRlAJqbJETHwiNKDqTfOjfE= golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= diff --git a/graphql/documents/data/config.graphql b/graphql/documents/data/config.graphql index 1e5040f09..2421bdb2f 100644 --- a/graphql/documents/data/config.graphql +++ b/graphql/documents/data/config.graphql @@ -25,7 +25,6 @@ fragment ConfigGeneralData on ConfigGeneralResult { username password maxSessionAge - trustedProxies logFile logOut logLevel @@ -52,8 +51,10 @@ fragment ConfigInterfaceData on ConfigInterfaceResult { soundOnPreview wallShowTitle wallPlayback + showScrubber maximumLoopDuration noBrowser + notificationsEnabled autostartVideo autostartVideoOnPlaySelected continuePlaylistDefault diff --git a/graphql/documents/mutations/movie.graphql b/graphql/documents/mutations/movie.graphql index e1236b9dd..375b3d239 100644 --- a/graphql/documents/mutations/movie.graphql +++ b/graphql/documents/mutations/movie.graphql @@ -22,6 +22,12 @@ mutation MovieUpdate($input: MovieUpdateInput!) { } } +mutation BulkMovieUpdate($input: BulkMovieUpdateInput!) { + bulkMovieUpdate(input: $input) { + ...MovieData + } +} + mutation MovieDestroy($id: ID!) { movieDestroy(input: { id: $id }) } diff --git a/graphql/documents/mutations/stash-box.graphql b/graphql/documents/mutations/stash-box.graphql index c20cdd25f..55c508737 100644 --- a/graphql/documents/mutations/stash-box.graphql +++ b/graphql/documents/mutations/stash-box.graphql @@ -5,3 +5,11 @@ mutation SubmitStashBoxFingerprints($input: StashBoxFingerprintSubmissionInput!) mutation StashBoxBatchPerformerTag($input: StashBoxBatchPerformerTagInput!) { stashBoxBatchPerformerTag(input: $input) } + +mutation SubmitStashBoxSceneDraft($input: StashBoxDraftSubmissionInput!) { + submitStashBoxSceneDraft(input: $input) +} + +mutation SubmitStashBoxPerformerDraft($input: StashBoxDraftSubmissionInput!) { + submitStashBoxPerformerDraft(input: $input) +} diff --git a/graphql/documents/queries/settings/config.graphql b/graphql/documents/queries/settings/config.graphql index 4ee9d4ec6..0a4b076d2 100644 --- a/graphql/documents/queries/settings/config.graphql +++ b/graphql/documents/queries/settings/config.graphql @@ -11,3 +11,10 @@ query Directory($path: String) { directories } } + +query ValidateStashBox($input: StashBoxInput!) { + validateStashBoxCredentials(input: $input) { + valid + status + } +} diff --git a/graphql/schema/schema.graphql b/graphql/schema/schema.graphql index 3f6419fed..9b5bf6ed7 100644 --- a/graphql/schema/schema.graphql +++ b/graphql/schema/schema.graphql @@ -136,6 +136,7 @@ type Query { "Desired collation locale. Determines the order of the directory result. eg. 'en-US', 'pt-BR', ..." locale: String = "en" ): Directory! + validateStashBoxCredentials(input: StashBoxInput!): StashBoxValidationResult! # System status systemStatus: SystemStatus! @@ -223,6 +224,7 @@ type Mutation { movieUpdate(input: MovieUpdateInput!): Movie movieDestroy(input: MovieDestroyInput!): Boolean! moviesDestroy(ids: [ID!]!): Boolean! + bulkMovieUpdate(input: BulkMovieUpdateInput!): [Movie!] tagCreate(input: TagCreateInput!): Tag tagUpdate(input: TagUpdateInput!): Tag @@ -281,6 +283,11 @@ type Mutation { """Submit fingerprints to stash-box instance""" submitStashBoxFingerprints(input: StashBoxFingerprintSubmissionInput!): Boolean! + """Submit scene as draft to stash-box instance""" + submitStashBoxSceneDraft(input: StashBoxDraftSubmissionInput!): ID + """Submit performer as draft to stash-box instance""" + submitStashBoxPerformerDraft(input: StashBoxDraftSubmissionInput!): ID + """Backup the database. Optionally returns a link to download the database file""" backupDatabase(input: BackupDatabaseInput!): String diff --git a/graphql/schema/types/config.graphql b/graphql/schema/types/config.graphql index 0bab8fd93..2b6fdbd5d 100644 --- a/graphql/schema/types/config.graphql +++ b/graphql/schema/types/config.graphql @@ -76,7 +76,7 @@ input ConfigGeneralInput { """Maximum session cookie age""" maxSessionAge: Int """Comma separated list of proxies to allow traffic from""" - trustedProxies: [String!] + trustedProxies: [String!] @deprecated(reason: "no longer supported") """Name of the log file""" logFile: String """Whether to also output to stderr""" @@ -157,7 +157,7 @@ type ConfigGeneralResult { """Maximum session cookie age""" maxSessionAge: Int! """Comma separated list of proxies to allow traffic from""" - trustedProxies: [String!]! + trustedProxies: [String!] @deprecated(reason: "no longer supported") """Name of the log file""" logFile: String """Whether to also output to stderr""" @@ -207,6 +207,9 @@ input ConfigInterfaceInput { wallShowTitle: Boolean """Wall playback type""" wallPlayback: String + + """Show scene scrubber by default""" + showScrubber: Boolean """Maximum duration (in seconds) in which a scene video will loop in the scene player""" maximumLoopDuration: Int @@ -239,6 +242,8 @@ input ConfigInterfaceInput { funscriptOffset: Int """True if we should not auto-open a browser window on startup""" noBrowser: Boolean + """True if we should send notifications to the desktop""" + notificationsEnabled: Boolean } type ConfigDisableDropdownCreate { @@ -259,10 +264,15 @@ type ConfigInterfaceResult { """Wall playback type""" wallPlayback: String + """Show scene scrubber by default""" + showScrubber: Boolean + """Maximum duration (in seconds) in which a scene video will loop in the scene player""" maximumLoopDuration: Int - """"True if we should not auto-open a browser window on startup""" + """True if we should not auto-open a browser window on startup""" noBrowser: Boolean + """True if we should send desktop notifications""" + notificationsEnabled: Boolean """If true, video will autostart on load in the scene player""" autostartVideo: Boolean """If true, video will autostart when loading from play random or play selected""" @@ -391,3 +401,8 @@ type StashConfig { input GenerateAPIKeyInput { clear: Boolean } + +type StashBoxValidationResult { + valid: Boolean! + status: String! +} diff --git a/graphql/schema/types/filters.graphql b/graphql/schema/types/filters.graphql index 5e21a6acf..bf3517fae 100644 --- a/graphql/schema/types/filters.graphql +++ b/graphql/schema/types/filters.graphql @@ -33,6 +33,12 @@ input ResolutionCriterionInput { modifier: CriterionModifier! } +input PHashDuplicationCriterionInput { + duplicated: Boolean + """Currently unimplemented""" + distance: Int +} + input PerformerFilterType { AND: PerformerFilterType OR: PerformerFilterType @@ -130,6 +136,8 @@ input SceneFilterType { organized: Boolean """Filter by o-counter""" o_counter: IntCriterionInput + """Filter Scenes that have an exact phash match available""" + duplicated: PHashDuplicationCriterionInput """Filter by resolution""" resolution: ResolutionCriterionInput """Filter by duration (in seconds)""" @@ -148,6 +156,10 @@ input SceneFilterType { tag_count: IntCriterionInput """Filter to only include scenes with performers with these tags""" performer_tags: HierarchicalMultiCriterionInput + """Filter scenes that have performers that have been favorited""" + performer_favorite: Boolean + """Filter scenes by performer age at time of scene""" + performer_age: IntCriterionInput """Filter to only include scenes with these performers""" performers: MultiCriterionInput """Filter by performer count""" @@ -243,6 +255,10 @@ input GalleryFilterType { performers: MultiCriterionInput """Filter by performer count""" performer_count: IntCriterionInput + """Filter galleries that have performers that have been favorited""" + performer_favorite: Boolean + """Filter galleries by performer age at time of gallery""" + performer_age: IntCriterionInput """Filter by number of images in this gallery""" image_count: IntCriterionInput """Filter by url""" @@ -324,6 +340,8 @@ input ImageFilterType { performers: MultiCriterionInput """Filter by performer count""" performer_count: IntCriterionInput + """Filter images that have performers that have been favorited""" + performer_favorite: Boolean """Filter to only include images with these galleries""" galleries: MultiCriterionInput } diff --git a/graphql/schema/types/movie.graphql b/graphql/schema/types/movie.graphql index 104c68176..3d100e141 100644 --- a/graphql/schema/types/movie.graphql +++ b/graphql/schema/types/movie.graphql @@ -54,6 +54,14 @@ input MovieUpdateInput { back_image: String } +input BulkMovieUpdateInput { + clientMutationId: String + ids: [ID!] + rating: Int + studio_id: ID + director: String +} + input MovieDestroyInput { id: ID! } diff --git a/graphql/schema/types/stash-box.graphql b/graphql/schema/types/stash-box.graphql index 471db19b1..7614a2fae 100644 --- a/graphql/schema/types/stash-box.graphql +++ b/graphql/schema/types/stash-box.graphql @@ -24,3 +24,8 @@ input StashBoxFingerprintSubmissionInput { scene_ids: [String!]! stash_box_index: Int! } + +input StashBoxDraftSubmissionInput { + id: String! + stash_box_index: Int! +} diff --git a/graphql/stash-box/query.graphql b/graphql/stash-box/query.graphql index 9bc24f70a..39bce5d3c 100644 --- a/graphql/stash-box/query.graphql +++ b/graphql/stash-box/query.graphql @@ -156,3 +156,21 @@ query FindSceneByID($id: ID!) { mutation SubmitFingerprint($input: FingerprintSubmission!) { submitFingerprint(input: $input) } + +query Me { + me { + name + } +} + +mutation SubmitSceneDraft($input: SceneDraftInput!) { + submitSceneDraft(input: $input) { + id + } +} + +mutation SubmitPerformerDraft($input: PerformerDraftInput!) { + submitPerformerDraft(input: $input) { + id + } +} diff --git a/main.go b/main.go index 6d59d8085..fa9e4d790 100644 --- a/main.go +++ b/main.go @@ -3,13 +3,14 @@ package main import ( "embed" + "fmt" "os" "os/signal" "runtime/pprof" "syscall" + "github.com/apenwarr/fixconsole" "github.com/stashapp/stash/pkg/api" - "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/manager" _ "github.com/golang-migrate/migrate/v4/database/sqlite3" @@ -22,18 +23,24 @@ var uiBox embed.FS //go:embed ui/login var loginUIBox embed.FS +func init() { + // On Windows, attach to parent shell + err := fixconsole.FixConsoleIfNeeded() + if err != nil { + fmt.Printf("FixConsoleOutput: %v\n", err) + } +} + func main() { manager.Initialize() api.Start(uiBox, loginUIBox) - // stop any profiling at exit - defer pprof.StopCPUProfile() blockForever() - err := manager.GetInstance().Shutdown() - if err != nil { - logger.Errorf("Error when closing: %s", err) - } + // stop any profiling at exit + pprof.StopCPUProfile() + + manager.GetInstance().Shutdown(0) } func blockForever() { diff --git a/pkg/api/authentication.go b/pkg/api/authentication.go index 17246badd..e5358affe 100644 --- a/pkg/api/authentication.go +++ b/pkg/api/authentication.go @@ -57,15 +57,10 @@ func authenticateHandler() func(http.Handler) http.Handler { if err := session.CheckAllowPublicWithoutAuth(c, r); err != nil { var externalAccess session.ExternalAccessError - var untrustedProxy session.UntrustedProxyError switch { case errors.As(err, &externalAccess): securityActivateTripwireAccessedFromInternetWithoutAuth(c, externalAccess, w) return - case errors.As(err, &untrustedProxy): - logger.Warnf("Rejected request from untrusted proxy: %v", net.IP(untrustedProxy)) - w.WriteHeader(http.StatusForbidden) - return default: logger.Errorf("Error checking external access security: %v", err) w.WriteHeader(http.StatusInternalServerError) @@ -135,9 +130,4 @@ func securityActivateTripwireAccessedFromInternetWithoutAuth(c *config.Instance, if err != nil { logger.Error(err) } - - err = manager.GetInstance().Shutdown() - if err != nil { - logger.Error(err) - } } diff --git a/pkg/api/check_version.go b/pkg/api/check_version.go index bd023da4a..c3014eab4 100644 --- a/pkg/api/check_version.go +++ b/pkg/api/check_version.go @@ -33,7 +33,7 @@ var stashReleases = func() map[string]string { "darwin/arm64": "stash-osx-applesilicon", "linux/amd64": "stash-linux", "windows/amd64": "stash-win.exe", - "linux/arm": "stash-pi", + "linux/arm": "stash-linux-arm32v6", "linux/arm64": "stash-linux-arm64v8", "linux/armv7": "stash-linux-arm32v7", } diff --git a/pkg/api/favicon.go b/pkg/api/favicon.go new file mode 100644 index 000000000..1a760bd3b --- /dev/null +++ b/pkg/api/favicon.go @@ -0,0 +1,28 @@ +package api + +import ( + "embed" + "runtime" +) + +const faviconDir = "ui/v2.5/build/" + +type FaviconProvider struct { + uiBox embed.FS +} + +func (p *FaviconProvider) GetFavicon() []byte { + if runtime.GOOS == "windows" { + faviconPath := faviconDir + "favicon.ico" + ret, _ := p.uiBox.ReadFile(faviconPath) + return ret + } + + return p.GetFaviconPng() +} + +func (p *FaviconProvider) GetFaviconPng() []byte { + faviconPath := faviconDir + "favicon.png" + ret, _ := p.uiBox.ReadFile(faviconPath) + return ret +} diff --git a/pkg/api/locale.go b/pkg/api/locale.go index f5f389ee7..23381b112 100644 --- a/pkg/api/locale.go +++ b/pkg/api/locale.go @@ -19,6 +19,10 @@ var matcher = language.NewMatcher([]language.Tag{ language.MustParse("sv-SE"), language.MustParse("zh-CN"), language.MustParse("zh-TW"), + language.MustParse("hr-HR"), + language.MustParse("nl-NL"), + language.MustParse("ru-RU"), + language.MustParse("tr-TR"), }) // newCollator parses a locale into a collator diff --git a/pkg/api/resolver_mutation_configure.go b/pkg/api/resolver_mutation_configure.go index 48f074769..6e78220c8 100644 --- a/pkg/api/resolver_mutation_configure.go +++ b/pkg/api/resolver_mutation_configure.go @@ -197,10 +197,6 @@ func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input models.Co c.Set(config.MaxSessionAge, *input.MaxSessionAge) } - if input.TrustedProxies != nil { - c.Set(config.TrustedProxies, input.TrustedProxies) - } - if input.LogFile != nil { c.Set(config.LogFile, input.LogFile) } @@ -298,6 +294,10 @@ func (r *mutationResolver) ConfigureInterface(ctx context.Context, input models. setBool(config.NoBrowser, input.NoBrowser) + setBool(config.NotificationsEnabled, input.NotificationsEnabled) + + setBool(config.ShowScrubber, input.ShowScrubber) + if input.WallPlayback != nil { c.Set(config.WallPlayback, *input.WallPlayback) } diff --git a/pkg/api/resolver_mutation_movie.go b/pkg/api/resolver_mutation_movie.go index 88769f6d3..59413f148 100644 --- a/pkg/api/resolver_mutation_movie.go +++ b/pkg/api/resolver_mutation_movie.go @@ -3,6 +3,7 @@ package api import ( "context" "database/sql" + "fmt" "strconv" "time" @@ -220,6 +221,71 @@ func (r *mutationResolver) MovieUpdate(ctx context.Context, input models.MovieUp return r.getMovie(ctx, movie.ID) } +func (r *mutationResolver) BulkMovieUpdate(ctx context.Context, input models.BulkMovieUpdateInput) ([]*models.Movie, error) { + movieIDs, err := utils.StringSliceToIntSlice(input.Ids) + if err != nil { + return nil, err + } + + updatedTime := time.Now() + + translator := changesetTranslator{ + inputMap: getUpdateInputMap(ctx), + } + + updatedMovie := models.MoviePartial{ + UpdatedAt: &models.SQLiteTimestamp{Timestamp: updatedTime}, + } + + updatedMovie.Rating = translator.nullInt64(input.Rating, "rating") + updatedMovie.StudioID = translator.nullInt64FromString(input.StudioID, "studio_id") + updatedMovie.Director = translator.nullString(input.Director, "director") + + ret := []*models.Movie{} + + if err := r.withTxn(ctx, func(repo models.Repository) error { + qb := repo.Movie() + + for _, movieID := range movieIDs { + updatedMovie.ID = movieID + + existing, err := qb.Find(movieID) + if err != nil { + return err + } + + if existing == nil { + return fmt.Errorf("movie with id %d not found", movieID) + } + + movie, err := qb.Update(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 { + r.hookExecutor.ExecutePostHooks(ctx, movie.ID, plugin.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) MovieDestroy(ctx context.Context, input models.MovieDestroyInput) (bool, error) { id, err := strconv.Atoi(input.ID) if err != nil { diff --git a/pkg/api/resolver_mutation_stash_box.go b/pkg/api/resolver_mutation_stash_box.go index 9c489e8de..1c9d6d34b 100644 --- a/pkg/api/resolver_mutation_stash_box.go +++ b/pkg/api/resolver_mutation_stash_box.go @@ -27,3 +27,62 @@ func (r *mutationResolver) StashBoxBatchPerformerTag(ctx context.Context, input jobID := manager.GetInstance().StashBoxBatchPerformerTag(ctx, input) return strconv.Itoa(jobID), nil } + +func (r *mutationResolver) SubmitStashBoxSceneDraft(ctx context.Context, input models.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) + } + + client := stashbox.NewClient(*boxes[input.StashBoxIndex], r.txnManager) + + id, err := strconv.Atoi(input.ID) + if err != nil { + return nil, err + } + + var res *string + err = r.withReadTxn(ctx, func(repo models.ReaderRepository) error { + qb := repo.Scene() + scene, err := qb.Find(id) + if err != nil { + return err + } + filepath := manager.GetInstance().Paths.Scene.GetScreenshotPath(scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm())) + + res, err = client.SubmitSceneDraft(ctx, id, boxes[input.StashBoxIndex].Endpoint, filepath) + return err + }) + + return res, err +} + +func (r *mutationResolver) SubmitStashBoxPerformerDraft(ctx context.Context, input models.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) + } + + client := stashbox.NewClient(*boxes[input.StashBoxIndex], r.txnManager) + + id, err := strconv.Atoi(input.ID) + if err != nil { + return nil, err + } + + var res *string + err = r.withReadTxn(ctx, func(repo models.ReaderRepository) error { + qb := repo.Performer() + performer, err := qb.Find(id) + if err != nil { + return err + } + + res, err = client.SubmitPerformerDraft(ctx, performer, boxes[input.StashBoxIndex].Endpoint) + return err + }) + + return res, err +} diff --git a/pkg/api/resolver_query_configuration.go b/pkg/api/resolver_query_configuration.go index b54019b4c..1383e9b62 100644 --- a/pkg/api/resolver_query_configuration.go +++ b/pkg/api/resolver_query_configuration.go @@ -2,9 +2,12 @@ package api import ( "context" + "fmt" + "strings" "github.com/stashapp/stash/pkg/manager/config" "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/scraper/stashbox" "github.com/stashapp/stash/pkg/utils" "golang.org/x/text/collate" ) @@ -83,7 +86,6 @@ func makeConfigGeneralResult() *models.ConfigGeneralResult { Username: config.GetUsername(), Password: config.GetPasswordHash(), MaxSessionAge: config.GetMaxSessionAge(), - TrustedProxies: config.GetTrustedProxies(), LogFile: &logFile, LogOut: config.GetLogOut(), LogLevel: config.GetLogLevel(), @@ -107,8 +109,10 @@ func makeConfigInterfaceResult() *models.ConfigInterfaceResult { menuItems := config.GetMenuItems() soundOnPreview := config.GetSoundOnPreview() wallShowTitle := config.GetWallShowTitle() + showScrubber := config.GetShowScrubber() wallPlayback := config.GetWallPlayback() noBrowser := config.GetNoBrowser() + notificationsEnabled := config.GetNotificationsEnabled() maximumLoopDuration := config.GetMaximumLoopDuration() autostartVideo := config.GetAutostartVideo() autostartVideoOnPlaySelected := config.GetAutostartVideoOnPlaySelected() @@ -129,8 +133,10 @@ func makeConfigInterfaceResult() *models.ConfigInterfaceResult { SoundOnPreview: &soundOnPreview, WallShowTitle: &wallShowTitle, WallPlayback: &wallPlayback, + ShowScrubber: &showScrubber, MaximumLoopDuration: &maximumLoopDuration, NoBrowser: &noBrowser, + NotificationsEnabled: ¬ificationsEnabled, AutostartVideo: &autostartVideo, ShowStudioAsText: &showStudioAsText, AutostartVideoOnPlaySelected: &autostartVideoOnPlaySelected, @@ -188,3 +194,38 @@ func makeConfigDefaultsResult() *models.ConfigDefaultSettingsResult { DeleteGenerated: &deleteGeneratedDefault, } } + +func (r *queryResolver) ValidateStashBoxCredentials(ctx context.Context, input models.StashBoxInput) (*models.StashBoxValidationResult, error) { + client := stashbox.NewClient(models.StashBox{Endpoint: input.Endpoint, APIKey: input.APIKey}, r.txnManager) + user, err := client.GetUser(ctx) + + valid := user != nil && user.Me != nil + var status string + if valid { + status = fmt.Sprintf("Successfully authenticated as %s", user.Me.Name) + } else { + switch { + case strings.Contains(strings.ToLower(err.Error()), "doctype"): + // Index file returned rather than graphql + status = "Invalid endpoint" + case strings.Contains(err.Error(), "request failed"): + status = "No response from server" + case strings.HasPrefix(err.Error(), "invalid character") || + strings.HasPrefix(err.Error(), "illegal base64 data") || + err.Error() == "unexpected end of JSON input" || + err.Error() == "token contains an invalid number of segments": + status = "Malformed API key." + case err.Error() == "" || err.Error() == "signature is invalid": + status = "Invalid or expired API key." + default: + status = fmt.Sprintf("Unknown error: %s", err) + } + } + + result := models.StashBoxValidationResult{ + Valid: valid, + Status: status, + } + + return &result, nil +} diff --git a/pkg/api/server.go b/pkg/api/server.go index fb07861c7..cf5f4041a 100644 --- a/pkg/api/server.go +++ b/pkg/api/server.go @@ -23,8 +23,10 @@ import ( "github.com/go-chi/chi" "github.com/go-chi/chi/middleware" "github.com/gorilla/websocket" - "github.com/pkg/browser" + + "github.com/go-chi/httplog" "github.com/rs/cors" + "github.com/stashapp/stash/pkg/desktop" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/manager" "github.com/stashapp/stash/pkg/manager/config" @@ -36,7 +38,6 @@ import ( var version string var buildstamp string var githash string -var officialBuild string func Start(uiBox embed.FS, loginUIBox embed.FS) { initialiseImages() @@ -52,7 +53,10 @@ func Start(uiBox embed.FS, loginUIBox embed.FS) { c := config.GetInstance() if c.GetLogAccess() { - r.Use(middleware.Logger) + httpLogger := httplog.NewLogger("Stash", httplog.Options{ + Concise: true, + }) + r.Use(httplog.RequestLogger(httpLogger)) } r.Use(SecurityHeadersMiddleware) r.Use(middleware.DefaultCompress) @@ -184,6 +188,7 @@ func Start(uiBox embed.FS, loginUIBox embed.FS) { } customUILocation := c.GetCustomUILocation() + static := statigz.FileServer(uiBox) // Serve the web app r.HandleFunc("/*", func(w http.ResponseWriter, r *http.Request) { @@ -222,7 +227,7 @@ func Start(uiBox embed.FS, loginUIBox embed.FS) { } r.URL.Path = uiRootDir + r.URL.Path - statigz.FileServer(uiBox).ServeHTTP(w, r) + static.ServeHTTP(w, r) } }) @@ -245,25 +250,16 @@ func Start(uiBox embed.FS, loginUIBox embed.FS) { TLSConfig: tlsConfig, } + printVersion() + go printLatestVersion(context.TODO()) + logger.Infof("stash is listening on " + address) + if tlsConfig != nil { + displayAddress = "https://" + displayAddress + "/" + } else { + displayAddress = "http://" + displayAddress + "/" + } + go func() { - printVersion() - printLatestVersion(context.TODO()) - logger.Infof("stash is listening on " + address) - if tlsConfig != nil { - displayAddress = "https://" + displayAddress + "/" - } else { - displayAddress = "http://" + displayAddress + "/" - } - - // This can be done before actually starting the server, as modern browsers will - // automatically reload the page if a local port is closed at page load and then opened. - if !c.GetNoBrowser() && manager.GetInstance().IsDesktop() { - err = browser.OpenURL(displayAddress) - if err != nil { - logger.Error("Could not open browser: " + err.Error()) - } - } - if tlsConfig != nil { logger.Infof("stash is running at " + displayAddress) logger.Error(server.ListenAndServeTLS("", "")) @@ -271,12 +267,14 @@ func Start(uiBox embed.FS, loginUIBox embed.FS) { logger.Infof("stash is running at " + displayAddress) logger.Error(server.ListenAndServe()) } + manager.GetInstance().Shutdown(0) }() + desktop.Start(manager.GetInstance(), &FaviconProvider{uiBox: uiBox}) } func printVersion() { versionString := githash - if IsOfficialBuild() { + if config.IsOfficialBuild() { versionString += " - Official Build" } else { versionString += " - Unofficial Build" @@ -287,10 +285,6 @@ func printVersion() { fmt.Printf("stash version: %s - %s\n", versionString, buildstamp) } -func IsOfficialBuild() bool { - return officialBuild == "true" -} - func GetVersion() (string, string, string) { return version, githash, buildstamp } @@ -364,7 +358,6 @@ func SecurityHeadersMiddleware(next http.Handler) http.Handler { w.Header().Set("Referrer-Policy", "same-origin") w.Header().Set("X-Content-Type-Options", "nosniff") - w.Header().Set("X-Frame-Options", "DENY") w.Header().Set("X-XSS-Protection", "1") w.Header().Set("Content-Security-Policy", cspDirectives) @@ -385,13 +378,7 @@ func BaseURLMiddleware(next http.Handler) http.Handler { } prefix := getProxyPrefix(r.Header) - port := "" - forwardedPort := r.Header.Get("X-Forwarded-Port") - if forwardedPort != "" && forwardedPort != "80" && forwardedPort != "8080" && forwardedPort != "443" && !strings.Contains(r.Host, ":") { - port = ":" + forwardedPort - } - - baseURL := scheme + "://" + r.Host + port + prefix + baseURL := scheme + "://" + r.Host + prefix externalHost := config.GetInstance().GetExternalHost() if externalHost != "" { diff --git a/pkg/desktop/desktop.go b/pkg/desktop/desktop.go new file mode 100644 index 000000000..cad8ade3c --- /dev/null +++ b/pkg/desktop/desktop.go @@ -0,0 +1,170 @@ +package desktop + +import ( + "io/ioutil" + "os" + "os/exec" + "path" + "path/filepath" + "runtime" + "strconv" + "strings" + + "github.com/pkg/browser" + "github.com/stashapp/stash/pkg/logger" + "github.com/stashapp/stash/pkg/manager/config" + "github.com/stashapp/stash/pkg/utils" + "golang.org/x/term" +) + +type ShutdownHandler interface { + Shutdown(code int) +} + +type FaviconProvider interface { + GetFavicon() []byte + GetFaviconPng() []byte +} + +func Start(shutdownHandler ShutdownHandler, faviconProvider FaviconProvider) { + if IsDesktop() { + c := config.GetInstance() + if !c.GetNoBrowser() { + openURLInBrowser("") + } + writeStashIcon(faviconProvider) + startSystray(shutdownHandler, faviconProvider) + } +} + +// openURLInBrowser opens a browser to the Stash UI. Path can be an empty string for main page. +func openURLInBrowser(path string) { + // This can be done before actually starting the server, as modern browsers will + // automatically reload the page if a local port is closed at page load and then opened. + serverAddress := getServerURL(path) + + err := browser.OpenURL(serverAddress) + if err != nil { + logger.Error("Could not open browser: " + err.Error()) + } +} + +func SendNotification(title string, text string) { + if IsDesktop() { + c := config.GetInstance() + if c.GetNotificationsEnabled() { + sendNotification(title, text) + } + } +} + +func IsDesktop() bool { + // Check if running under root + if os.Getuid() == 0 { + return false + } + // Check if stdin is a terminal + if term.IsTerminal(int(os.Stdin.Fd())) { + return false + } + if isService() { + return false + } + if IsServerDockerized() { + return false + } + + return true +} + +func IsServerDockerized() bool { + return isServerDockerized() +} + +// Set a command to execute in the background, instead of spawning a shell window +func HideExecShell(cmd *exec.Cmd) { + hideExecShell(cmd) +} + +// writeStashIcon writes the current stash logo to config/icon.png +func writeStashIcon(faviconProvider FaviconProvider) { + c := config.GetInstance() + if !c.IsNewSystem() { + iconPath := path.Join(c.GetConfigPath(), "icon.png") + err := ioutil.WriteFile(iconPath, faviconProvider.GetFaviconPng(), 0644) + if err != nil { + logger.Errorf("Couldn't write icon file: %s", err.Error()) + } + } +} + +// IsAllowedAutoUpdate tries to determine if the stash binary was installed from a +// package manager or if touching the executable is otherwise a bad idea +func IsAllowedAutoUpdate() bool { + + // Only try to update if downloaded from official sources + if !config.IsOfficialBuild() { + return false + } + + // Avoid updating if installed from package manager + if runtime.GOOS == "linux" { + executablePath, err := os.Executable() + if err != nil { + logger.Errorf("Cannot get executable path: %s", err) + return false + } + executablePath, err = filepath.EvalSymlinks(executablePath) + if err != nil { + logger.Errorf("Cannot get executable path: %s", err) + return false + } + if utils.IsPathInDir("/usr", executablePath) || utils.IsPathInDir("/opt", executablePath) { + return false + } + + if isServerDockerized() { + return false + } + } + + return true +} + +func getIconPath() string { + return path.Join(config.GetInstance().GetConfigPath(), "icon.png") +} + +func RevealInFileManager(path string) { + exists, err := utils.FileExists(path) + if err != nil { + logger.Errorf("Error checking file: %s", err) + return + } + if exists && IsDesktop() { + revealInFileManager(path) + } +} + +func getServerURL(path string) string { + c := config.GetInstance() + serverAddress := c.GetHost() + if serverAddress == "0.0.0.0" { + serverAddress = "localhost" + } + serverAddress = serverAddress + ":" + strconv.Itoa(c.GetPort()) + + proto := "" + if c.HasTLSConfig() { + proto = "https://" + } else { + proto = "http://" + } + serverAddress = proto + serverAddress + "/" + + if path != "" { + serverAddress += strings.TrimPrefix(path, "/") + } + + return serverAddress +} diff --git a/pkg/desktop/desktop_platform_darwin.go b/pkg/desktop/desktop_platform_darwin.go new file mode 100644 index 000000000..53c9776f2 --- /dev/null +++ b/pkg/desktop/desktop_platform_darwin.go @@ -0,0 +1,40 @@ +//go:build darwin +// +build darwin + +package desktop + +import ( + "os/exec" + + "github.com/kermieisinthehouse/gosx-notifier" + "github.com/stashapp/stash/pkg/logger" +) + +func isService() bool { + // MacOS /does/ support services, using launchd, but there is no straightforward way to check if it was used. + return false +} + +func isServerDockerized() bool { + return false +} + +func hideExecShell(cmd *exec.Cmd) { + +} + +func sendNotification(notificationTitle string, notificationText string) { + notification := gosxnotifier.NewNotification(notificationText) + notification.Title = notificationTitle + notification.AppIcon = getIconPath() + notification.Link = getServerURL("") + err := notification.Push() + + if err != nil { + logger.Errorf("Could not send MacOS notification: %s", err.Error()) + } +} + +func revealInFileManager(path string) { + exec.Command(`open`, `-R`, path) +} diff --git a/pkg/desktop/desktop_platform_linux.go b/pkg/desktop/desktop_platform_linux.go new file mode 100644 index 000000000..b1893c0e7 --- /dev/null +++ b/pkg/desktop/desktop_platform_linux.go @@ -0,0 +1,43 @@ +//go:build linux +// +build linux + +package desktop + +import ( + "io/ioutil" + "os" + "os/exec" + "strings" + + "github.com/stashapp/stash/pkg/logger" +) + +// isService checks if started by init, e.g. stash is a *nix systemd service +func isService() bool { + return os.Getppid() == 1 +} + +func isServerDockerized() bool { + _, dockerEnvErr := os.Stat("/.dockerenv") + cgroups, _ := ioutil.ReadFile("/proc/self/cgroup") + if !os.IsNotExist(dockerEnvErr) || strings.Contains(string(cgroups), "docker") { + return true + } + + return false +} + +func hideExecShell(cmd *exec.Cmd) { + +} + +func sendNotification(notificationTitle string, notificationText string) { + err := exec.Command("notify-send", "-i", getIconPath(), notificationTitle, notificationText, "-a", "Stash").Run() + if err != nil { + logger.Errorf("Error sending notification on Linux: %s", err.Error()) + } +} + +func revealInFileManager(path string) { + +} diff --git a/pkg/desktop/desktop_platform_windows.go b/pkg/desktop/desktop_platform_windows.go new file mode 100644 index 000000000..7a887d508 --- /dev/null +++ b/pkg/desktop/desktop_platform_windows.go @@ -0,0 +1,56 @@ +//go:build windows +// +build windows + +package desktop + +import ( + "os/exec" + "syscall" + + "golang.org/x/sys/windows" + + "github.com/go-toast/toast" + "github.com/stashapp/stash/pkg/logger" + "golang.org/x/sys/windows/svc" +) + +func isService() bool { + result, err := svc.IsWindowsService() + if err != nil { + logger.Errorf("Encountered error checking if running as Windows service: %s", err.Error()) + return false + } + return result +} + +func isServerDockerized() bool { + return false +} + +// On Windows, calling exec.Cmd.Start() will create a cmd window, even if we live in the taskbar. +// We don't want every ffmpeg / plugin to pop up a window. +func hideExecShell(cmd *exec.Cmd) { + cmd.SysProcAttr = &syscall.SysProcAttr{CreationFlags: windows.DETACHED_PROCESS} +} + +func sendNotification(notificationTitle string, notificationText string) { + notification := toast.Notification{ + AppID: "Stash", + Title: notificationTitle, + Message: notificationText, + Icon: getIconPath(), + Actions: []toast.Action{{ + Type: "protocol", + Label: "Open Stash", + Arguments: getServerURL(""), + }}, + } + err := notification.Push() + if err != nil { + logger.Errorf("Error creating Windows notification: %s", err.Error()) + } +} + +func revealInFileManager(path string) { + exec.Command(`explorer`, `\select`, path) +} diff --git a/pkg/desktop/icon_windows.syso b/pkg/desktop/icon_windows.syso new file mode 100644 index 000000000..ac347dda4 Binary files /dev/null and b/pkg/desktop/icon_windows.syso differ diff --git a/pkg/desktop/systray_linux.go b/pkg/desktop/systray_linux.go new file mode 100644 index 000000000..193126173 --- /dev/null +++ b/pkg/desktop/systray_linux.go @@ -0,0 +1,10 @@ +//go:build linux +// +build linux + +package desktop + +func startSystray(shutdownHandler ShutdownHandler, favicon FaviconProvider) { + // The systray is not available on linux because the required libraries (libappindicator3 and gtk+3.0) + // are not able to be statically compiled. Technically, the systray works perfectly fine when dynamically + // linked, but we cannot distribute it for compatibility reasons. +} diff --git a/pkg/desktop/systray_nonlinux.go b/pkg/desktop/systray_nonlinux.go new file mode 100644 index 000000000..b9f72cbac --- /dev/null +++ b/pkg/desktop/systray_nonlinux.go @@ -0,0 +1,95 @@ +//go:build windows || darwin || !linux +// +build windows darwin !linux + +package desktop + +import ( + "strings" + + "github.com/kermieisinthehouse/systray" + "github.com/stashapp/stash/pkg/logger" + "github.com/stashapp/stash/pkg/manager/config" +) + +// MUST be run on the main goroutine or will have no effect on macOS +func startSystray(shutdownHandler ShutdownHandler, faviconProvider FaviconProvider) { + + // Shows a small notification to inform that Stash will no longer show a terminal window, + // and instead will be available in the tray. Will only show the first time a pre-desktop integration + // system is started from a non-terminal method, e.g. double-clicking an icon. + c := config.GetInstance() + if c.GetShowOneTimeMovedNotification() { + SendNotification("Stash has moved!", "Stash now runs in your tray, instead of a terminal window.") + c.Set(config.ShowOneTimeMovedNotification, false) + if err := c.Write(); err != nil { + logger.Errorf("Error while writing configuration file: %s", err.Error()) + } + } + + // Listen for changes to rerender systray + // TODO: This is disabled for now. The systray package does not clean up all of its resources when Quit() is called. + // TODO: This results in this only working once, or changes being ignored. Our fork of systray fixes a crash(!) on macOS here. + // go func() { + // for { + // <-config.GetInstance().GetConfigUpdatesChannel() + // systray.Quit() + // } + // }() + + for { + systray.Run(func() { + systrayInitialize(shutdownHandler, faviconProvider) + }, nil) + } +} + +func systrayInitialize(shutdownHandler ShutdownHandler, faviconProvider FaviconProvider) { + favicon := faviconProvider.GetFavicon() + systray.SetTemplateIcon(favicon, favicon) + systray.SetTooltip("🟢 Stash is Running.") + + openStashButton := systray.AddMenuItem("Open Stash", "Open a browser window to Stash") + var menuItems []string + systray.AddSeparator() + c := config.GetInstance() + if !c.IsNewSystem() { + menuItems = c.GetMenuItems() + for _, item := range menuItems { + titleCaseItem := strings.Title(strings.ToLower(item)) + curr := systray.AddMenuItem(titleCaseItem, "Open to "+titleCaseItem) + go func(item string) { + for { + <-curr.ClickedCh + if item == "markers" { + item = "scenes/markers" + } + if c.GetNoBrowser() { + openURLInBrowser(item) + } + } + }(item) + } + systray.AddSeparator() + // TODO - Some ideas for future expansions + // systray.AddMenuItem("Start a Scan", "Scan all libraries with default settings") + // systray.AddMenuItem("Start Auto Tagging", "Auto Tag all libraries") + // systray.AddMenuItem("Check for updates", "Check for a new Stash release") + // systray.AddSeparator() + } + + quitStashButton := systray.AddMenuItem("Quit Stash Server", "Quits the Stash server") + + go func() { + for { + select { + case <-openStashButton.ClickedCh: + if !c.GetNoBrowser() { + openURLInBrowser("") + } + case <-quitStashButton.ClickedCh: + systray.Quit() + shutdownHandler.Shutdown(0) + } + } + }() +} diff --git a/pkg/ffmpeg/downloader.go b/pkg/ffmpeg/downloader.go index a5f655ace..bea7105c0 100644 --- a/pkg/ffmpeg/downloader.go +++ b/pkg/ffmpeg/downloader.go @@ -13,6 +13,7 @@ import ( "runtime" "strings" + "github.com/stashapp/stash/pkg/desktop" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/utils" ) @@ -202,7 +203,9 @@ func pathBinaryHasCorrectFlags() bool { if err != nil { return false } - bytes, _ := exec.Command(ffmpegPath).CombinedOutput() + cmd := exec.Command(ffmpegPath) + desktop.HideExecShell(cmd) + bytes, _ := cmd.CombinedOutput() output := string(bytes) hasOpus := strings.Contains(output, "--enable-libopus") hasVpx := strings.Contains(output, "--enable-libvpx") diff --git a/pkg/ffmpeg/encoder.go b/pkg/ffmpeg/encoder.go index 2334808ed..f66583912 100644 --- a/pkg/ffmpeg/encoder.go +++ b/pkg/ffmpeg/encoder.go @@ -9,6 +9,7 @@ import ( "sync" "time" + "github.com/stashapp/stash/pkg/desktop" "github.com/stashapp/stash/pkg/logger" ) @@ -90,6 +91,7 @@ func (e *Encoder) runTranscode(probeResult VideoFile, args []string) (string, er logger.Error("FFMPEG stdout not available: " + err.Error()) } + desktop.HideExecShell(cmd) if err = cmd.Start(); err != nil { return "", err } @@ -141,6 +143,7 @@ func (e *Encoder) run(sourcePath string, args []string, stdin io.Reader) (string cmd.Stderr = &stderr cmd.Stdin = stdin + desktop.HideExecShell(cmd) if err := cmd.Start(); err != nil { return "", err } diff --git a/pkg/ffmpeg/encoder_sprite_screenshot.go b/pkg/ffmpeg/encoder_sprite_screenshot.go index cba560430..d0068cb2a 100644 --- a/pkg/ffmpeg/encoder_sprite_screenshot.go +++ b/pkg/ffmpeg/encoder_sprite_screenshot.go @@ -8,6 +8,7 @@ import ( type SpriteScreenshotOptions struct { Time float64 + Frame int Width int } @@ -36,3 +37,31 @@ func (e *Encoder) SpriteScreenshot(probeResult VideoFile, options SpriteScreensh return img, err } + +// SpriteScreenshotSlow uses the select filter to get a single frame from a videofile instead of seeking +// It is very slow and should only be used for files with very small duration in secs / frame count +func (e *Encoder) SpriteScreenshotSlow(probeResult VideoFile, options SpriteScreenshotOptions) (image.Image, error) { + args := []string{ + "-v", "error", + "-i", probeResult.Path, + "-vsync", "0", // do not create/drop frames + "-vframes", "1", + "-vf", fmt.Sprintf("select=eq(n\\,%d),scale=%v:-1", options.Frame, options.Width), // keep only frame number options.Frame + "-c:v", "bmp", + "-f", "rawvideo", + "-", + } + data, err := e.run(probeResult.Path, args, nil) + if err != nil { + return nil, err + } + + reader := strings.NewReader(data) + + img, _, err := image.Decode(reader) + if err != nil { + return nil, err + } + + return img, err +} diff --git a/pkg/ffmpeg/ffprobe.go b/pkg/ffmpeg/ffprobe.go index dfde16d8b..acb4c2165 100644 --- a/pkg/ffmpeg/ffprobe.go +++ b/pkg/ffmpeg/ffprobe.go @@ -11,6 +11,7 @@ import ( "strings" "time" + "github.com/stashapp/stash/pkg/desktop" "github.com/stashapp/stash/pkg/logger" ) @@ -218,6 +219,7 @@ type VideoFile struct { Height int FrameRate float64 Rotation int64 + FrameCount int64 AudioCodec string } @@ -228,7 +230,9 @@ type FFProbe string // Execute exec command and bind result to struct. func (f *FFProbe) NewVideoFile(videoPath string, stripExt bool) (*VideoFile, error) { args := []string{"-v", "quiet", "-print_format", "json", "-show_format", "-show_streams", "-show_error", videoPath} - out, err := exec.Command(string(*f), args...).Output() + cmd := exec.Command(string(*f), args...) + desktop.HideExecShell(cmd) + out, err := cmd.Output() if err != nil { return nil, fmt.Errorf("FFProbe encountered an error with <%s>.\nError JSON:\n%s\nError: %s", videoPath, string(out), err.Error()) @@ -242,6 +246,24 @@ func (f *FFProbe) NewVideoFile(videoPath string, stripExt bool) (*VideoFile, err return parse(videoPath, probeJSON, stripExt) } +// GetReadFrameCount counts the actual frames of the video file +func (f *FFProbe) GetReadFrameCount(vf *VideoFile) (int64, error) { + args := []string{"-v", "quiet", "-print_format", "json", "-count_frames", "-show_format", "-show_streams", "-show_error", vf.Path} + out, err := exec.Command(string(*f), args...).Output() + + if err != nil { + return 0, fmt.Errorf("FFProbe encountered an error with <%s>.\nError JSON:\n%s\nError: %s", vf.Path, string(out), err.Error()) + } + + probeJSON := &FFProbeJSON{} + if err := json.Unmarshal(out, probeJSON); err != nil { + return 0, fmt.Errorf("error unmarshalling video data for <%s>: %s", vf.Path, err.Error()) + } + + fc, err := parse(vf.Path, probeJSON, false) + return fc.FrameCount, err +} + func parse(filePath string, probeJSON *FFProbeJSON, stripExt bool) (*VideoFile, error) { if probeJSON == nil { return nil, fmt.Errorf("failed to get ffprobe json for <%s>", filePath) @@ -263,8 +285,8 @@ func parse(filePath string, probeJSON *FFProbeJSON, stripExt bool) (*VideoFile, } result.Comment = probeJSON.Format.Tags.Comment - result.Bitrate, _ = strconv.ParseInt(probeJSON.Format.BitRate, 10, 64) + result.Container = probeJSON.Format.FormatName duration, _ := strconv.ParseFloat(probeJSON.Format.Duration, 64) result.Duration = math.Round(duration*100) / 100 @@ -288,6 +310,15 @@ func parse(filePath string, probeJSON *FFProbeJSON, stripExt bool) (*VideoFile, if videoStream != nil { result.VideoStream = videoStream result.VideoCodec = videoStream.CodecName + result.FrameCount, _ = strconv.ParseInt(videoStream.NbFrames, 10, 64) + if videoStream.NbReadFrames != "" { // if ffprobe counted the frames use that instead + fc, _ := strconv.ParseInt(videoStream.NbReadFrames, 10, 64) + if fc > 0 { + result.FrameCount, _ = strconv.ParseInt(videoStream.NbReadFrames, 10, 64) + } else { + logger.Debugf("[ffprobe] <%s> invalid Read Frames count", videoStream.NbReadFrames) + } + } result.VideoBitrate, _ = strconv.ParseInt(videoStream.BitRate, 10, 64) var framerate float64 if strings.Contains(videoStream.AvgFrameRate, "/") { diff --git a/pkg/ffmpeg/stream.go b/pkg/ffmpeg/stream.go index 1f4d4960e..41832fc89 100644 --- a/pkg/ffmpeg/stream.go +++ b/pkg/ffmpeg/stream.go @@ -8,6 +8,7 @@ import ( "strconv" "strings" + "github.com/stashapp/stash/pkg/desktop" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" ) @@ -220,6 +221,7 @@ func (e *Encoder) stream(probeResult VideoFile, options TranscodeStreamOptions) return nil, err } + desktop.HideExecShell(cmd) if err = cmd.Start(); err != nil { return nil, err } diff --git a/pkg/ffmpeg/types.go b/pkg/ffmpeg/types.go index ed3fadf67..d239c6cdf 100644 --- a/pkg/ffmpeg/types.go +++ b/pkg/ffmpeg/types.go @@ -70,6 +70,7 @@ type FFProbeStream struct { Level int `json:"level,omitempty"` NalLengthSize string `json:"nal_length_size,omitempty"` NbFrames string `json:"nb_frames"` + NbReadFrames string `json:"nb_read_frames"` PixFmt string `json:"pix_fmt,omitempty"` Profile string `json:"profile"` RFrameRate string `json:"r_frame_rate"` diff --git a/pkg/file/scan.go b/pkg/file/scan.go index 4c0ac3152..672fee853 100644 --- a/pkg/file/scan.go +++ b/pkg/file/scan.go @@ -4,6 +4,7 @@ import ( "fmt" "io" "io/fs" + "os" "strconv" "time" @@ -117,6 +118,19 @@ func (o Scanner) generateHashes(f *models.File, file SourceFile, regenerate bool if o.CalculateOSHash && (regenerate || f.OSHash == "") { logger.Infof("Calculating oshash for %s ...", f.Path) + size := file.FileInfo().Size() + + // #2196 for symlinks + // get the size of the actual file, not the symlink + if file.FileInfo().Mode()&os.ModeSymlink == os.ModeSymlink { + fi, err := os.Stat(f.Path) + if err != nil { + return false, err + } + logger.Debugf("File <%s> is symlink. Size changed from <%d> to <%d>", f.Path, size, fi.Size()) + size = fi.Size() + } + src, err = file.Open() if err != nil { return false, err @@ -130,7 +144,7 @@ func (o Scanner) generateHashes(f *models.File, file SourceFile, regenerate bool // regenerate hash var oshash string - oshash, err = o.Hasher.OSHash(seekSrc, file.FileInfo().Size()) + oshash, err = o.Hasher.OSHash(seekSrc, size) if err != nil { return false, fmt.Errorf("error generating oshash for %s: %w", file.Path(), err) } diff --git a/pkg/identify/identify.go b/pkg/identify/identify.go index 2520618b3..1cb857e87 100644 --- a/pkg/identify/identify.go +++ b/pkg/identify/identify.go @@ -40,7 +40,7 @@ func (t *SceneIdentifier) Identify(ctx context.Context, txnManager models.Transa } if result == nil { - logger.Infof("Unable to identify %s", scene.Path) + logger.Debugf("Unable to identify %s", scene.Path) return nil } @@ -176,7 +176,7 @@ func (t *SceneIdentifier) modifyScene(ctx context.Context, txnManager models.Tra // don't update anything if nothing was set if updater.IsEmpty() { - logger.Infof("Nothing to set for %s", s.Path) + logger.Debugf("Nothing to set for %s", s.Path) return nil } diff --git a/pkg/image/vips.go b/pkg/image/vips.go index 061afa5f8..951406848 100644 --- a/pkg/image/vips.go +++ b/pkg/image/vips.go @@ -6,6 +6,7 @@ import ( "os/exec" "strings" + "github.com/stashapp/stash/pkg/desktop" "github.com/stashapp/stash/pkg/logger" ) @@ -32,6 +33,7 @@ func (e *vipsEncoder) run(args []string, stdin *bytes.Buffer) (string, error) { cmd.Stderr = &stderr cmd.Stdin = stdin + desktop.HideExecShell(cmd) if err := cmd.Start(); err != nil { return "", err } diff --git a/pkg/job/manager.go b/pkg/job/manager.go index ed4dda133..95837ca2a 100644 --- a/pkg/job/manager.go +++ b/pkg/job/manager.go @@ -2,9 +2,13 @@ package job import ( "context" + "fmt" + "strconv" + "strings" "sync" "time" + "github.com/stashapp/stash/pkg/desktop" "github.com/stashapp/stash/pkg/utils" ) @@ -204,9 +208,14 @@ func (m *Manager) onJobFinish(job *Job) { } else { job.Status = StatusFinished } - t := time.Now() job.EndTime = &t + cleanDesc := strings.TrimRight(job.Description, ".") + timeElapsed := job.EndTime.Sub(*job.StartTime) + hours := fmt.Sprintf("%+02s", strconv.FormatFloat(timeElapsed.Hours(), 'f', 0, 64)) + minutes := fmt.Sprintf("%+02s", strconv.FormatFloat(timeElapsed.Minutes(), 'f', 0, 64)) + seconds := fmt.Sprintf("%+02s", strconv.FormatFloat(timeElapsed.Seconds(), 'f', 0, 64)) + desktop.SendNotification("Task Finished", "Task \""+cleanDesc+"\" is finished in "+hours+":"+minutes+":"+seconds+".") } func (m *Manager) removeJob(job *Job) { diff --git a/pkg/logger/logger.go b/pkg/logger/logger.go index 0b4a4225b..1a4c0d180 100644 --- a/pkg/logger/logger.go +++ b/pkg/logger/logger.go @@ -32,6 +32,7 @@ func Init(logFile string, logOut bool, logLevel string) { customFormatter.TimestampFormat = "2006-01-02 15:04:05" customFormatter.ForceColors = true customFormatter.FullTimestamp = true + logger.SetOutput(os.Stderr) logger.SetFormatter(customFormatter) // #1837 - trigger the console to use color-mode since it won't be diff --git a/pkg/manager/config/config.go b/pkg/manager/config/config.go index bfe1b7003..07780db8c 100644 --- a/pkg/manager/config/config.go +++ b/pkg/manager/config/config.go @@ -21,6 +21,8 @@ import ( "github.com/stashapp/stash/pkg/utils" ) +var officialBuild string + const ( Stash = "stash" Cache = "cache" @@ -133,6 +135,9 @@ const ( ShowStudioAsText = "show_studio_as_text" CSSEnabled = "cssEnabled" + ShowScrubber = "show_scrubber" + showScrubberDefault = true + WallPlayback = "wall_playback" defaultWallPlayback = "video" @@ -147,7 +152,6 @@ const ( FunscriptOffset = "funscript_offset" // Security - TrustedProxies = "trusted_proxies" dangerousAllowPublicWithoutAuth = "dangerous_allow_public_without_auth" dangerousAllowPublicWithoutAuthDefault = "false" SecurityTripwireAccessedFromPublicInternet = "security_tripwire_accessed_from_public_internet" @@ -179,8 +183,12 @@ const ( deleteGeneratedDefaultDefault = true // Desktop Integration Options - NoBrowser = "noBrowser" - NoBrowserDefault = false + NoBrowser = "noBrowser" + NoBrowserDefault = false + NotificationsEnabled = "notifications_enabled" + NotificationsEnabledDefault = true + ShowOneTimeMovedNotification = "show_one_time_moved_notification" + ShowOneTimeMovedNotificationDefault = false // File upload options MaxUploadSize = "max_upload_size" @@ -212,6 +220,10 @@ func (s *StashBoxError) Error() string { return "Stash-box: " + s.msg } +func IsOfficialBuild() bool { + return officialBuild == "true" +} + type Instance struct { // main instance - backed by config file main *viper.Viper @@ -222,8 +234,9 @@ type Instance struct { cpuProfilePath string isNewSystem bool - certFile string - keyFile string + // configUpdates chan int + certFile string + keyFile string sync.RWMutex // deadlock.RWMutex // for deadlock testing/issues } @@ -271,7 +284,25 @@ func (i *Instance) GetNoBrowser() bool { return i.getBool(NoBrowser) } +func (i *Instance) GetNotificationsEnabled() bool { + return i.getBool(NotificationsEnabled) +} + +// func (i *Instance) GetConfigUpdatesChannel() chan int { +// return i.configUpdates +// } + +// GetShowOneTimeMovedNotification shows whether a small notification to inform the user that Stash +// will no longer show a terminal window, and instead will be available in the tray, should be shown. +// It is true when an existing system is started after upgrading, and set to false forever after it is shown. +func (i *Instance) GetShowOneTimeMovedNotification() bool { + return i.getBool(ShowOneTimeMovedNotification) +} + func (i *Instance) Set(key string, value interface{}) { + // if key == MenuItems { + // i.configUpdates <- 0 + // } i.Lock() defer i.Unlock() i.main.Set(key, value) @@ -367,6 +398,18 @@ func (i *Instance) getBool(key string) bool { return i.viper(key).GetBool(key) } +func (i *Instance) getBoolDefault(key string, def bool) bool { + i.RLock() + defer i.RUnlock() + + ret := def + v := i.viper(key) + if v.IsSet(key) { + ret = v.GetBool(key) + } + return ret +} + func (i *Instance) getInt(key string) int { i.RLock() defer i.RUnlock() @@ -531,16 +574,7 @@ func (i *Instance) GetScraperCDPPath() string { // GetScraperCertCheck returns true if the scraper should check for insecure // certificates when fetching an image or a page. func (i *Instance) GetScraperCertCheck() bool { - ret := true - i.RLock() - defer i.RUnlock() - - v := i.viper(ScraperCertCheck) - if v.IsSet(ScraperCertCheck) { - ret = v.GetBool(ScraperCertCheck) - } - - return ret + return i.getBoolDefault(ScraperCertCheck, true) } func (i *Instance) GetScraperExcludeTagPatterns() []string { @@ -820,6 +854,10 @@ func (i *Instance) GetWallPlayback() string { return ret } +func (i *Instance) GetShowScrubber() bool { + return i.getBoolDefault(ShowScrubber, showScrubberDefault) +} + func (i *Instance) GetMaximumLoopDuration() int { return i.getInt(MaximumLoopDuration) } @@ -829,16 +867,7 @@ func (i *Instance) GetAutostartVideo() bool { } func (i *Instance) GetAutostartVideoOnPlaySelected() bool { - i.Lock() - defer i.Unlock() - - ret := autostartVideoOnPlaySelectedDefault - v := i.viper(AutostartVideoOnPlaySelected) - if v.IsSet(AutostartVideoOnPlaySelected) { - ret = v.GetBool(AutostartVideoOnPlaySelected) - } - - return ret + return i.getBoolDefault(AutostartVideoOnPlaySelected, autostartVideoOnPlaySelectedDefault) } func (i *Instance) GetContinuePlaylistDefault() bool { @@ -926,16 +955,7 @@ func (i *Instance) GetDeleteFileDefault() bool { } func (i *Instance) GetDeleteGeneratedDefault() bool { - i.RLock() - defer i.RUnlock() - ret := deleteGeneratedDefaultDefault - - v := i.viper(DeleteGeneratedDefault) - if v.IsSet(DeleteGeneratedDefault) { - ret = v.GetBool(DeleteGeneratedDefault) - } - - return ret + return i.getBoolDefault(DeleteGeneratedDefault, deleteGeneratedDefaultDefault) } // GetDefaultIdentifySettings returns the default Identify task settings. @@ -1014,12 +1034,6 @@ func (i *Instance) GetDefaultGenerateSettings() *models.GenerateMetadataOptions return nil } -// GetTrustedProxies returns a comma separated list of ip addresses that should allow proxying. -// When empty, allow from any private network -func (i *Instance) GetTrustedProxies() []string { - return i.getStringSlice(TrustedProxies) -} - // GetDangerousAllowPublicWithoutAuth determines if the security feature is enabled. // See https://github.com/stashapp/stash/wiki/Authentication-Required-When-Accessing-Stash-From-the-Internet func (i *Instance) GetDangerousAllowPublicWithoutAuth() bool { @@ -1066,17 +1080,7 @@ func (i *Instance) GetLogFile() string { // in addition to writing to a log file. Logging will be output to the // terminal if file logging is disabled. Defaults to true. func (i *Instance) GetLogOut() bool { - i.RLock() - defer i.RUnlock() - - ret := defaultLogOut - v := i.viper(LogOut) - - if v.IsSet(LogOut) { - ret = v.GetBool(LogOut) - } - - return ret + return i.getBoolDefault(LogOut, defaultLogOut) } // GetLogLevel returns the lowest log level to write to the log. @@ -1093,16 +1097,7 @@ func (i *Instance) GetLogLevel() string { // GetLogAccess returns true if http requests should be logged to the terminal. // HTTP requests are not logged to the log file. Defaults to true. func (i *Instance) GetLogAccess() bool { - i.RLock() - defer i.RUnlock() - ret := defaultLogAccess - - v := i.viper(LogAccess) - if v.IsSet(LogAccess) { - ret = v.GetBool(LogAccess) - } - - return ret + return i.getBoolDefault(LogAccess, defaultLogAccess) } // Max allowed graphql upload size in megabytes @@ -1191,6 +1186,8 @@ func (i *Instance) setDefaultValues(write bool) error { i.main.SetDefault(Generated, i.main.GetString(Metadata)) i.main.SetDefault(NoBrowser, NoBrowserDefault) + i.main.SetDefault(NotificationsEnabled, NotificationsEnabledDefault) + i.main.SetDefault(ShowOneTimeMovedNotification, ShowOneTimeMovedNotificationDefault) // Set default scrapers and plugins paths i.main.SetDefault(ScrapersPath, defaultScrapersPath) @@ -1217,6 +1214,12 @@ func (i *Instance) setExistingSystemDefaults() error { i.main.Set(NoBrowser, true) } + // Existing systems as of the introduction of the taskbar should inform users. + if !i.main.InConfig(ShowOneTimeMovedNotification) { + configDirtied = true + i.main.Set(ShowOneTimeMovedNotification, true) + } + if configDirtied { return i.main.WriteConfig() } @@ -1254,4 +1257,5 @@ func (i *Instance) setInitialConfig(write bool) error { func (i *Instance) FinalizeSetup() { i.isNewSystem = false + // i.configUpdates <- 0 } diff --git a/pkg/manager/config/config_concurrency_test.go b/pkg/manager/config/config_concurrency_test.go index 4b940cb2e..6db550c73 100644 --- a/pkg/manager/config/config_concurrency_test.go +++ b/pkg/manager/config/config_concurrency_test.go @@ -99,7 +99,6 @@ func TestConcurrentConfigAccess(t *testing.T) { i.Set(DefaultIdentifySettings, i.GetDefaultIdentifySettings()) i.Set(DeleteGeneratedDefault, i.GetDeleteGeneratedDefault()) i.Set(DeleteFileDefault, i.GetDeleteFileDefault()) - i.Set(TrustedProxies, i.GetTrustedProxies()) i.Set(dangerousAllowPublicWithoutAuth, i.GetDangerousAllowPublicWithoutAuth()) i.Set(SecurityTripwireAccessedFromPublicInternet, i.GetSecurityTripwireAccessedFromPublicInternet()) i.Set(DisableDropdownCreatePerformer, i.GetDisableDropdownCreate().Performer) diff --git a/pkg/manager/config/init.go b/pkg/manager/config/init.go index 0b2ddb6b8..02322ec62 100644 --- a/pkg/manager/config/init.go +++ b/pkg/manager/config/init.go @@ -45,6 +45,7 @@ func Initialize() (*Instance, error) { _ = GetInstance() instance.overrides = overrides instance.cpuProfilePath = flags.cpuProfilePath + // instance.configUpdates = make(chan int) if err = initConfig(instance, flags); err != nil { return diff --git a/pkg/manager/exclude_files.go b/pkg/manager/exclude_files.go index b80b2f911..6c5452d0d 100644 --- a/pkg/manager/exclude_files.go +++ b/pkg/manager/exclude_files.go @@ -38,7 +38,7 @@ func excludeFiles(files []string, patterns []string) ([]string, int) { func matchFileRegex(file string, fileRegexps []*regexp.Regexp) bool { for _, regPattern := range fileRegexps { - if regPattern.MatchString(strings.ToLower(file)) { + if regPattern.MatchString(file) { return true } } @@ -60,7 +60,10 @@ func generateRegexps(patterns []string) []*regexp.Regexp { var fileRegexps []*regexp.Regexp for _, pattern := range patterns { - reg, err := regexp.Compile(strings.ToLower(pattern)) + if !strings.HasPrefix(pattern, "(?i)") { + pattern = "(?i)" + pattern + } + reg, err := regexp.Compile(pattern) if err != nil { logger.Errorf("Exclude :%v", err) } else { @@ -78,7 +81,7 @@ func generateRegexps(patterns []string) []*regexp.Regexp { func matchFileSimple(file string, regExps []*regexp.Regexp) bool { for _, regPattern := range regExps { - if regPattern.MatchString(strings.ToLower(file)) { + if regPattern.MatchString(file) { return true } } diff --git a/pkg/manager/exclude_files_test.go b/pkg/manager/exclude_files_test.go index df8a0a140..d81e75e00 100644 --- a/pkg/manager/exclude_files_test.go +++ b/pkg/manager/exclude_files_test.go @@ -26,7 +26,9 @@ var excludeTestFilenames = []string{ "\\\\network\\videos\\filename windows network.mp4", "\\\\network\\share\\windows network wanted.mp4", "\\\\network\\share\\windows network wanted sample.mp4", - "\\\\network\\private\\windows.network.skip.mp4"} + "\\\\network\\private\\windows.network.skip.mp4", + "/stash/videos/a5.mp4", + "/stash/videos/mIxEdCaSe.mp4"} var excludeTests = []struct { testPattern []string @@ -42,6 +44,10 @@ var excludeTests = []struct { {[]string{"^\\\\\\\\network"}, 4}, // windows net share {[]string{"\\\\private\\\\"}, 1}, // windows net share {[]string{"\\\\private\\\\", "sample\\.mp4"}, 3}, // windows net share + {[]string{"\\D\\d\\.mp4"}, 1}, // validates that \D doesn't get converted to lowercase \d + {[]string{"mixedcase\\.mp4"}, 1}, // validates we can match the mixed case file + {[]string{"MIXEDCASE\\.mp4"}, 1}, // validates we can match the mixed case file + {[]string{"(?i)MIXEDCASE\\.mp4"}, 1}, // validates we can match the mixed case file without adding another (?i) to it } func TestExcludeFiles(t *testing.T) { diff --git a/pkg/manager/generator.go b/pkg/manager/generator.go index c7aed9716..9a6126cc2 100644 --- a/pkg/manager/generator.go +++ b/pkg/manager/generator.go @@ -9,6 +9,7 @@ import ( "strconv" "strings" + "github.com/stashapp/stash/pkg/desktop" "github.com/stashapp/stash/pkg/ffmpeg" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/utils" @@ -72,6 +73,7 @@ func (g *GeneratorInfo) calculateFrameRate(videoStream *ffmpeg.FFProbeStream) er } command := exec.Command(string(instance.FFMPEG), args...) + desktop.HideExecShell(command) var stdErrBuffer bytes.Buffer command.Stderr = &stdErrBuffer // Frames go to stderr rather than stdout if err := command.Run(); err == nil { @@ -112,6 +114,12 @@ func (g *GeneratorInfo) configure() error { return err } + // #2250 - ensure ChunkCount is valid + if g.ChunkCount < 1 { + logger.Warnf("[generator] Segment count (%d) must be > 0. Using 1 instead.", g.ChunkCount) + g.ChunkCount = 1 + } + g.NthFrame = g.NumberOfFrames / g.ChunkCount return nil diff --git a/pkg/manager/generator_sprite.go b/pkg/manager/generator_sprite.go index c374217ce..72c45e124 100644 --- a/pkg/manager/generator_sprite.go +++ b/pkg/manager/generator_sprite.go @@ -25,6 +25,7 @@ type SpriteGenerator struct { VTTOutputPath string Rows int Columns int + SlowSeek bool // use alternate seek function, very slow! Overwrite bool } @@ -34,17 +35,33 @@ func NewSpriteGenerator(videoFile ffmpeg.VideoFile, videoChecksum string, imageO if !exists { return nil, err } + slowSeek := false + chunkCount := rows * cols - // FFMPEG bombs out if we try to request 89 snapshots from a 2 second video - if videoFile.Duration < 3 { - return nil, errors.New("video too short to create sprite") + // For files with small duration / low frame count try to seek using frame number intead of seconds + if videoFile.Duration < 5 || (0 < videoFile.FrameCount && videoFile.FrameCount <= int64(chunkCount)) { // some files can have FrameCount == 0, only use SlowSeek if duration < 5 + if videoFile.Duration <= 0 { + s := fmt.Sprintf("video %s: duration(%.3f)/frame count(%d) invalid, skipping sprite creation", videoFile.Path, videoFile.Duration, videoFile.FrameCount) + return nil, errors.New(s) + } + logger.Warnf("[generator] video %s too short (%.3fs, %d frames), using frame seeking", videoFile.Path, videoFile.Duration, videoFile.FrameCount) + slowSeek = true + // do an actual frame count of the file ( number of frames = read frames) + ffprobe := GetInstance().FFProbe + fc, err := ffprobe.GetReadFrameCount(&videoFile) + if err == nil { + if fc != videoFile.FrameCount { + logger.Warnf("[generator] updating framecount (%d) for %s with read frames count (%d)", videoFile.FrameCount, videoFile.Path, fc) + videoFile.FrameCount = fc + } + } } generator, err := newGeneratorInfo(videoFile) if err != nil { return nil, err } - generator.ChunkCount = rows * cols + generator.ChunkCount = chunkCount if err := generator.configure(); err != nil { return nil, err } @@ -55,6 +72,7 @@ func NewSpriteGenerator(videoFile ffmpeg.VideoFile, videoChecksum string, imageO ImageOutputPath: imageOutputPath, VTTOutputPath: vttOutputPath, Rows: rows, + SlowSeek: slowSeek, Columns: cols, }, nil } @@ -75,23 +93,51 @@ func (g *SpriteGenerator) generateSpriteImage(encoder *ffmpeg.Encoder) error { if !g.Overwrite && g.imageExists() { return nil } - logger.Infof("[generator] generating sprite image for %s", g.Info.VideoFile.Path) - // Create `this.chunkCount` thumbnails in the tmp directory - stepSize := g.Info.VideoFile.Duration / float64(g.Info.ChunkCount) var images []image.Image - for i := 0; i < g.Info.ChunkCount; i++ { - time := float64(i) * stepSize - options := ffmpeg.SpriteScreenshotOptions{ - Time: time, - Width: 160, + if !g.SlowSeek { + logger.Infof("[generator] generating sprite image for %s", g.Info.VideoFile.Path) + // generate `ChunkCount` thumbnails + stepSize := g.Info.VideoFile.Duration / float64(g.Info.ChunkCount) + + for i := 0; i < g.Info.ChunkCount; i++ { + time := float64(i) * stepSize + + options := ffmpeg.SpriteScreenshotOptions{ + Time: time, + Width: 160, + } + + img, err := encoder.SpriteScreenshot(g.Info.VideoFile, options) + + if err != nil { + return err + } + images = append(images, img) } - img, err := encoder.SpriteScreenshot(g.Info.VideoFile, options) - if err != nil { - return err + } else { + logger.Infof("[generator] generating sprite image for %s (%d frames)", g.Info.VideoFile.Path, g.Info.VideoFile.FrameCount) + + stepFrame := float64(g.Info.VideoFile.FrameCount-1) / float64(g.Info.ChunkCount) + + for i := 0; i < g.Info.ChunkCount; i++ { + // generate exactly `ChunkCount` thumbnails, using duplicate frames if needed + frame := math.Round(float64(i) * stepFrame) + if frame >= math.MaxInt || frame <= math.MinInt { + return errors.New("invalid frame number conversion") + } + options := ffmpeg.SpriteScreenshotOptions{ + Frame: int(frame), + Width: 160, + } + img, err := encoder.SpriteScreenshotSlow(g.Info.VideoFile, options) + if err != nil { + return err + } + images = append(images, img) } - images = append(images, img) + } if len(images) == 0 { @@ -132,7 +178,15 @@ func (g *SpriteGenerator) generateSpriteVTT(encoder *ffmpeg.Encoder) error { width := image.Width / g.Columns height := image.Height / g.Rows - stepSize := float64(g.Info.NthFrame) / g.Info.FrameRate + var stepSize float64 + if !g.SlowSeek { + stepSize = float64(g.Info.NthFrame) / g.Info.FrameRate + } else { + // for files with a low framecount ( unicode.MaxASCII { + return false + } + } + return true +} + // nameMatchesPath returns the index in the path for the right-most match. // Returns -1 if not found. func nameMatchesPath(name, path string) int { - // escape specific regex characters - name = regexp.QuoteMeta(name) - - name = strings.ToLower(name) - path = strings.ToLower(path) - - // handle path separators - const separator = `[` + separatorChars + `]` - - reStr := strings.ReplaceAll(name, " ", separator+"*") - reStr = `(?:^|_|[^\w\d])` + reStr + `(?:$|_|[^\w\d])` - - re := regexp.MustCompile(reStr) + // #2363 - optimisation: only use unicode character regexp if path contains + // unicode characters + re := nameToRegexp(name, !allASCII(path)) found := re.FindAllStringIndex(path, -1) if found == nil { @@ -84,6 +99,39 @@ func nameMatchesPath(name, path string) int { return found[len(found)-1][0] } +// nameToRegexp compiles a regexp pattern to match paths from the given name. +// Set useUnicode to true if this regexp is to be used on any strings with unicode characters. +func nameToRegexp(name string, useUnicode bool) *regexp.Regexp { + // escape specific regex characters + name = regexp.QuoteMeta(name) + + name = strings.ToLower(name) + + // handle path separators + const separator = `[` + separatorChars + `]` + + // performance optimisation: only use \p{L} is useUnicode is true + notWord := reNotLetterWord + if useUnicode { + notWord = reNotLetterWordUnicode + } + + reStr := strings.ReplaceAll(name, " ", separator+"*") + reStr = `(?:^|_|` + notWord + `)` + reStr + `(?:$|_|` + notWord + `)` + + re := regexp.MustCompile(reStr) + return re +} + +func regexpMatchesPath(r *regexp.Regexp, path string) int { + path = strings.ToLower(path) + found := r.FindAllStringIndex(path, -1) + if found == nil { + return -1 + } + return found[len(found)-1][0] +} + func PathToPerformers(path string, performerReader models.PerformerReader) ([]*models.Performer, error) { words := getPathWords(path) performers, err := performerReader.QueryForAutoTag(words) @@ -199,8 +247,13 @@ func PathToScenes(name string, paths []string, sceneReader models.SceneReader) ( } var ret []*models.Scene + + // paths may have unicode characters + const useUnicode = true + + r := nameToRegexp(name, useUnicode) for _, p := range scenes { - if nameMatchesPath(name, p.Path) != -1 { + if regexpMatchesPath(r, p.Path) != -1 { ret = append(ret, p) } } @@ -231,8 +284,13 @@ func PathToImages(name string, paths []string, imageReader models.ImageReader) ( } var ret []*models.Image + + // paths may have unicode characters + const useUnicode = true + + r := nameToRegexp(name, useUnicode) for _, p := range images { - if nameMatchesPath(name, p.Path) != -1 { + if regexpMatchesPath(r, p.Path) != -1 { ret = append(ret, p) } } @@ -263,8 +321,13 @@ func PathToGalleries(name string, paths []string, galleryReader models.GalleryRe } var ret []*models.Gallery + + // paths may have unicode characters + const useUnicode = true + + r := nameToRegexp(name, useUnicode) for _, p := range gallerys { - if nameMatchesPath(name, p.Path.String) != -1 { + if regexpMatchesPath(r, p.Path.String) != -1 { ret = append(ret, p) } } diff --git a/pkg/match/path_test.go b/pkg/match/path_test.go index f2818a801..c162b8feb 100644 --- a/pkg/match/path_test.go +++ b/pkg/match/path_test.go @@ -4,71 +4,90 @@ import "testing" func Test_nameMatchesPath(t *testing.T) { const name = "first last" + const unicodeName = "伏字" tests := []struct { - name string - path string - want int + testName string + name string + path string + want int }{ { "exact", name, + name, 0, }, { "partial", + name, "first", -1, }, { "separator", + name, "first.last", 0, }, { "separator", + name, "first-last", 0, }, { "separator", + name, "first_last", 0, }, { "separators", + name, "first.-_ last", 0, }, { "within string", + name, "before_first last/after", 6, }, { "not within string", + name, "beforefirst last/after", -1, }, { "not within string", + name, "before/first lastafter", -1, }, { "not within string", + name, "first last1", -1, }, { "not within string", + name, "1first last", -1, }, + { + "unicode", + unicodeName, + unicodeName, + 0, + }, } for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := nameMatchesPath(name, tt.path); got != tt.want { + t.Run(tt.testName, func(t *testing.T) { + if got := nameMatchesPath(tt.name, tt.path); got != tt.want { t.Errorf("nameMatchesPath() = %v, want %v", got, tt.want) } }) diff --git a/pkg/plugin/raw.go b/pkg/plugin/raw.go index 1fcc6ad87..6456cab4c 100644 --- a/pkg/plugin/raw.go +++ b/pkg/plugin/raw.go @@ -8,6 +8,7 @@ import ( "os/exec" "sync" + "github.com/stashapp/stash/pkg/desktop" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/plugin/common" ) @@ -87,6 +88,7 @@ func (t *rawPluginTask) Start() error { t.waitGroup.Add(1) t.done = make(chan bool, 1) + desktop.HideExecShell(cmd) if err = cmd.Start(); err != nil { return fmt.Errorf("error running plugin: %v", err) } diff --git a/pkg/scene/delete.go b/pkg/scene/delete.go index 57802d0cb..63672eecb 100644 --- a/pkg/scene/delete.go +++ b/pkg/scene/delete.go @@ -135,6 +135,14 @@ func Destroy(scene *models.Scene, repo models.Repository, fileDeleter *FileDelet if err := fileDeleter.Files([]string{scene.Path}); err != nil { return err } + + funscriptPath := utils.GetFunscriptPath(scene.Path) + funscriptExists, _ := utils.FileExists(funscriptPath) + if funscriptExists { + if err := fileDeleter.Files([]string{funscriptPath}); err != nil { + return err + } + } } if deleteGenerated { diff --git a/pkg/scraper/mapped.go b/pkg/scraper/mapped.go index a797a4242..d3fb44c37 100644 --- a/pkg/scraper/mapped.go +++ b/pkg/scraper/mapped.go @@ -840,7 +840,7 @@ func (s mappedScraper) processScene(ctx context.Context, q mappedQuery, r mapped for _, p := range performerTagResults { tag := &models.ScrapedTag{} p.apply(tag) - ret.Tags = append(ret.Tags, tag) + performer.Tags = append(performer.Tags, tag) } ret.Performers = append(ret.Performers, performer) diff --git a/pkg/scraper/script.go b/pkg/scraper/script.go index cb6900ace..8212c3deb 100644 --- a/pkg/scraper/script.go +++ b/pkg/scraper/script.go @@ -10,6 +10,7 @@ import ( "path/filepath" "strings" + "github.com/stashapp/stash/pkg/desktop" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" ) @@ -66,6 +67,7 @@ func (s *scriptScraper) runScraperScript(inString string, out interface{}) error logger.Error("Scraper stdout not available: " + err.Error()) } + desktop.HideExecShell(cmd) if err = cmd.Start(); err != nil { logger.Error("Error running scraper script: " + err.Error()) return errors.New("error running scraper script") diff --git a/pkg/scraper/stashbox/graphql/generated_client.go b/pkg/scraper/stashbox/graphql/generated_client.go index 8e0b31429..39bf3f91e 100644 --- a/pkg/scraper/stashbox/graphql/generated_client.go +++ b/pkg/scraper/stashbox/graphql/generated_client.go @@ -31,6 +31,8 @@ type Query struct { FindScenesByFingerprints []*Scene "json:\"findScenesByFingerprints\" graphql:\"findScenesByFingerprints\"" FindScenesByFullFingerprints []*Scene "json:\"findScenesByFullFingerprints\" graphql:\"findScenesByFullFingerprints\"" QueryScenes QueryScenesResultType "json:\"queryScenes\" graphql:\"queryScenes\"" + FindSite *Site "json:\"findSite\" graphql:\"findSite\"" + QuerySites QuerySitesResultType "json:\"querySites\" graphql:\"querySites\"" FindEdit *Edit "json:\"findEdit\" graphql:\"findEdit\"" QueryEdits QueryEditsResultType "json:\"queryEdits\" graphql:\"queryEdits\"" FindUser *User "json:\"findUser\" graphql:\"findUser\"" @@ -38,48 +40,57 @@ type Query struct { Me *User "json:\"me\" graphql:\"me\"" SearchPerformer []*Performer "json:\"searchPerformer\" graphql:\"searchPerformer\"" SearchScene []*Scene "json:\"searchScene\" graphql:\"searchScene\"" + FindDraft *Draft "json:\"findDraft\" graphql:\"findDraft\"" + FindDrafts []*Draft "json:\"findDrafts\" graphql:\"findDrafts\"" Version Version "json:\"version\" graphql:\"version\"" + GetConfig StashBoxConfig "json:\"getConfig\" graphql:\"getConfig\"" } type Mutation struct { - SceneCreate *Scene "json:\"sceneCreate\" graphql:\"sceneCreate\"" - SceneUpdate *Scene "json:\"sceneUpdate\" graphql:\"sceneUpdate\"" - SceneDestroy bool "json:\"sceneDestroy\" graphql:\"sceneDestroy\"" - PerformerCreate *Performer "json:\"performerCreate\" graphql:\"performerCreate\"" - PerformerUpdate *Performer "json:\"performerUpdate\" graphql:\"performerUpdate\"" - PerformerDestroy bool "json:\"performerDestroy\" graphql:\"performerDestroy\"" - StudioCreate *Studio "json:\"studioCreate\" graphql:\"studioCreate\"" - StudioUpdate *Studio "json:\"studioUpdate\" graphql:\"studioUpdate\"" - StudioDestroy bool "json:\"studioDestroy\" graphql:\"studioDestroy\"" - TagCreate *Tag "json:\"tagCreate\" graphql:\"tagCreate\"" - TagUpdate *Tag "json:\"tagUpdate\" graphql:\"tagUpdate\"" - TagDestroy bool "json:\"tagDestroy\" graphql:\"tagDestroy\"" - UserCreate *User "json:\"userCreate\" graphql:\"userCreate\"" - UserUpdate *User "json:\"userUpdate\" graphql:\"userUpdate\"" - UserDestroy bool "json:\"userDestroy\" graphql:\"userDestroy\"" - ImageCreate *Image "json:\"imageCreate\" graphql:\"imageCreate\"" - ImageDestroy bool "json:\"imageDestroy\" graphql:\"imageDestroy\"" - NewUser *string "json:\"newUser\" graphql:\"newUser\"" - ActivateNewUser *User "json:\"activateNewUser\" graphql:\"activateNewUser\"" - GenerateInviteCode string "json:\"generateInviteCode\" graphql:\"generateInviteCode\"" - RescindInviteCode bool "json:\"rescindInviteCode\" graphql:\"rescindInviteCode\"" - GrantInvite int "json:\"grantInvite\" graphql:\"grantInvite\"" - RevokeInvite int "json:\"revokeInvite\" graphql:\"revokeInvite\"" - TagCategoryCreate *TagCategory "json:\"tagCategoryCreate\" graphql:\"tagCategoryCreate\"" - TagCategoryUpdate *TagCategory "json:\"tagCategoryUpdate\" graphql:\"tagCategoryUpdate\"" - TagCategoryDestroy bool "json:\"tagCategoryDestroy\" graphql:\"tagCategoryDestroy\"" - RegenerateAPIKey string "json:\"regenerateAPIKey\" graphql:\"regenerateAPIKey\"" - ResetPassword bool "json:\"resetPassword\" graphql:\"resetPassword\"" - ChangePassword bool "json:\"changePassword\" graphql:\"changePassword\"" - SceneEdit Edit "json:\"sceneEdit\" graphql:\"sceneEdit\"" - PerformerEdit Edit "json:\"performerEdit\" graphql:\"performerEdit\"" - StudioEdit Edit "json:\"studioEdit\" graphql:\"studioEdit\"" - TagEdit Edit "json:\"tagEdit\" graphql:\"tagEdit\"" - EditVote Edit "json:\"editVote\" graphql:\"editVote\"" - EditComment Edit "json:\"editComment\" graphql:\"editComment\"" - ApplyEdit Edit "json:\"applyEdit\" graphql:\"applyEdit\"" - CancelEdit Edit "json:\"cancelEdit\" graphql:\"cancelEdit\"" - SubmitFingerprint bool "json:\"submitFingerprint\" graphql:\"submitFingerprint\"" + SceneCreate *Scene "json:\"sceneCreate\" graphql:\"sceneCreate\"" + SceneUpdate *Scene "json:\"sceneUpdate\" graphql:\"sceneUpdate\"" + SceneDestroy bool "json:\"sceneDestroy\" graphql:\"sceneDestroy\"" + PerformerCreate *Performer "json:\"performerCreate\" graphql:\"performerCreate\"" + PerformerUpdate *Performer "json:\"performerUpdate\" graphql:\"performerUpdate\"" + PerformerDestroy bool "json:\"performerDestroy\" graphql:\"performerDestroy\"" + StudioCreate *Studio "json:\"studioCreate\" graphql:\"studioCreate\"" + StudioUpdate *Studio "json:\"studioUpdate\" graphql:\"studioUpdate\"" + StudioDestroy bool "json:\"studioDestroy\" graphql:\"studioDestroy\"" + TagCreate *Tag "json:\"tagCreate\" graphql:\"tagCreate\"" + TagUpdate *Tag "json:\"tagUpdate\" graphql:\"tagUpdate\"" + TagDestroy bool "json:\"tagDestroy\" graphql:\"tagDestroy\"" + UserCreate *User "json:\"userCreate\" graphql:\"userCreate\"" + UserUpdate *User "json:\"userUpdate\" graphql:\"userUpdate\"" + UserDestroy bool "json:\"userDestroy\" graphql:\"userDestroy\"" + ImageCreate *Image "json:\"imageCreate\" graphql:\"imageCreate\"" + ImageDestroy bool "json:\"imageDestroy\" graphql:\"imageDestroy\"" + NewUser *string "json:\"newUser\" graphql:\"newUser\"" + ActivateNewUser *User "json:\"activateNewUser\" graphql:\"activateNewUser\"" + GenerateInviteCode *string "json:\"generateInviteCode\" graphql:\"generateInviteCode\"" + RescindInviteCode bool "json:\"rescindInviteCode\" graphql:\"rescindInviteCode\"" + GrantInvite int "json:\"grantInvite\" graphql:\"grantInvite\"" + RevokeInvite int "json:\"revokeInvite\" graphql:\"revokeInvite\"" + TagCategoryCreate *TagCategory "json:\"tagCategoryCreate\" graphql:\"tagCategoryCreate\"" + TagCategoryUpdate *TagCategory "json:\"tagCategoryUpdate\" graphql:\"tagCategoryUpdate\"" + TagCategoryDestroy bool "json:\"tagCategoryDestroy\" graphql:\"tagCategoryDestroy\"" + SiteCreate *Site "json:\"siteCreate\" graphql:\"siteCreate\"" + SiteUpdate *Site "json:\"siteUpdate\" graphql:\"siteUpdate\"" + SiteDestroy bool "json:\"siteDestroy\" graphql:\"siteDestroy\"" + RegenerateAPIKey string "json:\"regenerateAPIKey\" graphql:\"regenerateAPIKey\"" + ResetPassword bool "json:\"resetPassword\" graphql:\"resetPassword\"" + ChangePassword bool "json:\"changePassword\" graphql:\"changePassword\"" + SceneEdit Edit "json:\"sceneEdit\" graphql:\"sceneEdit\"" + PerformerEdit Edit "json:\"performerEdit\" graphql:\"performerEdit\"" + StudioEdit Edit "json:\"studioEdit\" graphql:\"studioEdit\"" + TagEdit Edit "json:\"tagEdit\" graphql:\"tagEdit\"" + EditVote Edit "json:\"editVote\" graphql:\"editVote\"" + EditComment Edit "json:\"editComment\" graphql:\"editComment\"" + ApplyEdit Edit "json:\"applyEdit\" graphql:\"applyEdit\"" + CancelEdit Edit "json:\"cancelEdit\" graphql:\"cancelEdit\"" + SubmitFingerprint bool "json:\"submitFingerprint\" graphql:\"submitFingerprint\"" + SubmitSceneDraft DraftSubmissionStatus "json:\"submitSceneDraft\" graphql:\"submitSceneDraft\"" + SubmitPerformerDraft DraftSubmissionStatus "json:\"submitPerformerDraft\" graphql:\"submitPerformerDraft\"" + DestroyDraft bool "json:\"destroyDraft\" graphql:\"destroyDraft\"" } type URLFragment struct { URL string "json:\"url\" graphql:\"url\"" @@ -180,60 +191,43 @@ type FindSceneByID struct { type SubmitFingerprintPayload struct { SubmitFingerprint bool "json:\"submitFingerprint\" graphql:\"submitFingerprint\"" } +type Me struct { + Me *struct { + Name string "json:\"name\" graphql:\"name\"" + } "json:\"me\" graphql:\"me\"" +} +type SubmitSceneDraftPayload struct { + SubmitSceneDraft struct { + ID *string "json:\"id\" graphql:\"id\"" + } "json:\"submitSceneDraft\" graphql:\"submitSceneDraft\"" +} +type SubmitPerformerDraftPayload struct { + SubmitPerformerDraft struct { + ID *string "json:\"id\" graphql:\"id\"" + } "json:\"submitPerformerDraft\" graphql:\"submitPerformerDraft\"" +} const FindSceneByFingerprintQuery = `query FindSceneByFingerprint ($fingerprint: FingerprintQueryInput!) { findSceneByFingerprint(fingerprint: $fingerprint) { ... SceneFragment } } -fragment FuzzyDateFragment on FuzzyDate { - date - accuracy +fragment BodyModificationFragment on BodyModification { + location + description } -fragment SceneFragment on Scene { - id - title - details +fragment FingerprintFragment on Fingerprint { + algorithm + hash duration - date - urls { - ... URLFragment - } - images { - ... ImageFragment - } - studio { - ... StudioFragment - } - tags { - ... TagFragment - } - performers { - ... PerformerAppearanceFragment - } - fingerprints { - ... FingerprintFragment - } } fragment URLFragment on URL { url type } -fragment StudioFragment on Studio { +fragment TagFragment on Tag { name id - urls { - ... URLFragment - } - images { - ... ImageFragment - } -} -fragment PerformerAppearanceFragment on PerformerAppearance { - as - performer { - ... PerformerFragment - } } fragment PerformerFragment on Performer { id @@ -269,15 +263,9 @@ fragment PerformerFragment on Performer { ... BodyModificationFragment } } -fragment ImageFragment on Image { - id - url - width - height -} -fragment TagFragment on Tag { - name - id +fragment FuzzyDateFragment on FuzzyDate { + date + accuracy } fragment MeasurementsFragment on Measurements { band_size @@ -285,14 +273,52 @@ fragment MeasurementsFragment on Measurements { waist hip } -fragment BodyModificationFragment on BodyModification { - location - description -} -fragment FingerprintFragment on Fingerprint { - algorithm - hash +fragment SceneFragment on Scene { + id + title + details duration + date + urls { + ... URLFragment + } + images { + ... ImageFragment + } + studio { + ... StudioFragment + } + tags { + ... TagFragment + } + performers { + ... PerformerAppearanceFragment + } + fingerprints { + ... FingerprintFragment + } +} +fragment ImageFragment on Image { + id + url + width + height +} +fragment StudioFragment on Studio { + name + id + urls { + ... URLFragment + } + images { + ... ImageFragment + } +} +fragment PerformerAppearanceFragment on PerformerAppearance { + as + performer { + ... PerformerFragment + } } ` @@ -314,12 +340,6 @@ const FindScenesByFullFingerprintsQuery = `query FindScenesByFullFingerprints ($ ... SceneFragment } } -fragment ImageFragment on Image { - id - url - width - height -} fragment StudioFragment on Studio { name id @@ -330,10 +350,6 @@ fragment StudioFragment on Studio { ... ImageFragment } } -fragment TagFragment on Tag { - name - id -} fragment PerformerFragment on Performer { id name @@ -378,6 +394,35 @@ fragment MeasurementsFragment on Measurements { waist hip } +fragment BodyModificationFragment on BodyModification { + location + description +} +fragment ImageFragment on Image { + id + url + width + height +} +fragment URLFragment on URL { + url + type +} +fragment TagFragment on Tag { + name + id +} +fragment PerformerAppearanceFragment on PerformerAppearance { + as + performer { + ... PerformerFragment + } +} +fragment FingerprintFragment on Fingerprint { + algorithm + hash + duration +} fragment SceneFragment on Scene { id title @@ -403,25 +448,6 @@ fragment SceneFragment on Scene { ... FingerprintFragment } } -fragment PerformerAppearanceFragment on PerformerAppearance { - as - performer { - ... PerformerFragment - } -} -fragment BodyModificationFragment on BodyModification { - location - description -} -fragment FingerprintFragment on Fingerprint { - algorithm - hash - duration -} -fragment URLFragment on URL { - url - type -} ` func (c *Client) FindScenesByFullFingerprints(ctx context.Context, fingerprints []*FingerprintQueryInput, httpRequestOptions ...client.HTTPRequestOption) (*FindScenesByFullFingerprints, error) { @@ -442,6 +468,11 @@ const SearchSceneQuery = `query SearchScene ($term: String!) { ... SceneFragment } } +fragment FingerprintFragment on Fingerprint { + algorithm + hash + duration +} fragment URLFragment on URL { url type @@ -456,14 +487,21 @@ fragment TagFragment on Tag { name id } -fragment FuzzyDateFragment on FuzzyDate { - date - accuracy +fragment PerformerAppearanceFragment on PerformerAppearance { + as + performer { + ... PerformerFragment + } } -fragment FingerprintFragment on Fingerprint { - algorithm - hash - duration +fragment MeasurementsFragment on Measurements { + band_size + cup_size + waist + hip +} +fragment BodyModificationFragment on BodyModification { + location + description } fragment SceneFragment on Scene { id @@ -500,12 +538,6 @@ fragment StudioFragment on Studio { ... ImageFragment } } -fragment PerformerAppearanceFragment on PerformerAppearance { - as - performer { - ... PerformerFragment - } -} fragment PerformerFragment on Performer { id name @@ -540,15 +572,9 @@ fragment PerformerFragment on Performer { ... BodyModificationFragment } } -fragment MeasurementsFragment on Measurements { - band_size - cup_size - waist - hip -} -fragment BodyModificationFragment on BodyModification { - location - description +fragment FuzzyDateFragment on FuzzyDate { + date + accuracy } ` @@ -570,30 +596,6 @@ const SearchPerformerQuery = `query SearchPerformer ($term: String!) { ... PerformerFragment } } -fragment URLFragment on URL { - url - type -} -fragment ImageFragment on Image { - id - url - width - height -} -fragment FuzzyDateFragment on FuzzyDate { - date - accuracy -} -fragment MeasurementsFragment on Measurements { - band_size - cup_size - waist - hip -} -fragment BodyModificationFragment on BodyModification { - location - description -} fragment PerformerFragment on Performer { id name @@ -628,6 +630,30 @@ fragment PerformerFragment on Performer { ... BodyModificationFragment } } +fragment URLFragment on URL { + url + type +} +fragment ImageFragment on Image { + id + url + width + height +} +fragment FuzzyDateFragment on FuzzyDate { + date + accuracy +} +fragment MeasurementsFragment on Measurements { + band_size + cup_size + waist + hip +} +fragment BodyModificationFragment on BodyModification { + location + description +} ` func (c *Client) SearchPerformer(ctx context.Context, term string, httpRequestOptions ...client.HTTPRequestOption) (*SearchPerformer, error) { @@ -648,40 +674,6 @@ const FindPerformerByIDQuery = `query FindPerformerByID ($id: ID!) { ... PerformerFragment } } -fragment PerformerFragment on Performer { - id - name - disambiguation - aliases - gender - merged_ids - urls { - ... URLFragment - } - images { - ... ImageFragment - } - birthdate { - ... FuzzyDateFragment - } - ethnicity - country - eye_color - hair_color - height - measurements { - ... MeasurementsFragment - } - breast_type - career_start_year - career_end_year - tattoos { - ... BodyModificationFragment - } - piercings { - ... BodyModificationFragment - } -} fragment URLFragment on URL { url type @@ -706,46 +698,6 @@ fragment BodyModificationFragment on BodyModification { location description } -` - -func (c *Client) FindPerformerByID(ctx context.Context, id string, httpRequestOptions ...client.HTTPRequestOption) (*FindPerformerByID, error) { - vars := map[string]interface{}{ - "id": id, - } - - var res FindPerformerByID - if err := c.Client.Post(ctx, FindPerformerByIDQuery, &res, vars, httpRequestOptions...); err != nil { - return nil, err - } - - return &res, nil -} - -const FindSceneByIDQuery = `query FindSceneByID ($id: ID!) { - findScene(id: $id) { - ... SceneFragment - } -} -fragment ImageFragment on Image { - id - url - width - height -} -fragment StudioFragment on Studio { - name - id - urls { - ... URLFragment - } - images { - ... ImageFragment - } -} -fragment TagFragment on Tag { - name - id -} fragment PerformerFragment on Performer { id name @@ -780,6 +732,34 @@ fragment PerformerFragment on Performer { ... BodyModificationFragment } } +` + +func (c *Client) FindPerformerByID(ctx context.Context, id string, httpRequestOptions ...client.HTTPRequestOption) (*FindPerformerByID, error) { + vars := map[string]interface{}{ + "id": id, + } + + var res FindPerformerByID + if err := c.Client.Post(ctx, FindPerformerByIDQuery, &res, vars, httpRequestOptions...); err != nil { + return nil, err + } + + return &res, nil +} + +const FindSceneByIDQuery = `query FindSceneByID ($id: ID!) { + findScene(id: $id) { + ... SceneFragment + } +} +fragment TagFragment on Tag { + name + id +} +fragment FuzzyDateFragment on FuzzyDate { + date + accuracy +} fragment MeasurementsFragment on Measurements { band_size cup_size @@ -820,9 +800,21 @@ fragment URLFragment on URL { url type } -fragment BodyModificationFragment on BodyModification { - location - description +fragment ImageFragment on Image { + id + url + width + height +} +fragment StudioFragment on Studio { + name + id + urls { + ... URLFragment + } + images { + ... ImageFragment + } } fragment PerformerAppearanceFragment on PerformerAppearance { as @@ -830,9 +822,43 @@ fragment PerformerAppearanceFragment on PerformerAppearance { ... PerformerFragment } } -fragment FuzzyDateFragment on FuzzyDate { - date - accuracy +fragment PerformerFragment on Performer { + id + name + disambiguation + aliases + gender + merged_ids + urls { + ... URLFragment + } + images { + ... ImageFragment + } + birthdate { + ... FuzzyDateFragment + } + ethnicity + country + eye_color + hair_color + height + measurements { + ... MeasurementsFragment + } + breast_type + career_start_year + career_end_year + tattoos { + ... BodyModificationFragment + } + piercings { + ... BodyModificationFragment + } +} +fragment BodyModificationFragment on BodyModification { + location + description } ` @@ -866,3 +892,61 @@ func (c *Client) SubmitFingerprint(ctx context.Context, input FingerprintSubmiss return &res, nil } + +const MeQuery = `query Me { + me { + name + } +} +` + +func (c *Client) Me(ctx context.Context, httpRequestOptions ...client.HTTPRequestOption) (*Me, error) { + vars := map[string]interface{}{} + + var res Me + if err := c.Client.Post(ctx, MeQuery, &res, vars, httpRequestOptions...); err != nil { + return nil, err + } + + return &res, nil +} + +const SubmitSceneDraftQuery = `mutation SubmitSceneDraft ($input: SceneDraftInput!) { + submitSceneDraft(input: $input) { + id + } +} +` + +func (c *Client) SubmitSceneDraft(ctx context.Context, input SceneDraftInput, httpRequestOptions ...client.HTTPRequestOption) (*SubmitSceneDraftPayload, error) { + vars := map[string]interface{}{ + "input": input, + } + + var res SubmitSceneDraftPayload + if err := c.Client.Post(ctx, SubmitSceneDraftQuery, &res, vars, httpRequestOptions...); err != nil { + return nil, err + } + + return &res, nil +} + +const SubmitPerformerDraftQuery = `mutation SubmitPerformerDraft ($input: PerformerDraftInput!) { + submitPerformerDraft(input: $input) { + id + } +} +` + +func (c *Client) SubmitPerformerDraft(ctx context.Context, input PerformerDraftInput, httpRequestOptions ...client.HTTPRequestOption) (*SubmitPerformerDraftPayload, error) { + vars := map[string]interface{}{ + "input": input, + } + + var res SubmitPerformerDraftPayload + if err := c.Client.Post(ctx, SubmitPerformerDraftQuery, &res, vars, httpRequestOptions...); err != nil { + return nil, err + } + + return &res, nil +} diff --git a/pkg/scraper/stashbox/graphql/generated_models.go b/pkg/scraper/stashbox/graphql/generated_models.go index 932acbe6b..bdbdfca6e 100644 --- a/pkg/scraper/stashbox/graphql/generated_models.go +++ b/pkg/scraper/stashbox/graphql/generated_models.go @@ -11,6 +11,10 @@ import ( "github.com/99designs/gqlgen/graphql" ) +type DraftData interface { + IsDraftData() +} + type EditDetails interface { IsEditDetails() } @@ -19,6 +23,18 @@ type EditTarget interface { IsEditTarget() } +type SceneDraftPerformer interface { + IsSceneDraftPerformer() +} + +type SceneDraftStudio interface { + IsSceneDraftStudio() +} + +type SceneDraftTag interface { + IsSceneDraftTag() +} + type ActivateNewUserInput struct { Name string `json:"name"` Email string `json:"email"` @@ -60,6 +76,37 @@ type DateCriterionInput struct { Modifier CriterionModifier `json:"modifier"` } +type Draft struct { + ID string `json:"id"` + Created time.Time `json:"created"` + Expires time.Time `json:"expires"` + Data DraftData `json:"data"` +} + +type DraftEntity struct { + Name string `json:"name"` + ID *string `json:"id"` +} + +func (DraftEntity) IsSceneDraftPerformer() {} +func (DraftEntity) IsSceneDraftStudio() {} +func (DraftEntity) IsSceneDraftTag() {} + +type DraftEntityInput struct { + Name string `json:"name"` + ID *string `json:"id"` +} + +type DraftFingerprint struct { + Hash string `json:"hash"` + Algorithm FingerprintAlgorithm `json:"algorithm"` + Duration int `json:"duration"` +} + +type DraftSubmissionStatus struct { + ID *string `json:"id"` +} + type Edit struct { ID string `json:"id"` User *User `json:"user"` @@ -75,13 +122,15 @@ type Edit struct { // Entity specific options Options *PerformerEditOptions `json:"options"` Comments []*EditComment `json:"comments"` - Votes []*VoteComment `json:"votes"` + Votes []*EditVote `json:"votes"` // = Accepted - Rejected - VoteCount int `json:"vote_count"` - Status VoteStatusEnum `json:"status"` - Applied bool `json:"applied"` - Created time.Time `json:"created"` - Updated time.Time `json:"updated"` + VoteCount int `json:"vote_count"` + // Is the edit considered destructive. + Destructive bool `json:"destructive"` + Status VoteStatusEnum `json:"status"` + Applied bool `json:"applied"` + Created time.Time `json:"created"` + Updated time.Time `json:"updated"` } type EditComment struct { @@ -123,10 +172,15 @@ type EditInput struct { Comment *string `json:"comment"` } +type EditVote struct { + User *User `json:"user"` + Date time.Time `json:"date"` + Vote VoteTypeEnum `json:"vote"` +} + type EditVoteInput struct { - ID string `json:"id"` - Comment *string `json:"comment"` - Type VoteTypeEnum `json:"type"` + ID string `json:"id"` + Vote VoteTypeEnum `json:"vote"` } type EyeColorCriterionInput struct { @@ -135,24 +189,30 @@ type EyeColorCriterionInput struct { } type Fingerprint struct { - Hash string `json:"hash"` - Algorithm FingerprintAlgorithm `json:"algorithm"` - Duration int `json:"duration"` - Submissions int `json:"submissions"` - Created time.Time `json:"created"` - Updated time.Time `json:"updated"` + Hash string `json:"hash"` + Algorithm FingerprintAlgorithm `json:"algorithm"` + Duration int `json:"duration"` + Submissions int `json:"submissions"` + Created time.Time `json:"created"` + Updated time.Time `json:"updated"` + UserSubmitted bool `json:"user_submitted"` } type FingerprintEditInput struct { - Hash string `json:"hash"` - Algorithm FingerprintAlgorithm `json:"algorithm"` - Duration int `json:"duration"` - Submissions int `json:"submissions"` - Created time.Time `json:"created"` - Updated time.Time `json:"updated"` + UserIds []string `json:"user_ids"` + Hash string `json:"hash"` + Algorithm FingerprintAlgorithm `json:"algorithm"` + Duration int `json:"duration"` + Created time.Time `json:"created"` + // @deprecated(reason: "unused") + Submissions *int `json:"submissions"` + // @deprecated(reason: "unused") + Updated *time.Time `json:"updated"` } type FingerprintInput struct { + // assumes current user if omitted. Ignored for non-modify Users + UserIds []string `json:"user_ids"` Hash string `json:"hash"` Algorithm FingerprintAlgorithm `json:"algorithm"` Duration int `json:"duration"` @@ -166,6 +226,7 @@ type FingerprintQueryInput struct { type FingerprintSubmission struct { SceneID string `json:"scene_id"` Fingerprint *FingerprintInput `json:"fingerprint"` + Unmatch *bool `json:"unmatch"` } type FuzzyDate struct { @@ -238,6 +299,11 @@ type MultiIDCriterionInput struct { Modifier CriterionModifier `json:"modifier"` } +type MultiStringCriterionInput struct { + Value []string `json:"value"` + Modifier CriterionModifier `json:"modifier"` +} + type NewUserInput struct { Email string `json:"email"` InviteKey *string `json:"invite_key"` @@ -272,7 +338,8 @@ type Performer struct { Studios []*PerformerStudio `json:"studios"` } -func (Performer) IsEditTarget() {} +func (Performer) IsEditTarget() {} +func (Performer) IsSceneDraftPerformer() {} type PerformerAppearance struct { Performer *Performer `json:"performer"` @@ -305,12 +372,55 @@ type PerformerCreateInput struct { Tattoos []*BodyModificationInput `json:"tattoos"` Piercings []*BodyModificationInput `json:"piercings"` ImageIds []string `json:"image_ids"` + DraftID *string `json:"draft_id"` } type PerformerDestroyInput struct { ID string `json:"id"` } +type PerformerDraft struct { + Name string `json:"name"` + Aliases *string `json:"aliases"` + Gender *string `json:"gender"` + Birthdate *string `json:"birthdate"` + Urls []string `json:"urls"` + Ethnicity *string `json:"ethnicity"` + Country *string `json:"country"` + EyeColor *string `json:"eye_color"` + HairColor *string `json:"hair_color"` + Height *string `json:"height"` + Measurements *string `json:"measurements"` + BreastType *string `json:"breast_type"` + Tattoos *string `json:"tattoos"` + Piercings *string `json:"piercings"` + CareerStartYear *int `json:"career_start_year"` + CareerEndYear *int `json:"career_end_year"` + Image *Image `json:"image"` +} + +func (PerformerDraft) IsDraftData() {} + +type PerformerDraftInput struct { + Name string `json:"name"` + Aliases *string `json:"aliases"` + Gender *string `json:"gender"` + Birthdate *string `json:"birthdate"` + Urls []string `json:"urls"` + Ethnicity *string `json:"ethnicity"` + Country *string `json:"country"` + EyeColor *string `json:"eye_color"` + HairColor *string `json:"hair_color"` + Height *string `json:"height"` + Measurements *string `json:"measurements"` + BreastType *string `json:"breast_type"` + Tattoos *string `json:"tattoos"` + Piercings *string `json:"piercings"` + CareerStartYear *int `json:"career_start_year"` + CareerEndYear *int `json:"career_end_year"` + Image *graphql.Upload `json:"image"` +} + type PerformerEdit struct { Name *string `json:"name"` Disambiguation *string `json:"disambiguation"` @@ -340,6 +450,7 @@ type PerformerEdit struct { RemovedPiercings []*BodyModification `json:"removed_piercings"` AddedImages []*Image `json:"added_images"` RemovedImages []*Image `json:"removed_images"` + DraftID *string `json:"draft_id"` } func (PerformerEdit) IsEditDetails() {} @@ -363,6 +474,7 @@ type PerformerEditDetailsInput struct { Tattoos []*BodyModificationInput `json:"tattoos"` Piercings []*BodyModificationInput `json:"piercings"` ImageIds []string `json:"image_ids"` + DraftID *string `json:"draft_id"` } type PerformerEditInput struct { @@ -459,6 +571,11 @@ type QueryScenesResultType struct { Scenes []*Scene `json:"scenes"` } +type QuerySitesResultType struct { + Count int `json:"count"` + Sites []*Site `json:"sites"` +} + type QuerySpec struct { Page *int `json:"page"` PerPage *int `json:"per_page"` @@ -514,6 +631,7 @@ type Scene struct { Duration *int `json:"duration"` Director *string `json:"director"` Deleted bool `json:"deleted"` + Edits []*Edit `json:"edits"` } func (Scene) IsEditTarget() {} @@ -536,13 +654,39 @@ type SceneDestroyInput struct { ID string `json:"id"` } +type SceneDraft struct { + Title *string `json:"title"` + Details *string `json:"details"` + URL *URL `json:"url"` + Date *string `json:"date"` + Studio SceneDraftStudio `json:"studio"` + Performers []SceneDraftPerformer `json:"performers"` + Tags []SceneDraftTag `json:"tags"` + Image *Image `json:"image"` + Fingerprints []*DraftFingerprint `json:"fingerprints"` +} + +func (SceneDraft) IsDraftData() {} + +type SceneDraftInput struct { + Title *string `json:"title"` + Details *string `json:"details"` + URL *string `json:"url"` + Date *string `json:"date"` + Studio *DraftEntityInput `json:"studio"` + Performers []*DraftEntityInput `json:"performers"` + Tags []*DraftEntityInput `json:"tags"` + Image *graphql.Upload `json:"image"` + Fingerprints []*FingerprintInput `json:"fingerprints"` +} + type SceneEdit struct { Title *string `json:"title"` Details *string `json:"details"` AddedUrls []*URL `json:"added_urls"` RemovedUrls []*URL `json:"removed_urls"` Date *string `json:"date"` - StudioID *string `json:"studio_id"` + Studio *Studio `json:"studio"` // Added or modified performer appearance entries AddedPerformers []*PerformerAppearance `json:"added_performers"` RemovedPerformers []*PerformerAppearance `json:"removed_performers"` @@ -554,6 +698,7 @@ type SceneEdit struct { RemovedFingerprints []*Fingerprint `json:"removed_fingerprints"` Duration *int `json:"duration"` Director *string `json:"director"` + DraftID *string `json:"draft_id"` } func (SceneEdit) IsEditDetails() {} @@ -567,9 +712,10 @@ type SceneEditDetailsInput struct { Performers []*PerformerAppearanceInput `json:"performers"` TagIds []string `json:"tag_ids"` ImageIds []string `json:"image_ids"` - Fingerprints []*FingerprintEditInput `json:"fingerprints"` Duration *int `json:"duration"` Director *string `json:"director"` + Fingerprints []*FingerprintInput `json:"fingerprints"` + DraftID *string `json:"draft_id"` } type SceneEditInput struct { @@ -599,7 +745,7 @@ type SceneFilterType struct { // Filter to include scenes with performer appearing as alias Alias *StringCriterionInput `json:"alias"` // Filter to only include scenes with these fingerprints - Fingerprints *MultiIDCriterionInput `json:"fingerprints"` + Fingerprints *MultiStringCriterionInput `json:"fingerprints"` } type SceneUpdateInput struct { @@ -617,6 +763,50 @@ type SceneUpdateInput struct { Director *string `json:"director"` } +type Site struct { + ID string `json:"id"` + Name string `json:"name"` + Description *string `json:"description"` + URL *string `json:"url"` + Regex *string `json:"regex"` + ValidTypes []ValidSiteTypeEnum `json:"valid_types"` + Icon string `json:"icon"` + Created time.Time `json:"created"` + Updated time.Time `json:"updated"` +} + +type SiteCreateInput struct { + Name string `json:"name"` + Description *string `json:"description"` + URL *string `json:"url"` + Regex *string `json:"regex"` + ValidTypes []ValidSiteTypeEnum `json:"valid_types"` +} + +type SiteDestroyInput struct { + ID string `json:"id"` +} + +type SiteUpdateInput struct { + ID string `json:"id"` + Name string `json:"name"` + Description *string `json:"description"` + URL *string `json:"url"` + Regex *string `json:"regex"` + ValidTypes []ValidSiteTypeEnum `json:"valid_types"` +} + +type StashBoxConfig struct { + HostURL string `json:"host_url"` + RequireInvite bool `json:"require_invite"` + RequireActivation bool `json:"require_activation"` + VotePromotionThreshold *int `json:"vote_promotion_threshold"` + VoteApplicationThreshold int `json:"vote_application_threshold"` + VotingPeriod int `json:"voting_period"` + MinDestructiveVotingPeriod int `json:"min_destructive_voting_period"` + VoteCronInterval string `json:"vote_cron_interval"` +} + type StringCriterionInput struct { Value string `json:"value"` Modifier CriterionModifier `json:"modifier"` @@ -632,14 +822,14 @@ type Studio struct { Deleted bool `json:"deleted"` } -func (Studio) IsEditTarget() {} +func (Studio) IsEditTarget() {} +func (Studio) IsSceneDraftStudio() {} type StudioCreateInput struct { - Name string `json:"name"` - Urls []*URLInput `json:"urls"` - ParentID *string `json:"parent_id"` - ChildStudioIds []string `json:"child_studio_ids"` - ImageIds []string `json:"image_ids"` + Name string `json:"name"` + Urls []*URLInput `json:"urls"` + ParentID *string `json:"parent_id"` + ImageIds []string `json:"image_ids"` } type StudioDestroyInput struct { @@ -649,23 +839,20 @@ type StudioDestroyInput struct { type StudioEdit struct { Name *string `json:"name"` // Added and modified URLs - AddedUrls []*URL `json:"added_urls"` - RemovedUrls []*URL `json:"removed_urls"` - Parent *Studio `json:"parent"` - AddedChildStudios []*Studio `json:"added_child_studios"` - RemovedChildStudios []*Studio `json:"removed_child_studios"` - AddedImages []*Image `json:"added_images"` - RemovedImages []*Image `json:"removed_images"` + AddedUrls []*URL `json:"added_urls"` + RemovedUrls []*URL `json:"removed_urls"` + Parent *Studio `json:"parent"` + AddedImages []*Image `json:"added_images"` + RemovedImages []*Image `json:"removed_images"` } func (StudioEdit) IsEditDetails() {} type StudioEditDetailsInput struct { - Name *string `json:"name"` - Urls []*URLInput `json:"urls"` - ParentID *string `json:"parent_id"` - ChildStudioIds []string `json:"child_studio_ids"` - ImageIds []string `json:"image_ids"` + Name *string `json:"name"` + Urls []*URLInput `json:"urls"` + ParentID *string `json:"parent_id"` + ImageIds []string `json:"image_ids"` } type StudioEditInput struct { @@ -686,12 +873,11 @@ type StudioFilterType struct { } type StudioUpdateInput struct { - ID string `json:"id"` - Name *string `json:"name"` - Urls []*URLInput `json:"urls"` - ParentID *string `json:"parent_id"` - ChildStudioIds []string `json:"child_studio_ids"` - ImageIds []string `json:"image_ids"` + ID string `json:"id"` + Name *string `json:"name"` + Urls []*URLInput `json:"urls"` + ParentID *string `json:"parent_id"` + ImageIds []string `json:"image_ids"` } type Tag struct { @@ -704,7 +890,8 @@ type Tag struct { Category *TagCategory `json:"category"` } -func (Tag) IsEditTarget() {} +func (Tag) IsEditTarget() {} +func (Tag) IsSceneDraftTag() {} type TagCategory struct { ID string `json:"id"` @@ -742,11 +929,11 @@ type TagDestroyInput struct { } type TagEdit struct { - Name *string `json:"name"` - Description *string `json:"description"` - AddedAliases []string `json:"added_aliases"` - RemovedAliases []string `json:"removed_aliases"` - CategoryID *string `json:"category_id"` + Name *string `json:"name"` + Description *string `json:"description"` + AddedAliases []string `json:"added_aliases"` + RemovedAliases []string `json:"removed_aliases"` + Category *TagCategory `json:"category"` } func (TagEdit) IsEditDetails() {} @@ -786,11 +973,12 @@ type TagUpdateInput struct { type URL struct { URL string `json:"url"` Type string `json:"type"` + Site *Site `json:"site"` } type URLInput struct { - URL string `json:"url"` - Type string `json:"type"` + URL string `json:"url"` + SiteID string `json:"site_id"` } type User struct { @@ -801,12 +989,11 @@ type User struct { // Should not be visible to other users Email *string `json:"email"` // Should not be visible to other users - APIKey *string `json:"api_key"` - SuccessfulEdits int `json:"successful_edits"` - UnsuccessfulEdits int `json:"unsuccessful_edits"` - SuccessfulVotes int `json:"successful_votes"` - // Votes on unsuccessful edits - UnsuccessfulVotes int `json:"unsuccessful_votes"` + APIKey *string `json:"api_key"` + // Vote counts by type + VoteCount *UserVoteCount `json:"vote_count"` + // Edit counts by status + EditCount *UserEditCount `json:"edit_count"` // Calls to the API from this user over a configurable time period APICalls int `json:"api_calls"` InvitedBy *User `json:"invited_by"` @@ -834,6 +1021,16 @@ type UserDestroyInput struct { ID string `json:"id"` } +type UserEditCount struct { + Accepted int `json:"accepted"` + Rejected int `json:"rejected"` + Pending int `json:"pending"` + ImmediateAccepted int `json:"immediate_accepted"` + ImmediateRejected int `json:"immediate_rejected"` + Failed int `json:"failed"` + Canceled int `json:"canceled"` +} + type UserFilterType struct { // Filter to search user name - assumes like query unless quoted Name *string `json:"name"` @@ -866,19 +1063,21 @@ type UserUpdateInput struct { Email *string `json:"email"` } +type UserVoteCount struct { + Abstain int `json:"abstain"` + Accept int `json:"accept"` + Reject int `json:"reject"` + ImmediateAccept int `json:"immediate_accept"` + ImmediateReject int `json:"immediate_reject"` +} + type Version struct { Hash string `json:"hash"` BuildTime string `json:"build_time"` + BuildType string `json:"build_type"` Version string `json:"version"` } -type VoteComment struct { - User *User `json:"user"` - Date *string `json:"date"` - Comment *string `json:"comment"` - Type *VoteTypeEnum `json:"type"` -} - type BreastTypeEnum string const ( @@ -1435,6 +1634,7 @@ const ( RoleEnumInvite RoleEnum = "INVITE" // May grant and rescind invite tokens and resind invite keys RoleEnumManageInvites RoleEnum = "MANAGE_INVITES" + RoleEnumBot RoleEnum = "BOT" ) var AllRoleEnum = []RoleEnum{ @@ -1445,11 +1645,12 @@ var AllRoleEnum = []RoleEnum{ RoleEnumAdmin, RoleEnumInvite, RoleEnumManageInvites, + RoleEnumBot, } func (e RoleEnum) IsValid() bool { switch e { - case RoleEnumRead, RoleEnumVote, RoleEnumEdit, RoleEnumModify, RoleEnumAdmin, RoleEnumInvite, RoleEnumManageInvites: + case RoleEnumRead, RoleEnumVote, RoleEnumEdit, RoleEnumModify, RoleEnumAdmin, RoleEnumInvite, RoleEnumManageInvites, RoleEnumBot: return true } return false @@ -1605,6 +1806,49 @@ func (e TargetTypeEnum) MarshalGQL(w io.Writer) { fmt.Fprint(w, strconv.Quote(e.String())) } +type ValidSiteTypeEnum string + +const ( + ValidSiteTypeEnumPerformer ValidSiteTypeEnum = "PERFORMER" + ValidSiteTypeEnumScene ValidSiteTypeEnum = "SCENE" + ValidSiteTypeEnumStudio ValidSiteTypeEnum = "STUDIO" +) + +var AllValidSiteTypeEnum = []ValidSiteTypeEnum{ + ValidSiteTypeEnumPerformer, + ValidSiteTypeEnumScene, + ValidSiteTypeEnumStudio, +} + +func (e ValidSiteTypeEnum) IsValid() bool { + switch e { + case ValidSiteTypeEnumPerformer, ValidSiteTypeEnumScene, ValidSiteTypeEnumStudio: + return true + } + return false +} + +func (e ValidSiteTypeEnum) String() string { + return string(e) +} + +func (e *ValidSiteTypeEnum) UnmarshalGQL(v interface{}) error { + str, ok := v.(string) + if !ok { + return fmt.Errorf("enums must be strings") + } + + *e = ValidSiteTypeEnum(str) + if !e.IsValid() { + return fmt.Errorf("%s is not a valid ValidSiteTypeEnum", str) + } + return nil +} + +func (e ValidSiteTypeEnum) MarshalGQL(w io.Writer) { + fmt.Fprint(w, strconv.Quote(e.String())) +} + type VoteStatusEnum string const ( @@ -1613,6 +1857,8 @@ const ( VoteStatusEnumPending VoteStatusEnum = "PENDING" VoteStatusEnumImmediateAccepted VoteStatusEnum = "IMMEDIATE_ACCEPTED" VoteStatusEnumImmediateRejected VoteStatusEnum = "IMMEDIATE_REJECTED" + VoteStatusEnumFailed VoteStatusEnum = "FAILED" + VoteStatusEnumCanceled VoteStatusEnum = "CANCELED" ) var AllVoteStatusEnum = []VoteStatusEnum{ @@ -1621,11 +1867,13 @@ var AllVoteStatusEnum = []VoteStatusEnum{ VoteStatusEnumPending, VoteStatusEnumImmediateAccepted, VoteStatusEnumImmediateRejected, + VoteStatusEnumFailed, + VoteStatusEnumCanceled, } func (e VoteStatusEnum) IsValid() bool { switch e { - case VoteStatusEnumAccepted, VoteStatusEnumRejected, VoteStatusEnumPending, VoteStatusEnumImmediateAccepted, VoteStatusEnumImmediateRejected: + case VoteStatusEnumAccepted, VoteStatusEnumRejected, VoteStatusEnumPending, VoteStatusEnumImmediateAccepted, VoteStatusEnumImmediateRejected, VoteStatusEnumFailed, VoteStatusEnumCanceled: return true } return false @@ -1655,7 +1903,7 @@ func (e VoteStatusEnum) MarshalGQL(w io.Writer) { type VoteTypeEnum string const ( - VoteTypeEnumComment VoteTypeEnum = "COMMENT" + VoteTypeEnumAbstain VoteTypeEnum = "ABSTAIN" VoteTypeEnumAccept VoteTypeEnum = "ACCEPT" VoteTypeEnumReject VoteTypeEnum = "REJECT" // Immediately accepts the edit - bypassing the vote @@ -1665,7 +1913,7 @@ const ( ) var AllVoteTypeEnum = []VoteTypeEnum{ - VoteTypeEnumComment, + VoteTypeEnumAbstain, VoteTypeEnumAccept, VoteTypeEnumReject, VoteTypeEnumImmediateAccept, @@ -1674,7 +1922,7 @@ var AllVoteTypeEnum = []VoteTypeEnum{ func (e VoteTypeEnum) IsValid() bool { switch e { - case VoteTypeEnumComment, VoteTypeEnumAccept, VoteTypeEnumReject, VoteTypeEnumImmediateAccept, VoteTypeEnumImmediateReject: + case VoteTypeEnumAbstain, VoteTypeEnumAccept, VoteTypeEnumReject, VoteTypeEnumImmediateAccept, VoteTypeEnumImmediateReject: return true } return false diff --git a/pkg/scraper/stashbox/stash_box.go b/pkg/scraper/stashbox/stash_box.go index 087bce242..0d761a6e9 100644 --- a/pkg/scraper/stashbox/stash_box.go +++ b/pkg/scraper/stashbox/stash_box.go @@ -1,14 +1,20 @@ package stashbox import ( + "bytes" "context" + "encoding/json" "fmt" "io" + "mime/multipart" "net/http" + "os" "strconv" "strings" "github.com/Yamashou/gqlgenc/client" + "github.com/Yamashou/gqlgenc/graphqljson" + "github.com/corona10/goimagehash" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/match" @@ -66,6 +72,18 @@ func (c Client) QueryStashBoxScene(ctx context.Context, queryStr string) ([]*mod return ret, nil } +func phashMatches(hash, other int64) bool { + // HACK - stash-box match distance is configurable. This needs to be fixed on + // the stash-box end. + const stashBoxDistance = 4 + + imageHash := goimagehash.NewImageHash(uint64(hash), goimagehash.PHash) + otherHash := goimagehash.NewImageHash(uint64(other), goimagehash.PHash) + + distance, _ := imageHash.Distance(otherHash) + return distance <= stashBoxDistance +} + // FindStashBoxScenesByFingerprints queries stash-box for scenes using every // scene's MD5/OSHASH checksum, or PHash, and returns results in the same order // as the input slice. @@ -78,6 +96,7 @@ func (c Client) FindStashBoxScenesByFingerprints(ctx context.Context, sceneIDs [ var fingerprints []*graphql.FingerprintQueryInput // map fingerprints to their scene index fpToScene := make(map[string][]int) + phashToScene := make(map[int64][]int) if err := c.txnManager.WithReadTxn(ctx, func(r models.ReaderRepository) error { qb := r.Scene() @@ -115,6 +134,7 @@ func (c Client) FindStashBoxScenesByFingerprints(ctx context.Context, sceneIDs [ Algorithm: graphql.FingerprintAlgorithmPhash, }) fpToScene[phashStr] = append(fpToScene[phashStr], index) + phashToScene[scene.Phash.Int64] = append(phashToScene[scene.Phash.Int64], index) } } @@ -132,8 +152,8 @@ func (c Client) FindStashBoxScenesByFingerprints(ctx context.Context, sceneIDs [ ret := make([][]*models.ScrapedScene, len(sceneIDs)) for _, s := range allScenes { var addedTo []int - for _, fp := range s.Fingerprints { - sceneIndexes := fpToScene[fp.Hash] + + addScene := func(sceneIndexes []int) { for _, index := range sceneIndexes { if !utils.IntInclude(addedTo, index) { addedTo = append(addedTo, index) @@ -141,6 +161,24 @@ func (c Client) FindStashBoxScenesByFingerprints(ctx context.Context, sceneIDs [ } } } + + for _, fp := range s.Fingerprints { + addScene(fpToScene[fp.Hash]) + + // HACK - we really need stash-box to return specific hash-to-result sets + if fp.Algorithm == graphql.FingerprintAlgorithmPhash.String() { + hash, err := utils.StringToPhash(fp.Hash) + if err != nil { + continue + } + + for phash, sceneIndexes := range phashToScene { + if phashMatches(hash, phash) { + addScene(sceneIndexes) + } + } + } + } } return ret, nil @@ -468,7 +506,7 @@ func findURL(urls []*graphql.URLFragment, urlType string) *string { func enumToStringPtr(e fmt.Stringer, titleCase bool) *string { if e != nil { - ret := e.String() + ret := strings.ReplaceAll(e.String(), "_", " ") if titleCase { ret = strings.Title(strings.ToLower(ret)) } @@ -478,6 +516,28 @@ func enumToStringPtr(e fmt.Stringer, titleCase bool) *string { return nil } +func translateGender(gender *graphql.GenderEnum) *string { + var res models.GenderEnum + switch *gender { + case graphql.GenderEnumMale: + res = models.GenderEnumMale + case graphql.GenderEnumFemale: + res = models.GenderEnumFemale + case graphql.GenderEnumIntersex: + res = models.GenderEnumIntersex + case graphql.GenderEnumTransgenderFemale: + res = models.GenderEnumTransgenderFemale + case graphql.GenderEnumTransgenderMale: + res = models.GenderEnumTransgenderMale + } + + if res != "" { + strVal := res.String() + return &strVal + } + return nil +} + func formatMeasurements(m graphql.MeasurementsFragment) *string { if m.BandSize != nil && m.CupSize != nil && m.Hip != nil && m.Waist != nil { ret := fmt.Sprintf("%d%s-%d-%d", *m.BandSize, *m.CupSize, *m.Waist, *m.Hip) @@ -587,7 +647,7 @@ func performerFragmentToScrapedScenePerformer(p graphql.PerformerFragment) *mode } if p.Gender != nil { - sp.Gender = enumToStringPtr(p.Gender, false) + sp.Gender = translateGender(p.Gender) } if p.Ethnicity != nil { @@ -731,3 +791,274 @@ func (c Client) FindStashBoxPerformerByName(ctx context.Context, name string) (* return ret, nil } + +func (c Client) GetUser(ctx context.Context) (*graphql.Me, error) { + return c.client.Me(ctx) +} + +func (c Client) SubmitSceneDraft(ctx context.Context, sceneID int, endpoint string, imagePath string) (*string, error) { + draft := graphql.SceneDraftInput{} + var image *os.File + if err := c.txnManager.WithReadTxn(ctx, func(r models.ReaderRepository) error { + qb := r.Scene() + pqb := r.Performer() + sqb := r.Studio() + + scene, err := qb.Find(sceneID) + if err != nil { + return err + } + + if scene.Title.Valid { + draft.Title = &scene.Title.String + } + if scene.Details.Valid { + draft.Details = &scene.Details.String + } + if len(strings.TrimSpace(scene.URL.String)) > 0 { + url := strings.TrimSpace(scene.URL.String) + draft.URL = &url + } + if scene.Date.Valid { + draft.Date = &scene.Date.String + } + + if scene.StudioID.Valid { + studio, err := sqb.Find(int(scene.StudioID.Int64)) + if err != nil { + return err + } + studioDraft := graphql.DraftEntityInput{ + Name: studio.Name.String, + } + + stashIDs, err := sqb.GetStashIDs(studio.ID) + if err != nil { + return err + } + for _, stashID := range stashIDs { + if stashID.Endpoint == endpoint { + studioDraft.ID = &stashID.StashID + break + } + } + draft.Studio = &studioDraft + } + + fingerprints := []*graphql.FingerprintInput{} + if scene.OSHash.Valid && scene.Duration.Valid { + fingerprint := graphql.FingerprintInput{ + Hash: scene.OSHash.String, + Algorithm: graphql.FingerprintAlgorithmOshash, + Duration: int(scene.Duration.Float64), + } + fingerprints = append(fingerprints, &fingerprint) + } + + if scene.Checksum.Valid && scene.Duration.Valid { + fingerprint := graphql.FingerprintInput{ + Hash: scene.Checksum.String, + Algorithm: graphql.FingerprintAlgorithmMd5, + Duration: int(scene.Duration.Float64), + } + fingerprints = append(fingerprints, &fingerprint) + } + + if scene.Phash.Valid && scene.Duration.Valid { + fingerprint := graphql.FingerprintInput{ + Hash: utils.PhashToString(scene.Phash.Int64), + Algorithm: graphql.FingerprintAlgorithmPhash, + Duration: int(scene.Duration.Float64), + } + fingerprints = append(fingerprints, &fingerprint) + } + draft.Fingerprints = fingerprints + + scenePerformers, err := pqb.FindBySceneID(sceneID) + if err != nil { + return err + } + + performers := []*graphql.DraftEntityInput{} + for _, p := range scenePerformers { + performerDraft := graphql.DraftEntityInput{ + Name: p.Name.String, + } + + stashIDs, err := pqb.GetStashIDs(p.ID) + if err != nil { + return err + } + + for _, stashID := range stashIDs { + if stashID.Endpoint == endpoint { + performerDraft.ID = &stashID.StashID + break + } + } + + performers = append(performers, &performerDraft) + } + draft.Performers = performers + + var tags []*graphql.DraftEntityInput + sceneTags, err := r.Tag().FindBySceneID(scene.ID) + if err != nil { + return err + } + for _, tag := range sceneTags { + tags = append(tags, &graphql.DraftEntityInput{Name: tag.Name}) + } + draft.Tags = tags + + exists, _ := utils.FileExists(imagePath) + if exists { + file, err := os.Open(imagePath) + if err == nil { + image = file + } + } + + return nil + }); err != nil { + return nil, err + } + + var id *string + var ret graphql.SubmitSceneDraftPayload + err := c.submitDraft(ctx, graphql.SubmitSceneDraftQuery, draft, image, &ret) + id = ret.SubmitSceneDraft.ID + + return id, err +} + +func (c Client) SubmitPerformerDraft(ctx context.Context, performer *models.Performer, endpoint string) (*string, error) { + draft := graphql.PerformerDraftInput{} + var image io.Reader + if err := c.txnManager.WithReadTxn(ctx, func(r models.ReaderRepository) error { + pqb := r.Performer() + img, _ := pqb.GetImage(performer.ID) + if img != nil { + image = bytes.NewReader(img) + } + + if performer.Name.Valid { + draft.Name = performer.Name.String + } + if performer.Birthdate.Valid { + draft.Birthdate = &performer.Birthdate.String + } + if performer.Country.Valid { + draft.Country = &performer.Country.String + } + if performer.Ethnicity.Valid { + draft.Ethnicity = &performer.Ethnicity.String + } + if performer.EyeColor.Valid { + draft.EyeColor = &performer.EyeColor.String + } + if performer.FakeTits.Valid { + draft.BreastType = &performer.FakeTits.String + } + if performer.Gender.Valid { + draft.Gender = &performer.Gender.String + } + if performer.HairColor.Valid { + draft.HairColor = &performer.HairColor.String + } + if performer.Height.Valid { + draft.Height = &performer.Height.String + } + if performer.Measurements.Valid { + draft.Measurements = &performer.Measurements.String + } + if performer.Piercings.Valid { + draft.Piercings = &performer.Piercings.String + } + if performer.Tattoos.Valid { + draft.Tattoos = &performer.Tattoos.String + } + if performer.Aliases.Valid { + draft.Aliases = &performer.Aliases.String + } + + var urls []string + if len(strings.TrimSpace(performer.Twitter.String)) > 0 { + urls = append(urls, "https://twitter.com/"+strings.TrimSpace(performer.Twitter.String)) + } + if len(strings.TrimSpace(performer.Instagram.String)) > 0 { + urls = append(urls, "https://instagram.com/"+strings.TrimSpace(performer.Instagram.String)) + } + if len(strings.TrimSpace(performer.URL.String)) > 0 { + urls = append(urls, strings.TrimSpace(performer.URL.String)) + } + if len(urls) > 0 { + draft.Urls = urls + } + + return nil + }); err != nil { + return nil, err + } + + var id *string + var ret graphql.SubmitPerformerDraftPayload + err := c.submitDraft(ctx, graphql.SubmitPerformerDraftQuery, draft, image, &ret) + id = ret.SubmitPerformerDraft.ID + + return id, err +} + +func (c *Client) submitDraft(ctx context.Context, query string, input interface{}, image io.Reader, ret interface{}) error { + vars := map[string]interface{}{ + "input": input, + } + + r := &client.Request{ + Query: query, + Variables: vars, + OperationName: "", + } + + requestBody, err := json.Marshal(r) + if err != nil { + return fmt.Errorf("encode: %w", err) + } + + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + if err := writer.WriteField("operations", string(requestBody)); err != nil { + return err + } + + if image != nil { + if err := writer.WriteField("map", "{ \"0\": [\"variables.input.image\"] }"); err != nil { + return err + } + part, _ := writer.CreateFormFile("0", "draft") + if _, err := io.Copy(part, image); err != nil { + return err + } + } else if err := writer.WriteField("map", "{}"); err != nil { + return err + } + + writer.Close() + + req, _ := http.NewRequestWithContext(ctx, "POST", c.box.Endpoint, body) + req.Header.Add("Content-Type", writer.FormDataContentType()) + req.Header.Set("ApiKey", c.box.APIKey) + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if err := graphqljson.Unmarshal(resp.Body, ret); err != nil { + return err + } + + return err +} diff --git a/pkg/scraper/url.go b/pkg/scraper/url.go index 10a160f40..ddc63a8fb 100644 --- a/pkg/scraper/url.go +++ b/pkg/scraper/url.go @@ -5,6 +5,7 @@ import ( "context" "fmt" "io" + "net" "net/http" "net/url" "os" @@ -87,7 +88,7 @@ func loadURL(ctx context.Context, loadURL string, client *http.Client, scraperCo // func urlFromCDP uses chrome cdp and DOM to load and process the url // if remote is set as true in the scraperConfig it will try to use localhost:9222 // else it will look for google-chrome in path -func urlFromCDP(ctx context.Context, url string, driverOptions scraperDriverOptions, globalConfig GlobalConfig) (io.Reader, error) { +func urlFromCDP(ctx context.Context, urlCDP string, driverOptions scraperDriverOptions, globalConfig GlobalConfig) (io.Reader, error) { if !driverOptions.UseCDP { return nil, fmt.Errorf("url shouldn't be fetched through CDP") @@ -107,6 +108,33 @@ func urlFromCDP(ctx context.Context, url string, driverOptions scraperDriverOpti if isCDPPathHTTP(globalConfig) || isCDPPathWS(globalConfig) { remote := cdpPath + // ------------------------------------------------------------------- + // #1023 + // when chromium is listening over RDP it only accepts requests + // with host headers that are either IPs or `localhost` + cdpURL, err := url.Parse(remote) + if err != nil { + return nil, fmt.Errorf("failed to parse CDP Path: %v", err) + } + hostname := cdpURL.Hostname() + if hostname != "localhost" { + if net.ParseIP(hostname) == nil { // not an IP + addr, err := net.LookupIP(hostname) + if err != nil || len(addr) == 0 { // can not resolve to IP + return nil, fmt.Errorf("CDP: hostname <%s> can not be resolved", hostname) + } + if len(addr[0]) == 0 { // nil IP + return nil, fmt.Errorf("CDP: hostname <%s> resolved to nil", hostname) + } + // addr is a valid IP + // replace the host part of the cdpURL with the IP + cdpURL.Host = strings.Replace(cdpURL.Host, hostname, addr[0].String(), 1) + // use that for remote + remote = cdpURL.String() + } + } + // -------------------------------------------------------------------- + // if CDPPath is http(s) then we need to get the websocket URL if isCDPPathHTTP(globalConfig) { var err error @@ -150,7 +178,7 @@ func urlFromCDP(ctx context.Context, url string, driverOptions scraperDriverOpti setCDPCookies(driverOptions), printCDPCookies(driverOptions, "Cookies found"), network.SetExtraHTTPHeaders(network.Headers(headers)), - chromedp.Navigate(url), + chromedp.Navigate(urlCDP), chromedp.Sleep(sleepDuration), setCDPClicks(driverOptions), chromedp.OuterHTML("html", &res, chromedp.ByQuery), diff --git a/pkg/session/authentication.go b/pkg/session/authentication.go index ff617c774..503fe777d 100644 --- a/pkg/session/authentication.go +++ b/pkg/session/authentication.go @@ -16,12 +16,6 @@ func (e ExternalAccessError) Error() string { return fmt.Sprintf("stash accessed from external IP %s", net.IP(e).String()) } -type UntrustedProxyError net.IP - -func (e UntrustedProxyError) Error() string { - return fmt.Sprintf("untrusted proxy %s", net.IP(e).String()) -} - func CheckAllowPublicWithoutAuth(c *config.Instance, r *http.Request) error { if !c.HasCredentials() && !c.GetDangerousAllowPublicWithoutAuth() && !c.IsNewSystem() { requestIPString, _, err := net.SplitHostPort(r.RemoteAddr) @@ -42,43 +36,21 @@ func CheckAllowPublicWithoutAuth(c *config.Instance, r *http.Request) error { if r.Header.Get("X-FORWARDED-FOR") != "" { // Request was proxied - trustedProxies := c.GetTrustedProxies() proxyChain := strings.Split(r.Header.Get("X-FORWARDED-FOR"), ", ") - if len(trustedProxies) == 0 { - // validate proxies against local network only - if !isLocalIP(requestIP) { - return ExternalAccessError(requestIP) - } else { - // Safe to validate X-Forwarded-For - for i := range proxyChain { - ip := net.ParseIP(proxyChain[i]) - if !isLocalIP(ip) { - return ExternalAccessError(ip) - } - } - } + // validate proxies against local network only + if !isLocalIP(requestIP) { + return ExternalAccessError(requestIP) } else { - // validate proxies against trusted proxies list - if isIPTrustedProxy(requestIP, trustedProxies) { - // Safe to validate X-Forwarded-For - // validate backwards, as only the last one is not attacker-controlled - for i := len(proxyChain) - 1; i >= 0; i-- { - ip := net.ParseIP(proxyChain[i]) - if i == 0 { - // last entry is originating device, check if from the public internet - if !isLocalIP(ip) { - return ExternalAccessError(ip) - } - } else if !isIPTrustedProxy(ip, trustedProxies) { - return UntrustedProxyError(ip) - } + // Safe to validate X-Forwarded-For + for i := range proxyChain { + ip := net.ParseIP(proxyChain[i]) + if !isLocalIP(ip) { + return ExternalAccessError(ip) } - } else { - // Proxy not on safe proxy list - return UntrustedProxyError(requestIP) } } + } else if !isLocalIP(requestIP) { // request was not proxied return ExternalAccessError(requestIP) } @@ -104,18 +76,6 @@ func isLocalIP(requestIP net.IP) bool { return requestIP.IsPrivate() || requestIP.IsLoopback() || requestIP.IsLinkLocalUnicast() || cgNatAddrSpace.Contains(requestIP) } -func isIPTrustedProxy(ip net.IP, trustedProxies []string) bool { - if len(trustedProxies) == 0 { - return isLocalIP(ip) - } - for _, v := range trustedProxies { - if ip.Equal(net.ParseIP(v)) { - return true - } - } - return false -} - func LogExternalAccessError(err ExternalAccessError) { logger.Errorf("Stash has been accessed from the internet (public IP %s), without authentication. \n"+ "This is extremely dangerous! The whole world can see your stash page and browse your files! \n"+ diff --git a/pkg/session/authentication_test.go b/pkg/session/authentication_test.go index 1cf967c8f..6a660bc5c 100644 --- a/pkg/session/authentication_test.go +++ b/pkg/session/authentication_test.go @@ -66,7 +66,7 @@ func TestCheckAllowPublicWithoutAuth(t *testing.T) { } { - // X-FORWARDED-FOR without trusted proxy + // X-FORWARDED-FOR testCases := []struct { proxyChain string err error @@ -91,39 +91,6 @@ func TestCheckAllowPublicWithoutAuth(t *testing.T) { } } - { - // X-FORWARDED-FOR with trusted proxy - var trustedProxies = []string{"8.8.8.8", "4.4.4.4"} - c.Set(config.TrustedProxies, trustedProxies) - - testCases := []struct { - address string - proxyChain string - err error - }{ - {"192.168.1.1:8080", "192.168.1.1, 192.168.1.2, 100.64.0.1, 127.0.0.1", &UntrustedProxyError{}}, - {"8.8.8.8:8080", "192.168.1.2, 127.0.0.1", &UntrustedProxyError{}}, - {"8.8.8.8:8080", "193.168.1.1, 4.4.4.4", &ExternalAccessError{}}, - {"8.8.8.8:8080", "4.4.4.4", &ExternalAccessError{}}, - {"8.8.8.8:8080", "192.168.1.1, 4.4.4.4a", &UntrustedProxyError{}}, - {"8.8.8.8:8080", "192.168.1.1a, 4.4.4.4", &ExternalAccessError{}}, - {"8.8.8.8:8080", "192.168.1.1, 4.4.4.4", nil}, - {"8.8.8.8:8080", "192.168.1.1", nil}, - } - - header := make(http.Header) - - for i, tc := range testCases { - header.Set("X-FORWARDED-FOR", tc.proxyChain) - r := &http.Request{ - RemoteAddr: tc.address, - Header: header, - } - - doTest(i, r, tc.err) - } - } - { // test invalid request IPs invalidIPs := []string{"192.168.1.a:9999", "192.168.1.1"} @@ -134,11 +101,6 @@ func TestCheckAllowPublicWithoutAuth(t *testing.T) { } err := CheckAllowPublicWithoutAuth(c, r) - if errors.As(err, &UntrustedProxyError{}) || errors.As(err, &ExternalAccessError{}) { - t.Errorf("[%s]: unexpected error: %v", remoteAddr, err) - continue - } - if err == nil { t.Errorf("[%s]: expected error", remoteAddr) continue diff --git a/pkg/sqlite/filter.go b/pkg/sqlite/filter.go index 8935140c2..9d3676542 100644 --- a/pkg/sqlite/filter.go +++ b/pkg/sqlite/filter.go @@ -391,13 +391,7 @@ func stringCriterionHandler(c *models.StringCriterionInput, column string) crite case models.CriterionModifierNotNull: f.addWhere("(" + column + " IS NOT NULL AND TRIM(" + column + ") != '')") default: - clause, count := getSimpleCriterionClause(modifier, "?") - - if count == 1 { - f.addWhere(column+" "+clause, c.Value) - } else { - f.addWhere(column + " " + clause) - } + panic("unsupported string filter modifier") } } } diff --git a/pkg/sqlite/gallery.go b/pkg/sqlite/gallery.go index f76f84d4c..7c8aca107 100644 --- a/pkg/sqlite/gallery.go +++ b/pkg/sqlite/gallery.go @@ -220,6 +220,8 @@ func (qb *galleryQueryBuilder) makeFilter(galleryFilter *models.GalleryFilterTyp query.handleCriterion(galleryPerformerTagsCriterionHandler(qb, galleryFilter.PerformerTags)) query.handleCriterion(galleryAverageResolutionCriterionHandler(qb, galleryFilter.AverageResolution)) query.handleCriterion(galleryImageCountCriterionHandler(qb, galleryFilter.ImageCount)) + query.handleCriterion(galleryPerformerFavoriteCriterionHandler(galleryFilter.PerformerFavorite)) + query.handleCriterion(galleryPerformerAgeCriterionHandler(galleryFilter.PerformerAge)) return query } @@ -421,6 +423,43 @@ INNER JOIN (` + valuesClause + `) t ON t.column2 = pt.tag_id } } +func galleryPerformerFavoriteCriterionHandler(performerfavorite *bool) criterionHandlerFunc { + return func(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(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") + f.addWhere("galleries.date != '0001-01-01' AND performers.birthdate != '0001-01-01'") + + 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 *galleryQueryBuilder, resolution *models.ResolutionCriterionInput) criterionHandlerFunc { return func(f *filterBuilder) { if resolution != nil && resolution.Value.IsValid() { diff --git a/pkg/sqlite/image.go b/pkg/sqlite/image.go index 480b9a6b9..0e84be497 100644 --- a/pkg/sqlite/image.go +++ b/pkg/sqlite/image.go @@ -248,6 +248,7 @@ func (qb *imageQueryBuilder) makeFilter(imageFilter *models.ImageFilterType) *fi query.handleCriterion(imagePerformerCountCriterionHandler(qb, imageFilter.PerformerCount)) query.handleCriterion(imageStudioCriterionHandler(qb, imageFilter.Studios)) query.handleCriterion(imagePerformerTagsCriterionHandler(qb, imageFilter.PerformerTags)) + query.handleCriterion(imagePerformerFavoriteCriterionHandler(imageFilter.PerformerFavorite)) return query } @@ -446,6 +447,26 @@ func imagePerformerCountCriterionHandler(qb *imageQueryBuilder, performerCount * return h.handler(performerCount) } +func imagePerformerFavoriteCriterionHandler(performerfavorite *bool) criterionHandlerFunc { + return func(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 imageStudioCriterionHandler(qb *imageQueryBuilder, studios *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { h := hierarchicalMultiCriterionHandlerBuilder{ tx: qb.tx, diff --git a/pkg/sqlite/performer.go b/pkg/sqlite/performer.go index d824dc37e..b256d7d66 100644 --- a/pkg/sqlite/performer.go +++ b/pkg/sqlite/performer.go @@ -21,6 +21,12 @@ WHERE performers_tags.tag_id = ? GROUP BY performers_tags.performer_id ` +// KNOWN ISSUE: using \p{L} to find single unicode character names results in +// very slow queries. +// Suggested solution will be to cache single-character names and not include it +// in the autotag query. +const singleFirstCharacterRegex = `^[\w][.\-_ ]` + type performerQueryBuilder struct { repository } @@ -184,7 +190,7 @@ func (qb *performerQueryBuilder) QueryForAutoTag(words []string) ([]*models.Perf var args []interface{} whereClauses = append(whereClauses, "name regexp ?") - args = append(args, "^[\\w][.\\-_ ]") + args = append(args, singleFirstCharacterRegex) for _, w := range words { whereClauses = append(whereClauses, "name like ?") diff --git a/pkg/sqlite/repository.go b/pkg/sqlite/repository.go index 49329f13c..b9c8d22bb 100644 --- a/pkg/sqlite/repository.go +++ b/pkg/sqlite/repository.go @@ -171,6 +171,8 @@ func (r *repository) runSumQuery(query string, args []interface{}) (float64, err } func (r *repository) queryFunc(query string, args []interface{}, single bool, f func(rows *sqlx.Rows) error) error { + logger.Tracef("SQL: %s, args: %v", query, args) + rows, err := r.tx.Queryx(query, args...) if err != nil && !errors.Is(err, sql.ErrNoRows) { diff --git a/pkg/sqlite/scene.go b/pkg/sqlite/scene.go index 4981e43a5..2649a8322 100644 --- a/pkg/sqlite/scene.go +++ b/pkg/sqlite/scene.go @@ -392,6 +392,9 @@ func (qb *sceneQueryBuilder) makeFilter(sceneFilter *models.SceneFilterType) *fi query.handleCriterion(sceneStudioCriterionHandler(qb, sceneFilter.Studios)) query.handleCriterion(sceneMoviesCriterionHandler(qb, sceneFilter.Movies)) query.handleCriterion(scenePerformerTagsCriterionHandler(qb, sceneFilter.PerformerTags)) + query.handleCriterion(scenePerformerFavoriteCriterionHandler(sceneFilter.PerformerFavorite)) + query.handleCriterion(scenePerformerAgeCriterionHandler(sceneFilter.PerformerAge)) + query.handleCriterion(scenePhashDuplicatedCriterionHandler(sceneFilter.Duplicated)) return query } @@ -504,6 +507,21 @@ func phashCriterionHandler(phashFilter *models.StringCriterionInput) criterionHa } } +func scenePhashDuplicatedCriterionHandler(duplicatedFilter *models.PHashDuplicationCriterionInput) criterionHandlerFunc { + return func(f *filterBuilder) { + // TODO: Wishlist item: Implement Distance matching + if duplicatedFilter != nil { + var v string + if *duplicatedFilter.Duplicated { + v = ">" + } else { + v = "=" + } + f.addInnerJoin("(SELECT id FROM scenes JOIN (SELECT phash FROM scenes GROUP BY phash HAVING COUNT(phash) "+v+" 1) dupes on scenes.phash = dupes.phash)", "scph", "scenes.id = scph.id") + } + } +} + func durationCriterionHandler(durationFilter *models.IntCriterionInput, column string) criterionHandlerFunc { return func(f *filterBuilder) { if durationFilter != nil { @@ -642,6 +660,43 @@ func scenePerformerCountCriterionHandler(qb *sceneQueryBuilder, performerCount * return h.handler(performerCount) } +func scenePerformerFavoriteCriterionHandler(performerfavorite *bool) criterionHandlerFunc { + return func(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(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") + f.addWhere("scenes.date != '0001-01-01' AND performers.birthdate != '0001-01-01'") + + 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 sceneStudioCriterionHandler(qb *sceneQueryBuilder, studios *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { h := hierarchicalMultiCriterionHandlerBuilder{ tx: qb.tx, diff --git a/pkg/sqlite/sql.go b/pkg/sqlite/sql.go index 5407492d1..dd7805676 100644 --- a/pkg/sqlite/sql.go +++ b/pkg/sqlite/sql.go @@ -9,7 +9,6 @@ import ( "strconv" "strings" - "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" ) @@ -66,6 +65,10 @@ func getSort(sort string, direction string, tableName string) string { case strings.Compare(sort, "filesize") == 0: colName := getColumn(tableName, "size") return " ORDER BY cast(" + colName + " as integer) " + direction + case strings.Compare(sort, "perceptual_similarity") == 0: + colName := getColumn(tableName, "phash") + secondaryColName := getColumn(tableName, "size") + return " ORDER BY " + colName + " " + direction + ", " + secondaryColName + " DESC" case strings.HasPrefix(sort, randomSeedPrefix): // seed as a parameter from the UI // turn the provided seed into a float @@ -149,54 +152,39 @@ func getInBinding(length int) string { return "(" + bindings + ")" } -func getSimpleCriterionClause(criterionModifier models.CriterionModifier, rhs string) (string, int) { - if modifier := criterionModifier.String(); criterionModifier.IsValid() { - switch modifier { - case "EQUALS": - return "= " + rhs, 1 - case "NOT_EQUALS": - return "!= " + rhs, 1 - case "GREATER_THAN": - return "> " + rhs, 1 - case "LESS_THAN": - return "< " + rhs, 1 - case "IS_NULL": - return "IS NULL", 0 - case "NOT_NULL": - return "IS NOT NULL", 0 - case "BETWEEN": - return "BETWEEN (" + rhs + ") AND (" + rhs + ")", 2 - case "NOT_BETWEEN": - return "NOT BETWEEN (" + rhs + ") AND (" + rhs + ")", 2 - default: - logger.Errorf("todo") - return "= ?", 1 // TODO - } - } - - return "= ?", 1 // TODO +func getIntCriterionWhereClause(column string, input models.IntCriterionInput) (string, []interface{}) { + return getIntWhereClause(column, input.Modifier, input.Value, input.Value2) } -func getIntCriterionWhereClause(column string, input models.IntCriterionInput) (string, []interface{}) { - binding, _ := getSimpleCriterionClause(input.Modifier, "?") - var args []interface{} - - switch input.Modifier { - case "EQUALS", "NOT_EQUALS": - args = []interface{}{input.Value} - case "LESS_THAN": - args = []interface{}{input.Value} - case "GREATER_THAN": - args = []interface{}{input.Value} - case "BETWEEN", "NOT_BETWEEN": - upper := 0 - if input.Value2 != nil { - upper = *input.Value2 - } - args = []interface{}{input.Value, upper} +func getIntWhereClause(column string, modifier models.CriterionModifier, value int, upper *int) (string, []interface{}) { + if upper == nil { + u := 0 + upper = &u } - return column + " " + binding, args + args := []interface{}{value} + betweenArgs := []interface{}{value, *upper} + + switch modifier { + case models.CriterionModifierIsNull: + return fmt.Sprintf("%s IS NULL", column), nil + case models.CriterionModifierNotNull: + return fmt.Sprintf("%s IS NOT NULL", column), nil + case models.CriterionModifierEquals: + return fmt.Sprintf("%s = ?", column), args + case models.CriterionModifierNotEquals: + return fmt.Sprintf("%s != ?", column), args + case models.CriterionModifierBetween: + return fmt.Sprintf("%s BETWEEN ? AND ?", column), betweenArgs + case models.CriterionModifierNotBetween: + return fmt.Sprintf("%s NOT BETWEEN ? AND ?", column), betweenArgs + case models.CriterionModifierLessThan: + return fmt.Sprintf("%s < ?", column), args + case models.CriterionModifierGreaterThan: + return fmt.Sprintf("%s > ?", column), args + } + + panic("unsupported int modifier type") } // returns where clause and having clause diff --git a/pkg/sqlite/studio.go b/pkg/sqlite/studio.go index 91e6c63ea..6eac885cd 100644 --- a/pkg/sqlite/studio.go +++ b/pkg/sqlite/studio.go @@ -145,7 +145,6 @@ func (qb *studioQueryBuilder) QueryForAutoTag(words []string) ([]*models.Studio, var args []interface{} // always include names that begin with a single character - singleFirstCharacterRegex := "^[\\w][.\\-_ ]" whereClauses = append(whereClauses, "studios.name regexp ? OR COALESCE(studio_aliases.alias, '') regexp ?") args = append(args, singleFirstCharacterRegex, singleFirstCharacterRegex) diff --git a/pkg/sqlite/tag.go b/pkg/sqlite/tag.go index c5f3858d7..57514d751 100644 --- a/pkg/sqlite/tag.go +++ b/pkg/sqlite/tag.go @@ -236,7 +236,6 @@ func (qb *tagQueryBuilder) QueryForAutoTag(words []string) ([]*models.Tag, error var args []interface{} // always include names that begin with a single character - singleFirstCharacterRegex := "^[\\w][.\\-_ ]" whereClauses = append(whereClauses, "tags.name regexp ? OR COALESCE(tag_aliases.alias, '') regexp ?") args = append(args, singleFirstCharacterRegex, singleFirstCharacterRegex) diff --git a/scripts/cross-compile.sh b/scripts/cross-compile.sh index 2012d9b6b..3640551ae 100755 --- a/scripts/cross-compile.sh +++ b/scripts/cross-compile.sh @@ -1,7 +1,6 @@ #!/bin/bash -# "stashapp/compiler:develop" "stashapp/compiler:4" -COMPILER_CONTAINER="stashapp/compiler:5" +COMPILER_CONTAINER="stashapp/compiler:6" BUILD_DATE=`go run -mod=vendor scripts/getDate.go` GITHASH=`git rev-parse --short HEAD` @@ -10,8 +9,8 @@ STASH_VERSION=`git describe --tags --exclude latest_develop` SETENV="BUILD_DATE=\"$BUILD_DATE\" GITHASH=$GITHASH STASH_VERSION=\"$STASH_VERSION\"" SETUP="export CGO_ENABLED=1;" WINDOWS="echo '=== Building Windows binary ==='; $SETENV make cross-compile-windows;" -DARWIN="echo '=== Building OSX binary ==='; $SETENV make cross-compile-osx-intel;" -DARWIN_ARM64="echo '=== Building OSX (arm64) binary ==='; $SETENV make cross-compile-osx-applesilicon;" +DARWIN="echo '=== Building OSX binary ==='; $SETENV make cross-compile-macos-intel;" +DARWIN_ARM64="echo '=== Building OSX (arm64) binary ==='; $SETENV make cross-compile-macos-applesilicon;" LINUX_AMD64="echo '=== Building Linux (amd64) binary ==='; $SETENV make cross-compile-linux;" LINUX_ARM64v8="echo '=== Building Linux (armv8/arm64) binary ==='; $SETENV make cross-compile-linux-arm64v8;" LINUX_ARM32v7="echo '=== Building Linux (armv7/armhf) binary ==='; $SETENV make cross-compile-linux-arm32v7;" diff --git a/scripts/generate_icons.sh b/scripts/generate_icons.sh new file mode 100755 index 000000000..0fbd12100 --- /dev/null +++ b/scripts/generate_icons.sh @@ -0,0 +1,50 @@ +#!/bin/bash +# Update the Stash icon throughout the project from a master stash-logo.png + +# Imagemagick, and go packages icns and rsrc are required. +# Copy a high-resolution stash-logo.png to this stash/scripts folder +# and run this script from said folder, commit the result. + +if [ ! -f "stash-logo.png" ]; then + echo "stash-logo.png not found." + exit +fi + +if [ -z "$GOPATH" ]; then + echo "GOPATH environment variable not set" + exit +fi + +if [ ! -e "$GOPATH/bin/rsrc" ]; then + echo "Missing Dependency:" + echo "Please run the following /outside/ of the stash folder:" + echo "go install github.com/akavel/rsrc@latest" + exit +fi + +if [ ! -e "$GOPATH/bin/icnsify" ]; then + echo "Missing Dependency:" + echo "Please run the following /outside/ of the stash folder:" + echo "go install github.com/jackmordaunt/icns/v2/cmd/icnsify@latest" + exit +fi + +# Favicon, used for web favicon, windows systray icon, windows executable icon +convert stash-logo.png -define icon:auto-resize=256,64,48,32,16 favicon.ico +cp favicon.ico ../ui/v2.5/public/ + +# Build .syso for Windows icon, consumed by linker while building stash-win.exe +"$GOPATH"/bin/rsrc -ico favicon.ico -o icon_windows.syso +mv icon_windows.syso ../pkg/desktop/ + +# *nixes systray icon +convert stash-logo.png -resize x256 favicon.png +cp favicon.png ../ui/v2.5/public/ + +# MacOS, used for bundle icon +# https://developer.apple.com/library/archive/documentation/CoreFoundation/Conceptual/CFBundles/BundleTypes/BundleTypes.html +"$GOPATH"/bin/icnsify -i stash-logo.png -o icon.icns +mv icon.icns macos-bundle/Contents/Resources/icon.icns + +# cleanup +rm favicon.png favicon.ico \ No newline at end of file diff --git a/scripts/macos-bundle/Contents/Info.plist b/scripts/macos-bundle/Contents/Info.plist new file mode 100644 index 000000000..9a02e9cc2 --- /dev/null +++ b/scripts/macos-bundle/Contents/Info.plist @@ -0,0 +1,18 @@ + + + + + CFBundleExecutable + stash + CFBundleIconFile + icon.icns + CFBundleTypeIconFile + icon.icns + CFBundleIdentifier + org.stashapp.stash + NSHighResolutionCapable + True + LSUIElement + 1 + + \ No newline at end of file diff --git a/scripts/macos-bundle/Contents/Resources/icon.icns b/scripts/macos-bundle/Contents/Resources/icon.icns new file mode 100644 index 000000000..d60ac9a2b Binary files /dev/null and b/scripts/macos-bundle/Contents/Resources/icon.icns differ diff --git a/scripts/stash-logo.png b/scripts/stash-logo.png new file mode 100644 index 000000000..d64ef699f Binary files /dev/null and b/scripts/stash-logo.png differ diff --git a/ui/v2.5/index.html b/ui/v2.5/index.html index dd15fa776..f103a9650 100755 --- a/ui/v2.5/index.html +++ b/ui/v2.5/index.html @@ -3,7 +3,8 @@ - + + - + Stash diff --git a/ui/v2.5/public/apple-touch-icon.png b/ui/v2.5/public/apple-touch-icon.png new file mode 100644 index 000000000..b5cc0e8ad Binary files /dev/null and b/ui/v2.5/public/apple-touch-icon.png differ diff --git a/ui/v2.5/public/favicon.ico b/ui/v2.5/public/favicon.ico index 6ff0465ca..028c3ba3b 100644 Binary files a/ui/v2.5/public/favicon.ico and b/ui/v2.5/public/favicon.ico differ diff --git a/ui/v2.5/public/favicon.png b/ui/v2.5/public/favicon.png new file mode 100644 index 000000000..ea145b181 Binary files /dev/null and b/ui/v2.5/public/favicon.png differ diff --git a/ui/v2.5/public/manifest.json b/ui/v2.5/public/manifest.json index f9321c89e..e3d9e7c3b 100755 --- a/ui/v2.5/public/manifest.json +++ b/ui/v2.5/public/manifest.json @@ -8,7 +8,8 @@ "type": "image/x-icon" } ], - "start_url": ".", + "start_url": "/", + "scope": ".", "display": "standalone", "theme_color": "#000000", "background_color": "#ffffff" diff --git a/ui/v2.5/src/components/Changelog/Changelog.tsx b/ui/v2.5/src/components/Changelog/Changelog.tsx index e237f94d6..ffd212a0c 100644 --- a/ui/v2.5/src/components/Changelog/Changelog.tsx +++ b/ui/v2.5/src/components/Changelog/Changelog.tsx @@ -15,6 +15,7 @@ import V090 from "./versions/v090.md"; import V0100 from "./versions/v0100.md"; import V0110 from "./versions/v0110.md"; import V0120 from "./versions/v0120.md"; +import V0130 from "./versions/v0130.md"; import { MarkdownPage } from "../Shared/MarkdownPage"; // to avoid use of explicit any @@ -53,9 +54,9 @@ const Changelog: React.FC = () => { // after new release: // add entry to releases, using the current* fields // then update the current fields. - const currentVersion = stashVersion || "v0.12.0"; + const currentVersion = stashVersion || "v0.13.0"; const currentDate = buildDate; - const currentPage = V0120; + const currentPage = V0130; const releases: IStashRelease[] = [ { @@ -64,9 +65,14 @@ const Changelog: React.FC = () => { page: currentPage, defaultOpen: true, }, + { + version: "v0.12.0", + date: "2021-12-29", + page: V0120, + }, { version: "v0.11.0", - date: "2021-11-15", + date: "2021-11-16", page: V0110, }, { diff --git a/ui/v2.5/src/components/Changelog/versions/v0130.md b/ui/v2.5/src/components/Changelog/versions/v0130.md new file mode 100644 index 000000000..d2f1302fb --- /dev/null +++ b/ui/v2.5/src/components/Changelog/versions/v0130.md @@ -0,0 +1,41 @@ +### ✨ New Features +* Added title, rating and o-counter in image lightbox. ([#2274](https://github.com/stashapp/stash/pull/2274)) +* Added option to hide scene scrubber by default. ([#2325](https://github.com/stashapp/stash/pull/2325)) +* Added support for bulk-editing movies. ([#2283](https://github.com/stashapp/stash/pull/2283)) +* Added support for filtering scenes, images and galleries featuring favourite performers and performer age at time of production. ([#2257](https://github.com/stashapp/stash/pull/2257)) +* Added support for filtering scenes with (or without) phash duplicates. ([#2257](https://github.com/stashapp/stash/pull/2257)) +* Added support for sorting scenes by phash. ([#2257](https://github.com/stashapp/stash/pull/2257)) +* Open stash in system tray on Windows/MacOS when not running via terminal. ([#2073](https://github.com/stashapp/stash/pull/2073)) +* Optionally send desktop notifications when a task completes. ([#2073](https://github.com/stashapp/stash/pull/2073)) +* Added button to image card to view image in Lightbox. ([#2275](https://github.com/stashapp/stash/pull/2275)) +* Added support for submitting performer/scene drafts to stash-box. ([#2234](https://github.com/stashapp/stash/pull/2234)) + +### 🎨 Improvements +* Removed generate options from Tasks -> Generate. These should be set in System -> Preview Generation instead. ([#2342](https://github.com/stashapp/stash/pull/2342)) +* Added gallery icon on Image cards. ([#2324](https://github.com/stashapp/stash/pull/2324)) +* Made Performer page consistent with Studio and Tag pages. ([#2200](https://github.com/stashapp/stash/pull/2200)) +* Added gender icons to performers. ([#2179](https://github.com/stashapp/stash/pull/2179)) +* Added button to test credentials when adding/editing stash-box endpoints. ([#2173](https://github.com/stashapp/stash/pull/2173)) +* Show counts on list tabs in Performer, Studio and Tag pages. ([#2169](https://github.com/stashapp/stash/pull/2169)) + +### 🐛 Bug fixes +* Fix Scrape All button not returning phash distance-matched results from stash-box. ([#2355](https://github.com/stashapp/stash/pull/2355)) +* Fix performer checksum not being updated when name updated via batch stash-box tag. ([#2345](https://github.com/stashapp/stash/pull/2345)) +* Fix studios/performers/tags with unicode characters not being auto-tagged. ([#2336](https://github.com/stashapp/stash/pull/2336)) +* Preview Generation now uses defaults defined in System settings unless overridden in the Generate options. ([#2328](https://github.com/stashapp/stash/pull/2328)) +* Fix scraped performer tags being incorrectly applied to scene tags. ([#2339](https://github.com/stashapp/stash/pull/2339)) +* Fix performer tattoos incorrectly being applied to Twitter URL during batch performer tag. ([#2332](https://github.com/stashapp/stash/pull/2332)) +* Fix performer country not expanding from code when tagging from stash-box. ([#2323](https://github.com/stashapp/stash/pull/2323)) +* Fix image exclude regex not being honoured when scanning in zips. ([#2317](https://github.com/stashapp/stash/pull/2317)) +* Delete funscripts when deleting scene files. ([#2265](https://github.com/stashapp/stash/pull/2265)) +* Fix regex queries incorrectly being converted to lowercase. ([#2314](https://github.com/stashapp/stash/pull/2314)) +* Fix saved filters with URL encoded characters being incorrectly converted. ([#2301](https://github.com/stashapp/stash/pull/2301)) +* Removed trusted proxies setting. ([#2229](https://github.com/stashapp/stash/pull/2229)) +* Fix preview videos causing background media to stop on Android. ([#2254](https://github.com/stashapp/stash/pull/2254)) +* Allow Stash to be iframed. ([#2217](https://github.com/stashapp/stash/pull/2217)) +* Resolve CDP hostname if necessary. ([#2174](https://github.com/stashapp/stash/pull/2174)) +* Generate sprites for short video files. ([#2167](https://github.com/stashapp/stash/pull/2167)) +* Fix stash-box scraping including underscores in ethnicity. ([#2191](https://github.com/stashapp/stash/pull/2191)) +* Fix stash-box batch performer task not setting birthdate. ([#2189](https://github.com/stashapp/stash/pull/2189)) +* Fix error when scanning symlinks. ([#2196](https://github.com/stashapp/stash/issues/2196)) +* Fix timezone issue with Created/Updated dates in scene/image/gallery details pages. ([#2190](https://github.com/stashapp/stash/pull/2190)) diff --git a/ui/v2.5/src/components/Dialogs/GenerateDialog.tsx b/ui/v2.5/src/components/Dialogs/GenerateDialog.tsx index 1f95d54a7..70ec8c835 100644 --- a/ui/v2.5/src/components/Dialogs/GenerateDialog.tsx +++ b/ui/v2.5/src/components/Dialogs/GenerateDialog.tsx @@ -51,12 +51,14 @@ export const GenerateDialog: React.FC = ({ return; } + // combine the defaults with the system preview generation settings if (configuration?.defaults.generate) { const { generate } = configuration.defaults; setOptions(withoutTypename(generate)); setConfigRead(true); - } else if (configuration?.general) { - // backwards compatibility + } + + if (configuration?.general) { const { general } = configuration; setOptions((existing) => ({ ...existing, diff --git a/ui/v2.5/src/components/Dialogs/SubmitDraft.tsx b/ui/v2.5/src/components/Dialogs/SubmitDraft.tsx new file mode 100644 index 000000000..c3eceae8b --- /dev/null +++ b/ui/v2.5/src/components/Dialogs/SubmitDraft.tsx @@ -0,0 +1,126 @@ +import React, { useState } from "react"; +import { useMutation, DocumentNode } from "@apollo/client"; +import { Button, Form } from "react-bootstrap"; +import * as GQL from "src/core/generated-graphql"; +import { Modal } from "src/components/Shared"; +import { getStashboxBase } from "src/utils"; +import { FormattedMessage, useIntl } from "react-intl"; + +interface IProps { + show: boolean; + entity: { name?: string | null; id: string; title?: string | null }; + boxes: Pick[]; + query: DocumentNode; + onHide: () => void; +} + +type Variables = + | GQL.SubmitStashBoxSceneDraftMutationVariables + | GQL.SubmitStashBoxPerformerDraftMutationVariables; +type Query = + | GQL.SubmitStashBoxSceneDraftMutation + | GQL.SubmitStashBoxPerformerDraftMutation; + +const isSceneDraft = ( + query: Query | null +): query is GQL.SubmitStashBoxSceneDraftMutation => + (query as GQL.SubmitStashBoxSceneDraftMutation).submitStashBoxSceneDraft !== + undefined; + +const getResponseId = (query: Query | null) => + isSceneDraft(query) + ? query.submitStashBoxSceneDraft + : query?.submitStashBoxPerformerDraft; + +export const SubmitStashBoxDraft: React.FC = ({ + show, + boxes, + entity, + query, + onHide, +}) => { + const [submit, { data, error, loading }] = useMutation( + query + ); + const [selectedBox, setSelectedBox] = useState(0); + const intl = useIntl(); + + const handleSubmit = () => { + submit({ + variables: { + input: { + id: entity.id, + stash_box_index: selectedBox, + }, + }, + }); + }; + + const handleSelectBox = (e: React.ChangeEvent) => + setSelectedBox(Number.parseInt(e.currentTarget.value) ?? 0); + + return ( + + {data === undefined ? ( + <> + + + : + + + {boxes.map((box, i) => ( + + ))} + + + + + ) : ( + <> +
+ +
+
+ + + +
+ + )} + {error !== undefined && ( + <> +
+ +
+
{error.message}
+ + )} +
+ ); +}; diff --git a/ui/v2.5/src/components/Galleries/EditGalleriesDialog.tsx b/ui/v2.5/src/components/Galleries/EditGalleriesDialog.tsx index 88e2bd0be..9bd5bdb72 100644 --- a/ui/v2.5/src/components/Galleries/EditGalleriesDialog.tsx +++ b/ui/v2.5/src/components/Galleries/EditGalleriesDialog.tsx @@ -9,6 +9,14 @@ import { useToast } from "src/hooks"; import { FormUtils } from "src/utils"; import MultiSet from "../Shared/MultiSet"; import { RatingStars } from "../Scenes/SceneDetails/RatingStars"; +import { + getAggregateInputIDs, + getAggregateInputValue, + getAggregatePerformerIds, + getAggregateRating, + getAggregateStudioId, + getAggregateTagIds, +} from "src/utils/bulkUpdate"; interface IListOperationProps { selected: GQL.SlimGalleryDataFragment[]; @@ -42,22 +50,12 @@ export const EditGalleriesDialog: React.FC = ( const checkboxRef = React.createRef(); - function makeBulkUpdateIds( - ids: string[], - mode: GQL.BulkUpdateIdMode - ): GQL.BulkUpdateIds { - return { - mode, - ids, - }; - } - function getGalleryInput(): GQL.BulkGalleryUpdateInput { // need to determine what we are actually setting on each gallery - const aggregateRating = getRating(props.selected); - const aggregateStudioId = getStudioId(props.selected); - const aggregatePerformerIds = getPerformerIds(props.selected); - const aggregateTagIds = getTagIds(props.selected); + const aggregateRating = getAggregateRating(props.selected); + const aggregateStudioId = getAggregateStudioId(props.selected); + const aggregatePerformerIds = getAggregatePerformerIds(props.selected); + const aggregateTagIds = getAggregateTagIds(props.selected); const galleryInput: GQL.BulkGalleryUpdateInput = { ids: props.selected.map((gallery) => { @@ -65,67 +63,22 @@ export const EditGalleriesDialog: React.FC = ( }), }; - // if rating is undefined - if (rating === undefined) { - // and all galleries have the same rating, then we are unsetting the rating. - if (aggregateRating) { - // null to unset rating - galleryInput.rating = null; - } - // otherwise not setting the rating - } else { - // if rating is set, then we are setting the rating for all - galleryInput.rating = rating; - } + galleryInput.rating = getAggregateInputValue(rating, aggregateRating); + galleryInput.studio_id = getAggregateInputValue( + studioId, + aggregateStudioId + ); - // if studioId is undefined - if (studioId === undefined) { - // and all galleries have the same studioId, - // then unset the studioId, otherwise ignoring studioId - if (aggregateStudioId) { - // null to unset studio_id - galleryInput.studio_id = null; - } - } else { - // if studioId is set, then we are setting it - galleryInput.studio_id = studioId; - } - - // if performerIds are empty - if ( - performerMode === GQL.BulkUpdateIdMode.Set && - (!performerIds || performerIds.length === 0) - ) { - // and all galleries have the same ids, - if (aggregatePerformerIds.length > 0) { - // then unset the performerIds, otherwise ignore - galleryInput.performer_ids = makeBulkUpdateIds( - performerIds || [], - performerMode - ); - } - } else { - // if performerIds non-empty, then we are setting them - galleryInput.performer_ids = makeBulkUpdateIds( - performerIds || [], - performerMode - ); - } - - // if tagIds non-empty, then we are setting them - if ( - tagMode === GQL.BulkUpdateIdMode.Set && - (!tagIds || tagIds.length === 0) - ) { - // and all galleries have the same ids, - if (aggregateTagIds.length > 0) { - // then unset the tagIds, otherwise ignore - galleryInput.tag_ids = makeBulkUpdateIds(tagIds || [], tagMode); - } - } else { - // if tagIds non-empty, then we are setting them - galleryInput.tag_ids = makeBulkUpdateIds(tagIds || [], tagMode); - } + galleryInput.performer_ids = getAggregateInputIDs( + performerMode, + performerIds, + aggregatePerformerIds + ); + galleryInput.tag_ids = getAggregateInputIDs( + tagMode, + tagIds, + aggregateTagIds + ); if (organized !== undefined) { galleryInput.organized = organized; @@ -157,85 +110,6 @@ export const EditGalleriesDialog: React.FC = ( setIsUpdating(false); } - function getRating(state: GQL.SlimGalleryDataFragment[]) { - let ret: number | undefined; - let first = true; - - state.forEach((gallery) => { - if (first) { - ret = gallery.rating ?? undefined; - first = false; - } else if (ret !== gallery.rating) { - ret = undefined; - } - }); - - return ret; - } - - function getStudioId(state: GQL.SlimGalleryDataFragment[]) { - let ret: string | undefined; - let first = true; - - state.forEach((gallery) => { - if (first) { - ret = gallery?.studio?.id; - first = false; - } else { - const studio = gallery?.studio?.id; - if (ret !== studio) { - ret = undefined; - } - } - }); - - return ret; - } - - function getPerformerIds(state: GQL.SlimGalleryDataFragment[]) { - let ret: string[] = []; - let first = true; - - state.forEach((gallery) => { - if (first) { - ret = gallery.performers - ? gallery.performers.map((p) => p.id).sort() - : []; - first = false; - } else { - const perfIds = gallery.performers - ? gallery.performers.map((p) => p.id).sort() - : []; - - if (!_.isEqual(ret, perfIds)) { - ret = []; - } - } - }); - - return ret; - } - - function getTagIds(state: GQL.SlimGalleryDataFragment[]) { - let ret: string[] = []; - let first = true; - - state.forEach((gallery) => { - if (first) { - ret = gallery.tags ? gallery.tags.map((t) => t.id).sort() : []; - first = false; - } else { - const tIds = gallery.tags ? gallery.tags.map((t) => t.id).sort() : []; - - if (!_.isEqual(ret, tIds)) { - ret = []; - } - } - }); - - return ret; - } - useEffect(() => { const state = props.selected; let updateRating: number | undefined; diff --git a/ui/v2.5/src/components/Galleries/GalleryCard.tsx b/ui/v2.5/src/components/Galleries/GalleryCard.tsx index 5a72a737c..933278b8b 100644 --- a/ui/v2.5/src/components/Galleries/GalleryCard.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryCard.tsx @@ -111,7 +111,7 @@ export const GalleryCard: React.FC = (props) => { function maybeRenderOrganized() { if (props.gallery.organized) { return ( -
+
diff --git a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryDetailPanel.tsx b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryDetailPanel.tsx index 4c69a8bd0..46f00b4e1 100644 --- a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryDetailPanel.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryDetailPanel.tsx @@ -21,7 +21,9 @@ export const GalleryDetailPanel: React.FC = ({ if (!gallery.details) return; return ( <> -
Details
+
+ +

{gallery.details}

); @@ -34,7 +36,12 @@ export const GalleryDetailPanel: React.FC = ({ )); return ( <> -
Tags
+
+ +
{tags} ); @@ -53,7 +60,12 @@ export const GalleryDetailPanel: React.FC = ({ return ( <> -
Performers
+
+ +
{cards}
@@ -83,18 +95,19 @@ export const GalleryDetailPanel: React.FC = ({ ) : undefined} {gallery.rating ? (
- Rating: + :{" "} +
) : ( "" )}
:{" "} - {TextUtils.formatDate(intl, gallery.created_at)}{" "} + {TextUtils.formatDateTime(intl, gallery.created_at)}{" "}
:{" "} - {TextUtils.formatDate(intl, gallery.updated_at)}{" "} + {TextUtils.formatDateTime(intl, gallery.updated_at)}{" "}
{gallery.studio && ( diff --git a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryFileInfoPanel.tsx b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryFileInfoPanel.tsx index e03e96178..66ffcb2dd 100644 --- a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryFileInfoPanel.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryFileInfoPanel.tsx @@ -23,7 +23,7 @@ export const GalleryFileInfoPanel: React.FC = ( truncate /> = ( const checkboxRef = React.createRef(); - function makeBulkUpdateIds( - ids: string[], - mode: GQL.BulkUpdateIdMode - ): GQL.BulkUpdateIds { - return { - mode, - ids, - }; - } - function getImageInput(): GQL.BulkImageUpdateInput { // need to determine what we are actually setting on each image - const aggregateRating = getRating(props.selected); - const aggregateStudioId = getStudioId(props.selected); - const aggregatePerformerIds = getPerformerIds(props.selected); - const aggregateTagIds = getTagIds(props.selected); + const aggregateRating = getAggregateRating(props.selected); + const aggregateStudioId = getAggregateStudioId(props.selected); + const aggregatePerformerIds = getAggregatePerformerIds(props.selected); + const aggregateTagIds = getAggregateTagIds(props.selected); const imageInput: GQL.BulkImageUpdateInput = { ids: props.selected.map((image) => { @@ -65,67 +63,15 @@ export const EditImagesDialog: React.FC = ( }), }; - // if rating is undefined - if (rating === undefined) { - // and all images have the same rating, then we are unsetting the rating. - if (aggregateRating) { - // null rating to unset it - imageInput.rating = null; - } - // otherwise not setting the rating - } else { - // if rating is set, then we are setting the rating for all - imageInput.rating = rating; - } + imageInput.rating = getAggregateInputValue(rating, aggregateRating); + imageInput.studio_id = getAggregateInputValue(studioId, aggregateStudioId); - // if studioId is undefined - if (studioId === undefined) { - // and all images have the same studioId, - // then unset the studioId, otherwise ignoring studioId - if (aggregateStudioId) { - // null studio_id to unset it - imageInput.studio_id = null; - } - } else { - // if studioId is set, then we are setting it - imageInput.studio_id = studioId; - } - - // if performerIds are empty - if ( - performerMode === GQL.BulkUpdateIdMode.Set && - (!performerIds || performerIds.length === 0) - ) { - // and all images have the same ids, - if (aggregatePerformerIds.length > 0) { - // then unset the performerIds, otherwise ignore - imageInput.performer_ids = makeBulkUpdateIds( - performerIds || [], - performerMode - ); - } - } else { - // if performerIds non-empty, then we are setting them - imageInput.performer_ids = makeBulkUpdateIds( - performerIds || [], - performerMode - ); - } - - // if tagIds non-empty, then we are setting them - if ( - tagMode === GQL.BulkUpdateIdMode.Set && - (!tagIds || tagIds.length === 0) - ) { - // and all images have the same ids, - if (aggregateTagIds.length > 0) { - // then unset the tagIds, otherwise ignore - imageInput.tag_ids = makeBulkUpdateIds(tagIds || [], tagMode); - } - } else { - // if tagIds non-empty, then we are setting them - imageInput.tag_ids = makeBulkUpdateIds(tagIds || [], tagMode); - } + imageInput.performer_ids = getAggregateInputIDs( + performerMode, + performerIds, + aggregatePerformerIds + ); + imageInput.tag_ids = getAggregateInputIDs(tagMode, tagIds, aggregateTagIds); if (organized !== undefined) { imageInput.organized = organized; @@ -155,83 +101,6 @@ export const EditImagesDialog: React.FC = ( setIsUpdating(false); } - function getRating(state: GQL.SlimImageDataFragment[]) { - let ret: number | undefined; - let first = true; - - state.forEach((image: GQL.SlimImageDataFragment) => { - if (first) { - ret = image.rating ?? undefined; - first = false; - } else if (ret !== image.rating) { - ret = undefined; - } - }); - - return ret; - } - - function getStudioId(state: GQL.SlimImageDataFragment[]) { - let ret: string | undefined; - let first = true; - - state.forEach((image: GQL.SlimImageDataFragment) => { - if (first) { - ret = image?.studio?.id; - first = false; - } else { - const studio = image?.studio?.id; - if (ret !== studio) { - ret = undefined; - } - } - }); - - return ret; - } - - function getPerformerIds(state: GQL.SlimImageDataFragment[]) { - let ret: string[] = []; - let first = true; - - state.forEach((image: GQL.SlimImageDataFragment) => { - if (first) { - ret = image.performers ? image.performers.map((p) => p.id).sort() : []; - first = false; - } else { - const perfIds = image.performers - ? image.performers.map((p) => p.id).sort() - : []; - - if (!_.isEqual(ret, perfIds)) { - ret = []; - } - } - }); - - return ret; - } - - function getTagIds(state: GQL.SlimImageDataFragment[]) { - let ret: string[] = []; - let first = true; - - state.forEach((image: GQL.SlimImageDataFragment) => { - if (first) { - ret = image.tags ? image.tags.map((t) => t.id).sort() : []; - first = false; - } else { - const tIds = image.tags ? image.tags.map((t) => t.id).sort() : []; - - if (!_.isEqual(ret, tIds)) { - ret = []; - } - } - }); - - return ret; - } - useEffect(() => { const state = props.selected; let updateRating: number | undefined; diff --git a/ui/v2.5/src/components/Images/ImageCard.tsx b/ui/v2.5/src/components/Images/ImageCard.tsx index 0014b434c..73a61a4f4 100644 --- a/ui/v2.5/src/components/Images/ImageCard.tsx +++ b/ui/v2.5/src/components/Images/ImageCard.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { MouseEvent } from "react"; import { Button, ButtonGroup } from "react-bootstrap"; import cx from "classnames"; import * as GQL from "src/core/generated-graphql"; @@ -14,6 +14,7 @@ interface IImageCardProps { selected: boolean | undefined; zoomIndex: number; onSelectedChanged: (selected: boolean, shiftKey: boolean) => void; + onPreview?: (ev: MouseEvent) => void; } export const ImageCard: React.FC = ( @@ -49,7 +50,7 @@ export const ImageCard: React.FC = ( function maybeRenderOCounter() { if (props.image.o_counter) { return ( -
+
+ + ); + } + function maybeRenderOrganized() { if (props.image.organized) { return ( -
+
@@ -78,6 +100,7 @@ export const ImageCard: React.FC = ( props.image.tags.length > 0 || props.image.performers.length > 0 || props.image.o_counter || + props.image.galleries.length > 0 || props.image.organized ) { return ( @@ -87,6 +110,7 @@ export const ImageCard: React.FC = ( {maybeRenderTagPopoverButton()} {maybeRenderPerformerPopoverButton()} {maybeRenderOCounter()} + {maybeRenderGallery()} {maybeRenderOrganized()} @@ -119,6 +143,13 @@ export const ImageCard: React.FC = ( alt={props.image.title ?? ""} src={props.image.paths.thumbnail ?? ""} /> + {props.onPreview ? ( +
+ +
+ ) : undefined}
diff --git a/ui/v2.5/src/components/Images/ImageDetails/Image.tsx b/ui/v2.5/src/components/Images/ImageDetails/Image.tsx index cfa800fa5..b4144c8f9 100644 --- a/ui/v2.5/src/components/Images/ImageDetails/Image.tsx +++ b/ui/v2.5/src/components/Images/ImageDetails/Image.tsx @@ -34,7 +34,6 @@ export const Image: React.FC = () => { const { data, error, loading } = useFindImage(id); const image = data?.findImage; - const [oLoading, setOLoading] = useState(false); const [incrementO] = useImageIncrementO(image?.id ?? "0"); const [decrementO] = useImageDecrementO(image?.id ?? "0"); const [resetO] = useImageResetO(image?.id ?? "0"); @@ -87,34 +86,25 @@ export const Image: React.FC = () => { const onIncrementClick = async () => { try { - setOLoading(true); await incrementO(); } catch (e) { Toast.error(e); - } finally { - setOLoading(false); } }; const onDecrementClick = async () => { try { - setOLoading(true); await decrementO(); } catch (e) { Toast.error(e); - } finally { - setOLoading(false); } }; const onResetClick = async () => { try { - setOLoading(true); await resetO(); } catch (e) { Toast.error(e); - } finally { - setOLoading(false); } }; @@ -196,7 +186,6 @@ export const Image: React.FC = () => { = (props) => { )); return ( <> -
Tags
+
+ +
{tags} ); @@ -37,7 +42,12 @@ export const ImageDetailPanel: React.FC = (props) => { return ( <> -
Performers
+
+ +
{cards}
@@ -52,7 +62,12 @@ export const ImageDetailPanel: React.FC = (props) => { )); return ( <> -
Galleries
+
+ +
{tags} ); @@ -77,7 +92,8 @@ export const ImageDetailPanel: React.FC = (props) => {
{props.image.rating ? (
- Rating: + :{" "} +
) : ( "" @@ -85,7 +101,7 @@ export const ImageDetailPanel: React.FC = (props) => { {renderGalleries()} {props.image.file.width && props.image.file.height ? (
- Resolution:{" "} + :{" "} {TextUtils.resolution( props.image.file.width, props.image.file.height @@ -98,13 +114,13 @@ export const ImageDetailPanel: React.FC = (props) => {
{" "} :{" "} - {TextUtils.formatDate(intl, props.image.created_at)}{" "} + {TextUtils.formatDateTime(intl, props.image.created_at)}{" "}
} {
:{" "} - {TextUtils.formatDate(intl, props.image.updated_at)}{" "} + {TextUtils.formatDateTime(intl, props.image.updated_at)}{" "}
}
diff --git a/ui/v2.5/src/components/Images/ImageList.tsx b/ui/v2.5/src/components/Images/ImageList.tsx index f95624eb1..de3c42731 100644 --- a/ui/v2.5/src/components/Images/ImageList.tsx +++ b/ui/v2.5/src/components/Images/ImageList.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useState } from "react"; +import React, { useCallback, useState, useMemo, MouseEvent } from "react"; import { useIntl } from "react-intl"; import _ from "lodash"; import { useHistory } from "react-router-dom"; @@ -30,56 +30,10 @@ interface IImageWallProps { onChangePage: (page: number) => void; currentPage: number; pageCount: number; + handleImageOpen: (index: number) => void; } -const ImageWall: React.FC = ({ - images, - onChangePage, - currentPage, - pageCount, -}) => { - const [slideshowRunning, setSlideshowRunning] = useState(false); - const handleLightBoxPage = useCallback( - (direction: number) => { - if (direction === -1) { - if (currentPage === 1) { - onChangePage(pageCount); - } else { - onChangePage(currentPage - 1); - } - } else if (direction === 1) { - if (currentPage === pageCount) { - // return to the first page - onChangePage(1); - } else { - onChangePage(currentPage + 1); - } - } - }, - [onChangePage, currentPage, pageCount] - ); - - const handleClose = useCallback(() => { - setSlideshowRunning(false); - }, [setSlideshowRunning]); - - const showLightbox = useLightbox({ - images, - showNavigation: false, - pageCallback: pageCount > 1 ? handleLightBoxPage : undefined, - pageHeader: `Page ${currentPage} / ${pageCount}`, - slideshowEnabled: slideshowRunning, - onClose: handleClose, - }); - - const handleImageOpen = useCallback( - (index) => { - setSlideshowRunning(true); - showLightbox(index, true); - }, - [showLightbox] - ); - +const ImageWall: React.FC = ({ images, handleImageOpen }) => { const thumbs = images.map((image, index) => (
= ({ ); }; +interface IImageListImages { + images: SlimImageDataFragment[]; + filter: ListFilterModel; + selectedIds: Set; + onChangePage: (page: number) => void; + pageCount: number; + onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void; +} + +const ImageListImages: React.FC = ({ + images, + filter, + selectedIds, + onChangePage, + pageCount, + onSelectChange, +}) => { + const [slideshowRunning, setSlideshowRunning] = useState(false); + const handleLightBoxPage = useCallback( + (direction: number) => { + if (direction === -1) { + if (filter.currentPage === 1) { + onChangePage(pageCount); + } else { + onChangePage(filter.currentPage - 1); + } + } else if (direction === 1) { + if (filter.currentPage === pageCount) { + // return to the first page + onChangePage(1); + } else { + onChangePage(filter.currentPage + 1); + } + } + }, + [onChangePage, filter.currentPage, pageCount] + ); + + const handleClose = useCallback(() => { + setSlideshowRunning(false); + }, [setSlideshowRunning]); + + const lightboxState = useMemo(() => { + return { + images, + showNavigation: false, + pageCallback: pageCount > 1 ? handleLightBoxPage : undefined, + pageHeader: `Page ${filter.currentPage} / ${pageCount}`, + slideshowEnabled: slideshowRunning, + onClose: handleClose, + }; + }, [ + images, + pageCount, + filter.currentPage, + slideshowRunning, + handleClose, + handleLightBoxPage, + ]); + + const showLightbox = useLightbox(lightboxState); + + const handleImageOpen = useCallback( + (index) => { + setSlideshowRunning(true); + showLightbox(index, true); + }, + [showLightbox] + ); + + function onPreview(index: number, ev: MouseEvent) { + handleImageOpen(index); + ev.preventDefault(); + } + + function renderImageCard( + index: number, + image: SlimImageDataFragment, + zoomIndex: number + ) { + return ( + 0} + selected={selectedIds.has(image.id)} + onSelectedChanged={(selected: boolean, shiftKey: boolean) => + onSelectChange(image.id, selected, shiftKey) + } + onPreview={ + selectedIds.size < 1 ? (ev) => onPreview(index, ev) : undefined + } + /> + ); + } + + if (filter.displayMode === DisplayMode.Grid) { + return ( +
+ {images.map((image, index) => + renderImageCard(index, image, filter.zoomIndex) + )} +
+ ); + } + if (filter.displayMode === DisplayMode.Wall) { + return ( + + ); + } + + // should not happen + return <>; +}; + interface IImageList { filterHook?: (filter: ListFilterModel) => ListFilterModel; persistState?: PersistanceLevel; @@ -237,23 +313,8 @@ export const ImageList: React.FC = ({ ); } - function renderImageCard( - image: SlimImageDataFragment, - selectedIds: Set, - zoomIndex: number - ) { - return ( - 0} - selected={selectedIds.has(image.id)} - onSelectedChanged={(selected: boolean, shiftKey: boolean) => - onSelectChange(image.id, selected, shiftKey) - } - /> - ); + function selectChange(id: string, selected: boolean, shiftKey: boolean) { + onSelectChange(id, selected, shiftKey); } function renderImages( @@ -266,25 +327,17 @@ export const ImageList: React.FC = ({ if (!result.data || !result.data.findImages) { return; } - if (filter.displayMode === DisplayMode.Grid) { - return ( -
- {result.data.findImages.images.map((image) => - renderImageCard(image, selectedIds, filter.zoomIndex) - )} -
- ); - } - if (filter.displayMode === DisplayMode.Wall) { - return ( - - ); - } + + return ( + + ); } function renderContent( diff --git a/ui/v2.5/src/components/List/AddFilterDialog.tsx b/ui/v2.5/src/components/List/AddFilterDialog.tsx index d67e523b9..a370fab76 100644 --- a/ui/v2.5/src/components/List/AddFilterDialog.tsx +++ b/ui/v2.5/src/components/List/AddFilterDialog.tsx @@ -37,6 +37,7 @@ interface IAddFilterProps { onCancel: () => void; filterOptions: ListFilterOptions; editingCriterion?: Criterion; + existingCriterions: Criterion[]; } export const AddFilterDialog: React.FC = ({ @@ -44,6 +45,7 @@ export const AddFilterDialog: React.FC = ({ onCancel, filterOptions, editingCriterion, + existingCriterions, }) => { const defaultValue = useRef(); @@ -206,6 +208,10 @@ export const AddFilterDialog: React.FC = ({ const thisOptions = [NoneCriterionOption] .concat(filterOptions.criterionOptions) + .filter( + (c) => + !existingCriterions.find((ec) => ec.criterionOption.type === c.type) + ) .map((c) => { return { value: c.type, diff --git a/ui/v2.5/src/components/Movies/EditMoviesDialog.tsx b/ui/v2.5/src/components/Movies/EditMoviesDialog.tsx new file mode 100644 index 000000000..d93a82217 --- /dev/null +++ b/ui/v2.5/src/components/Movies/EditMoviesDialog.tsx @@ -0,0 +1,162 @@ +import React, { useEffect, useState } from "react"; +import { Form, Col, Row } from "react-bootstrap"; +import { FormattedMessage, useIntl } from "react-intl"; +import { useBulkMovieUpdate } from "src/core/StashService"; +import * as GQL from "src/core/generated-graphql"; +import { Modal, StudioSelect } from "src/components/Shared"; +import { useToast } from "src/hooks"; +import { FormUtils } from "src/utils"; +import { RatingStars } from "../Scenes/SceneDetails/RatingStars"; +import { + getAggregateInputValue, + getAggregateRating, + getAggregateStudioId, +} from "src/utils/bulkUpdate"; + +interface IListOperationProps { + selected: GQL.MovieDataFragment[]; + onClose: (applied: boolean) => void; +} + +export const EditMoviesDialog: React.FC = ( + props: IListOperationProps +) => { + const intl = useIntl(); + const Toast = useToast(); + const [rating, setRating] = useState(); + const [studioId, setStudioId] = useState(); + const [director, setDirector] = useState(); + + const [updateMovies] = useBulkMovieUpdate(getMovieInput()); + + const [isUpdating, setIsUpdating] = useState(false); + + function getMovieInput(): GQL.BulkMovieUpdateInput { + const aggregateRating = getAggregateRating(props.selected); + const aggregateStudioId = getAggregateStudioId(props.selected); + + const movieInput: GQL.BulkMovieUpdateInput = { + ids: props.selected.map((movie) => movie.id), + director, + }; + + // if rating is undefined + movieInput.rating = getAggregateInputValue(rating, aggregateRating); + movieInput.studio_id = getAggregateInputValue(studioId, aggregateStudioId); + + return movieInput; + } + + async function onSave() { + setIsUpdating(true); + try { + await updateMovies(); + Toast.success({ + content: intl.formatMessage( + { id: "toast.updated_entity" }, + { + entity: intl.formatMessage({ id: "movies" }).toLocaleLowerCase(), + } + ), + }); + props.onClose(true); + } catch (e) { + Toast.error(e); + } + setIsUpdating(false); + } + + useEffect(() => { + const state = props.selected; + let updateRating: number | undefined; + let updateStudioId: string | undefined; + let updateDirector: string | undefined; + let first = true; + + state.forEach((movie: GQL.MovieDataFragment) => { + if (first) { + first = false; + updateRating = movie.rating ?? undefined; + updateStudioId = movie.studio?.id ?? undefined; + updateDirector = movie.director ?? undefined; + } else { + if (movie.rating !== updateRating) { + updateRating = undefined; + } + if (movie.studio?.id !== updateStudioId) { + updateStudioId = undefined; + } + if (movie.director !== updateDirector) { + updateDirector = undefined; + } + } + }); + + setRating(updateRating); + setStudioId(updateStudioId); + setDirector(updateDirector); + }, [props.selected]); + + function render() { + return ( + props.onClose(false), + text: intl.formatMessage({ id: "actions.cancel" }), + variant: "secondary", + }} + isRunning={isUpdating} + > +
+ + {FormUtils.renderLabel({ + title: intl.formatMessage({ id: "rating" }), + })} + + setRating(value)} + disabled={isUpdating} + /> + + + + {FormUtils.renderLabel({ + title: intl.formatMessage({ id: "studio" }), + })} + + + setStudioId(items.length > 0 ? items[0]?.id : undefined) + } + ids={studioId ? [studioId] : []} + isDisabled={isUpdating} + /> + + + + + + + setDirector(event.currentTarget.value)} + placeholder={intl.formatMessage({ id: "director" })} + /> + +
+
+ ); + } + + return render(); +}; diff --git a/ui/v2.5/src/components/Movies/MovieCard.tsx b/ui/v2.5/src/components/Movies/MovieCard.tsx index 28a1d8252..423768872 100644 --- a/ui/v2.5/src/components/Movies/MovieCard.tsx +++ b/ui/v2.5/src/components/Movies/MovieCard.tsx @@ -85,12 +85,14 @@ export const MovieCard: FunctionComponent = (props: IProps) => { } details={ - <> - {props.movie.date} -

- -

- +
+ {props.movie.date} + +
} selected={props.selected} selecting={props.selecting} diff --git a/ui/v2.5/src/components/Movies/MovieDetails/MovieEditPanel.tsx b/ui/v2.5/src/components/Movies/MovieDetails/MovieEditPanel.tsx index e07e8bf3c..64d70b3e1 100644 --- a/ui/v2.5/src/components/Movies/MovieDetails/MovieEditPanel.tsx +++ b/ui/v2.5/src/components/Movies/MovieDetails/MovieEditPanel.tsx @@ -470,7 +470,7 @@ export const MovieEditPanel: React.FC = ({ ListFilterModel; @@ -57,6 +59,17 @@ export const MovieList: React.FC = ({ filterHook }) => { }; }; + function renderEditDialog( + selectedMovies: MovieDataFragment[], + onClose: (applied: boolean) => void + ) { + return ( + <> + + + ); + } + const renderDeleteDialog = ( selectedMovies: SlimMovieDataFragment[], onClose: (confirmed: boolean) => void @@ -76,6 +89,7 @@ export const MovieList: React.FC = ({ filterHook }) => { otherOperations, selectable: true, persistState: PersistanceLevel.ALL, + renderEditDialog, renderDeleteDialog, filterHook, }); diff --git a/ui/v2.5/src/components/Movies/styles.scss b/ui/v2.5/src/components/Movies/styles.scss index 773b04392..09cfa97da 100644 --- a/ui/v2.5/src/components/Movies/styles.scss +++ b/ui/v2.5/src/components/Movies/styles.scss @@ -17,6 +17,10 @@ .movie-scene-number { text-align: center; } + + &__details { + margin-bottom: 1rem; + } } .movie-images { diff --git a/ui/v2.5/src/components/Performers/EditPerformersDialog.tsx b/ui/v2.5/src/components/Performers/EditPerformersDialog.tsx index 99e7f6196..83d135c75 100644 --- a/ui/v2.5/src/components/Performers/EditPerformersDialog.tsx +++ b/ui/v2.5/src/components/Performers/EditPerformersDialog.tsx @@ -9,6 +9,13 @@ import { useToast } from "src/hooks"; import { FormUtils } from "src/utils"; import MultiSet from "../Shared/MultiSet"; import { RatingStars } from "../Scenes/SceneDetails/RatingStars"; +import { + getAggregateInputIDs, + getAggregateInputValue, + getAggregateRating, + getAggregateTagIds, +} from "src/utils/bulkUpdate"; +import { genderStrings, stringToGender } from "src/utils/gender"; interface IListOperationProps { selected: GQL.SlimPerformerDataFragment[]; @@ -27,6 +34,16 @@ export const EditPerformersDialog: React.FC = ( const [tagIds, setTagIds] = useState(); const [existingTagIds, setExistingTagIds] = useState(); const [favorite, setFavorite] = useState(); + const [ethnicity, setEthnicity] = useState(); + const [country, setCountry] = useState(); + const [eyeColor, setEyeColor] = useState(); + const [fakeTits, setFakeTits] = useState(); + const [careerLength, setCareerLength] = useState(); + const [tattoos, setTattoos] = useState(); + const [piercings, setPiercings] = useState(); + const [hairColor, setHairColor] = useState(); + const [gender, setGender] = useState(); + const genderOptions = [""].concat(genderStrings); const [updatePerformers] = useBulkPerformerUpdate(getPerformerInput()); @@ -35,20 +52,10 @@ export const EditPerformersDialog: React.FC = ( const checkboxRef = React.createRef(); - function makeBulkUpdateIds( - ids: string[], - mode: GQL.BulkUpdateIdMode - ): GQL.BulkUpdateIds { - return { - mode, - ids, - }; - } - function getPerformerInput(): GQL.BulkPerformerUpdateInput { // need to determine what we are actually setting on each performer - const aggregateTagIds = getTagIds(props.selected); - const aggregateRating = getRating(props.selected); + const aggregateTagIds = getAggregateTagIds(props.selected); + const aggregateRating = getAggregateRating(props.selected); const performerInput: GQL.BulkPerformerUpdateInput = { ids: props.selected.map((performer) => { @@ -56,37 +63,24 @@ export const EditPerformersDialog: React.FC = ( }), }; - // if rating is undefined - if (rating === undefined) { - // and all galleries have the same rating, then we are unsetting the rating. - if (aggregateRating) { - // null to unset rating - performerInput.rating = null; - } - // otherwise not setting the rating - } else { - // if rating is set, then we are setting the rating for all - performerInput.rating = rating; - } + performerInput.rating = getAggregateInputValue(rating, aggregateRating); - // if tagIds non-empty, then we are setting them - if ( - tagMode === GQL.BulkUpdateIdMode.Set && - (!tagIds || tagIds.length === 0) - ) { - // and all performers have the same ids, - if (aggregateTagIds.length > 0) { - // then unset the tagIds, otherwise ignore - performerInput.tag_ids = makeBulkUpdateIds(tagIds || [], tagMode); - } - } else { - // if tagIds non-empty, then we are setting them - performerInput.tag_ids = makeBulkUpdateIds(tagIds || [], tagMode); - } + performerInput.tag_ids = getAggregateInputIDs( + tagMode, + tagIds, + aggregateTagIds + ); - if (favorite !== undefined) { - performerInput.favorite = favorite; - } + performerInput.favorite = favorite; + performerInput.ethnicity = ethnicity; + performerInput.country = country; + performerInput.eye_color = eyeColor; + performerInput.fake_tits = fakeTits; + performerInput.career_length = careerLength; + performerInput.tattoos = tattoos; + performerInput.piercings = piercings; + performerInput.hair_color = hairColor; + performerInput.gender = gender; return performerInput; } @@ -112,49 +106,12 @@ export const EditPerformersDialog: React.FC = ( setIsUpdating(false); } - function getTagIds(state: GQL.SlimPerformerDataFragment[]) { - let ret: string[] = []; - let first = true; - - state.forEach((performer: GQL.SlimPerformerDataFragment) => { - if (first) { - ret = performer.tags ? performer.tags.map((t) => t.id).sort() : []; - first = false; - } else { - const tIds = performer.tags - ? performer.tags.map((t) => t.id).sort() - : []; - - if (!_.isEqual(ret, tIds)) { - ret = []; - } - } - }); - - return ret; - } - - function getRating(state: GQL.SlimPerformerDataFragment[]) { - let ret: number | undefined; - let first = true; - - state.forEach((performer) => { - if (first) { - ret = performer.rating ?? undefined; - first = false; - } else if (ret !== performer.rating) { - ret = undefined; - } - }); - - return ret; - } - useEffect(() => { const state = props.selected; let updateTagIds: string[] = []; let updateFavorite: boolean | undefined; let updateRating: number | undefined; + let updateGender: GQL.GenderEnum | undefined; let first = true; state.forEach((performer: GQL.SlimPerformerDataFragment) => { @@ -166,6 +123,7 @@ export const EditPerformersDialog: React.FC = ( first = false; updateFavorite = performer.favorite; updateRating = performerRating ?? undefined; + updateGender = performer.gender ?? undefined; } else { if (!_.isEqual(performerTagIDs, updateTagIds)) { updateTagIds = []; @@ -176,12 +134,26 @@ export const EditPerformersDialog: React.FC = ( if (performerRating !== updateRating) { updateRating = undefined; } + if (performer.gender !== updateGender) { + updateGender = undefined; + } } }); setExistingTagIds(updateTagIds); setFavorite(updateFavorite); setRating(updateRating); + setGender(updateGender); + + // these fields are not part of SlimPerformerDataFragment + setEthnicity(undefined); + setCountry(undefined); + setEyeColor(undefined); + setFakeTits(undefined); + setCareerLength(undefined); + setTattoos(undefined); + setPiercings(undefined); + setHairColor(undefined); }, [props.selected, tagMode]); useEffect(() => { @@ -200,6 +172,27 @@ export const EditPerformersDialog: React.FC = ( } } + function renderTextField( + name: string, + value: string | undefined, + setter: (newValue: string | undefined) => void + ) { + return ( + + + + + setter(event.currentTarget.value)} + placeholder={intl.formatMessage({ id: name })} + /> + + ); + } + function render() { return ( = (
+ + cycleFavorite()} + /> + + + + + + + + setGender(stringToGender(event.currentTarget.value)) + } + > + {genderOptions.map((opt) => ( + + ))} + + + + {renderTextField("country", country, setCountry)} + {renderTextField("ethnicity", ethnicity, setEthnicity)} + {renderTextField("hair_color", hairColor, setHairColor)} + {renderTextField("eye_color", eyeColor, setEyeColor)} + {renderTextField("fake_tits", fakeTits, setFakeTits)} + {renderTextField("tattoos", tattoos, setTattoos)} + {renderTextField("piercings", piercings, setPiercings)} + {renderTextField("career_length", careerLength, setCareerLength)} + @@ -244,16 +275,6 @@ export const EditPerformersDialog: React.FC = ( mode={tagMode} /> - - - cycleFavorite()} - /> -
); diff --git a/ui/v2.5/src/components/Performers/GenderIcon.tsx b/ui/v2.5/src/components/Performers/GenderIcon.tsx new file mode 100644 index 000000000..516e70dbd --- /dev/null +++ b/ui/v2.5/src/components/Performers/GenderIcon.tsx @@ -0,0 +1,36 @@ +import React from "react"; +import { + faVenus, + faTransgenderAlt, + faMars, +} from "@fortawesome/free-solid-svg-icons"; +import * as GQL from "src/core/generated-graphql"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { useIntl } from "react-intl"; + +interface IIconProps { + gender?: GQL.Maybe; + className?: string; +} + +const GenderIcon: React.FC = ({ gender, className }) => { + const intl = useIntl(); + if (gender) { + const icon = + gender === GQL.GenderEnum.Male + ? faMars + : gender === GQL.GenderEnum.Female + ? faVenus + : faTransgenderAlt; + return ( + + ); + } + return null; +}; + +export default GenderIcon; diff --git a/ui/v2.5/src/components/Performers/PerformerCard.tsx b/ui/v2.5/src/components/Performers/PerformerCard.tsx index 619511e70..937a2d81b 100644 --- a/ui/v2.5/src/components/Performers/PerformerCard.tsx +++ b/ui/v2.5/src/components/Performers/PerformerCard.tsx @@ -16,6 +16,7 @@ import { CriterionValue, } from "src/models/list-filter/criteria/criterion"; import { PopoverCountButton } from "../Shared/PopoverCountButton"; +import GenderIcon from "./GenderIcon"; export interface IPerformerCardExtraCriteria { scenes: Criterion[]; @@ -179,10 +180,29 @@ export const PerformerCard: React.FC = ({ ); } + function maybeRenderFlag() { + if (performer.country) { + return ( + + + + {performer.country} + + + ); + } + } + return ( + } title={performer.name ?? ""} image={ <> @@ -193,14 +213,16 @@ export const PerformerCard: React.FC = ({ /> {maybeRenderFavoriteIcon()} {maybeRenderRatingBanner()} + {maybeRenderFlag()} } details={ <> - {age !== 0 ?
{ageString}
: ""} - - - + {age !== 0 ? ( +
{ageString}
+ ) : ( + "" + )} {maybeRenderPopoverButtonGroup()} } diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx index c31f7ef0a..8f7d16a8f 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, { useEffect, useMemo, useState } from "react"; -import { Button, Tabs, Tab } from "react-bootstrap"; +import { Button, Tabs, Tab, Badge, Col, Row } from "react-bootstrap"; import { FormattedMessage, useIntl } from "react-intl"; import { useParams, useHistory } from "react-router-dom"; import { Helmet } from "react-helmet"; @@ -10,9 +10,11 @@ import { useFindPerformer, usePerformerUpdate, usePerformerDestroy, + mutateMetadataAutoTag, } from "src/core/StashService"; import { CountryFlag, + DetailsEditNavbar, ErrorMessage, Icon, LoadingIndicator, @@ -21,12 +23,13 @@ import { useLightbox, useToast } from "src/hooks"; import { TextUtils } from "src/utils"; import { RatingStars } from "src/components/Scenes/SceneDetails/RatingStars"; import { PerformerDetailsPanel } from "./PerformerDetailsPanel"; -import { PerformerOperationsPanel } from "./PerformerOperationsPanel"; import { PerformerScenesPanel } from "./PerformerScenesPanel"; import { PerformerGalleriesPanel } from "./PerformerGalleriesPanel"; import { PerformerMoviesPanel } from "./PerformerMoviesPanel"; import { PerformerImagesPanel } from "./PerformerImagesPanel"; import { PerformerEditPanel } from "./PerformerEditPanel"; +import { PerformerSubmitButton } from "./PerformerSubmitButton"; +import GenderIcon from "../GenderIcon"; interface IProps { performer: GQL.PerformerDataFragment; @@ -43,6 +46,7 @@ const PerformerPage: React.FC = ({ performer }) => { const [imagePreview, setImagePreview] = useState(); const [imageEncoding, setImageEncoding] = useState(false); + const [isEditing, setIsEditing] = useState(false); // if undefined then get the existing image // if null then get the default (no) image @@ -67,9 +71,7 @@ const PerformerPage: React.FC = ({ performer }) => { tab === "scenes" || tab === "galleries" || tab === "images" || - tab === "movies" || - tab === "edit" || - tab === "operations" + tab === "movies" ? tab : "details"; const setActiveTabKey = (newTab: string | null) => { @@ -83,14 +85,24 @@ const PerformerPage: React.FC = ({ performer }) => { const onImageEncoding = (isEncoding = false) => setImageEncoding(isEncoding); + async function onAutoTag() { + try { + await mutateMetadataAutoTag({ performers: [performer.id] }); + Toast.success({ + content: intl.formatMessage({ id: "toast.started_auto_tagging" }), + }); + } catch (e) { + Toast.error(e); + } + } + // set up hotkeys useEffect(() => { Mousetrap.bind("a", () => setActiveTabKey("details")); - Mousetrap.bind("e", () => setActiveTabKey("edit")); + Mousetrap.bind("e", () => setIsEditing(!isEditing)); Mousetrap.bind("c", () => setActiveTabKey("scenes")); Mousetrap.bind("g", () => setActiveTabKey("galleries")); Mousetrap.bind("m", () => setActiveTabKey("movies")); - Mousetrap.bind("o", () => setActiveTabKey("operations")); Mousetrap.bind("f", () => setFavorite(!performer.favorite)); // numeric keypresses get caught by jwplayer, so blur the element @@ -138,45 +150,114 @@ const PerformerPage: React.FC = ({ performer }) => { } const renderTabs = () => ( - - - - - - - - - - - - - - - - - + + + + { + setIsEditing(!isEditing); + }} + onDelete={onDelete} + onAutoTag={onAutoTag} + isNew={false} + isEditing={false} + onSave={() => {}} + onImageChange={() => {}} + classNames="mb-2" + customButtons={ +
+ +
+ } + >
+
+ + + + + + + {intl.formatMessage({ id: "scenes" })} + + {intl.formatNumber(performer.scene_count ?? 0)} + +
+ } + > + +
+ + {intl.formatMessage({ id: "galleries" })} + + {intl.formatNumber(performer.gallery_count ?? 0)} + + + } + > + + + + {intl.formatMessage({ id: "images" })} + + {intl.formatNumber(performer.image_count ?? 0)} + + + } + > + + + + {intl.formatMessage({ id: "movies" })} + + {intl.formatNumber(performer.movie_count ?? 0)} + + + } + > + + +
+ + ); + + function renderTabsOrEditPanel() { + if (isEditing) { + return ( { + setIsEditing(false); + }} /> - - - - - - ); + ); + } else { + return renderTabs(); + } + } function maybeRenderAge() { if (performer?.birthdate) { @@ -235,7 +316,7 @@ const PerformerPage: React.FC = ({ performer }) => { } } - const renderIcons = () => ( + const renderClickableIcons = () => (
diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerDetailsPanel.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerDetailsPanel.tsx index 03223a9c3..63c279eca 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerDetailsPanel.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerDetailsPanel.tsx @@ -2,9 +2,8 @@ import React from "react"; import { FormattedMessage, useIntl } from "react-intl"; import { TagLink } from "src/components/Shared"; import * as GQL from "src/core/generated-graphql"; -import { TextUtils } from "src/utils"; +import { TextUtils, getStashboxBase } from "src/utils"; import { TextField, URLField } from "src/utils/field"; -import { genderToString } from "src/utils/gender"; interface IPerformerDetails { performer: GQL.PerformerDataFragment; @@ -48,7 +47,7 @@ export const PerformerDetailsPanel: React.FC = ({