mirror of
https://github.com/stashapp/stash.git
synced 2025-12-18 12:54:38 +03:00
Merge pull request #2369 from stashapp/develop
Merge develop to master for 0.13
This commit is contained in:
24
.github/workflows/build.yml
vendored
24
.github/workflows/build.yml
vendored
@@ -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
|
||||
|
||||
|
||||
2
.github/workflows/golangci-lint.yml
vendored
2
.github/workflows/golangci-lint.yml
vendored
@@ -9,7 +9,7 @@ on:
|
||||
pull_request:
|
||||
|
||||
env:
|
||||
COMPILER_IMAGE: stashapp/compiler:5
|
||||
COMPILER_IMAGE: stashapp/compiler:6
|
||||
|
||||
jobs:
|
||||
golangci:
|
||||
|
||||
77
Makefile
77
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
|
||||
|
||||
26
README.md
26
README.md
@@ -2,7 +2,7 @@
|
||||
https://stashapp.cc
|
||||
|
||||
[](https://github.com/stashapp/stash/actions/workflows/build.yml)
|
||||
[](https://hub.docker.com/r/stashapp/Stash 'DockerHub')
|
||||
[](https://hub.docker.com/r/stashapp/stash 'DockerHub')
|
||||
[](https://goreportcard.com/report/github.com/stashapp/stash)
|
||||
[](https://discord.gg/2TsNFKt)
|
||||
|
||||
@@ -22,30 +22,32 @@ For further information you can [read the in-app manual](ui/v2.5/src/docs/en).
|
||||
|
||||
<img src="docs/readme_assets/windows_logo.svg" width="100%" height="75"> Windows | <img src="docs/readme_assets/mac_logo.svg" width="100%" height="75"> MacOS| <img src="docs/readme_assets/linux_logo.svg" width="100%" height="75"> Linux | <img src="docs/readme_assets/docker_logo.svg" width="100%" height="75"> Docker
|
||||
:---:|:---:|:---:|:---:
|
||||
[Latest Release](https://github.com/stashapp/stash/releases/latest/download/stash-win.exe) <br /> <sup><sub>[Development Preview](https://github.com/stashapp/stash/releases/download/latest_develop/stash-win.exe)</sub></sup> | [Latest Release (Apple Silicon)](https://github.com/stashapp/stash/releases/latest/download/stash-osx-applesilicon) <br /> <sup><sub>[Development Preview (Apple Silicon)](https://github.com/stashapp/stash/releases/download/latest_develop/stash-osx-applesilicon)</sub></sup> <br>[Latest Release (Intel)](https://github.com/stashapp/stash/releases/latest/download/stash-osx) <br /> <sup><sub>[Development Preview (Intel)](https://github.com/stashapp/stash/releases/download/latest_develop/stash-osx)</sub></sup> | [Latest Release (amd64)](https://github.com/stashapp/stash/releases/latest/download/stash-linux) <br /> <sup><sub>[Development Preview (amd64)](https://github.com/stashapp/stash/releases/download/latest_develop/stash-linux)</sub></sup> <br /> [More Architectures...](https://github.com/stashapp/stash/releases/latest) | [Instructions](docker/production/README.md) <br /> <sup><sub> [Sample docker-compose.yml](docker/production/docker-compose.yml)</sub></sup>
|
||||
|
||||
## 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) <br /> <sup><sub>[Development Preview](https://github.com/stashapp/stash/releases/download/latest_develop/stash-win.exe)</sub></sup> | [Latest Release (Apple Silicon)](https://github.com/stashapp/stash/releases/latest/download/stash-macos-applesilicon) <br /> <sup><sub>[Development Preview (Apple Silicon)](https://github.com/stashapp/stash/releases/download/latest_develop/stash-macos-applesilicon)</sub></sup> <br />[Latest Release (Intel)](https://github.com/stashapp/stash/releases/latest/download/stash-macos-intel) <br /> <sup><sub>[Development Preview (Intel)](https://github.com/stashapp/stash/releases/download/latest_develop/stash-macos-intel)</sub></sup> | [Latest Release (amd64)](https://github.com/stashapp/stash/releases/latest/download/stash-linux) <br /> <sup><sub>[Development Preview (amd64)](https://github.com/stashapp/stash/releases/download/latest_develop/stash-linux)</sub></sup> <br /> [More Architectures...](https://github.com/stashapp/stash/releases/latest) | [Instructions](docker/production/README.md) <br /> <sup><sub> [Sample docker-compose.yml](docker/production/docker-compose.yml)</sub></sup>
|
||||
|
||||
## 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.
|
||||
|
||||
<sub>StashDB is the canonical instance of our open source metadata API, [stash-box](https://github.com/stashapp/stash-box).</sub>
|
||||
|
||||
# Translation
|
||||
[](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)
|
||||
|
||||
|
||||
@@ -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; \
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
user=stashapp
|
||||
repo=compiler
|
||||
version=5
|
||||
version=6
|
||||
|
||||
latest:
|
||||
docker build -t ${user}/${repo}:latest .
|
||||
|
||||
@@ -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.
|
||||
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.
|
||||
|
||||
14
go.mod
14
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
|
||||
|
||||
26
go.sum
26
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=
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -22,6 +22,12 @@ mutation MovieUpdate($input: MovieUpdateInput!) {
|
||||
}
|
||||
}
|
||||
|
||||
mutation BulkMovieUpdate($input: BulkMovieUpdateInput!) {
|
||||
bulkMovieUpdate(input: $input) {
|
||||
...MovieData
|
||||
}
|
||||
}
|
||||
|
||||
mutation MovieDestroy($id: ID!) {
|
||||
movieDestroy(input: { id: $id })
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -11,3 +11,10 @@ query Directory($path: String) {
|
||||
directories
|
||||
}
|
||||
}
|
||||
|
||||
query ValidateStashBox($input: StashBoxInput!) {
|
||||
validateStashBoxCredentials(input: $input) {
|
||||
valid
|
||||
status
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"""
|
||||
@@ -208,6 +208,9 @@ input ConfigInterfaceInput {
|
||||
"""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
|
||||
"""If true, video will autostart on load in the scene player"""
|
||||
@@ -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!
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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!
|
||||
}
|
||||
|
||||
@@ -24,3 +24,8 @@ input StashBoxFingerprintSubmissionInput {
|
||||
scene_ids: [String!]!
|
||||
stash_box_index: Int!
|
||||
}
|
||||
|
||||
input StashBoxDraftSubmissionInput {
|
||||
id: String!
|
||||
stash_box_index: Int!
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
21
main.go
21
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() {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
28
pkg/api/favicon.go
Normal file
28
pkg/api/favicon.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,9 +250,8 @@ func Start(uiBox embed.FS, loginUIBox embed.FS) {
|
||||
TLSConfig: tlsConfig,
|
||||
}
|
||||
|
||||
go func() {
|
||||
printVersion()
|
||||
printLatestVersion(context.TODO())
|
||||
go printLatestVersion(context.TODO())
|
||||
logger.Infof("stash is listening on " + address)
|
||||
if tlsConfig != nil {
|
||||
displayAddress = "https://" + displayAddress + "/"
|
||||
@@ -255,15 +259,7 @@ func Start(uiBox embed.FS, loginUIBox embed.FS) {
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
||||
go func() {
|
||||
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 != "" {
|
||||
|
||||
170
pkg/desktop/desktop.go
Normal file
170
pkg/desktop/desktop.go
Normal file
@@ -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
|
||||
}
|
||||
40
pkg/desktop/desktop_platform_darwin.go
Normal file
40
pkg/desktop/desktop_platform_darwin.go
Normal file
@@ -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)
|
||||
}
|
||||
43
pkg/desktop/desktop_platform_linux.go
Normal file
43
pkg/desktop/desktop_platform_linux.go
Normal file
@@ -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) {
|
||||
|
||||
}
|
||||
56
pkg/desktop/desktop_platform_windows.go
Normal file
56
pkg/desktop/desktop_platform_windows.go
Normal file
@@ -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)
|
||||
}
|
||||
BIN
pkg/desktop/icon_windows.syso
Normal file
BIN
pkg/desktop/icon_windows.syso
Normal file
Binary file not shown.
10
pkg/desktop/systray_linux.go
Normal file
10
pkg/desktop/systray_linux.go
Normal file
@@ -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.
|
||||
}
|
||||
95
pkg/desktop/systray_nonlinux.go
Normal file
95
pkg/desktop/systray_nonlinux.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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, "/") {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
@@ -181,6 +185,10 @@ const (
|
||||
// Desktop Integration Options
|
||||
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,6 +234,7 @@ type Instance struct {
|
||||
|
||||
cpuProfilePath string
|
||||
isNewSystem bool
|
||||
// configUpdates chan int
|
||||
certFile string
|
||||
keyFile string
|
||||
sync.RWMutex
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,11 +93,14 @@ 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
|
||||
|
||||
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
|
||||
|
||||
@@ -87,12 +108,37 @@ func (g *SpriteGenerator) generateSpriteImage(encoder *ffmpeg.Encoder) error {
|
||||
Time: time,
|
||||
Width: 160,
|
||||
}
|
||||
|
||||
img, err := encoder.SpriteScreenshot(g.Info.VideoFile, options)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
images = append(images, img)
|
||||
}
|
||||
} 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)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
if len(images) == 0 {
|
||||
return fmt.Errorf("images slice is empty, failed to generate sprite images for %s", g.Info.VideoFile.Path)
|
||||
@@ -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 (<ChunkCount) g.Info.NthFrame can be zero
|
||||
// so recalculate from scratch
|
||||
stepSize = float64(g.Info.VideoFile.FrameCount-1) / float64(g.Info.ChunkCount)
|
||||
stepSize /= g.Info.FrameRate
|
||||
}
|
||||
|
||||
vttLines := []string{"WEBVTT", ""}
|
||||
for index := 0; index < g.Info.ChunkCount; index++ {
|
||||
|
||||
@@ -2,6 +2,8 @@ package manager
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"github.com/stashapp/stash/pkg/file"
|
||||
"github.com/stashapp/stash/pkg/manager/config"
|
||||
"strings"
|
||||
|
||||
"github.com/stashapp/stash/pkg/logger"
|
||||
@@ -14,20 +16,26 @@ func walkGalleryZip(path string, walkFunc func(file *zip.File) error) error {
|
||||
}
|
||||
defer readCloser.Close()
|
||||
|
||||
for _, file := range readCloser.File {
|
||||
if file.FileInfo().IsDir() {
|
||||
excludeImgRegex := generateRegexps(config.GetInstance().GetImageExcludes())
|
||||
|
||||
for _, f := range readCloser.File {
|
||||
if f.FileInfo().IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.Contains(file.Name, "__MACOSX") {
|
||||
if strings.Contains(f.Name, "__MACOSX") {
|
||||
continue
|
||||
}
|
||||
|
||||
if !isImage(file.Name) {
|
||||
if !isImage(f.Name) {
|
||||
continue
|
||||
}
|
||||
|
||||
err := walkFunc(file)
|
||||
if matchFileRegex(file.ZipFile(path, f).Path(), excludeImgRegex) {
|
||||
continue
|
||||
}
|
||||
|
||||
err := walkFunc(f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -4,12 +4,9 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"runtime/pprof"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -407,34 +404,6 @@ func (s *singleton) Migrate(ctx context.Context, input models.MigrateInput) erro
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *singleton) IsDesktop() bool {
|
||||
// check if running under root
|
||||
if os.Getuid() == 0 {
|
||||
return false
|
||||
}
|
||||
// check if started by init, e.g. stash is a *nix systemd service / MacOS launchd service
|
||||
if os.Getppid() == 1 {
|
||||
return false
|
||||
}
|
||||
if IsServerDockerized() {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func IsServerDockerized() bool {
|
||||
if runtime.GOOS == "linux" {
|
||||
_, dockerEnvErr := os.Stat("/.dockerenv")
|
||||
cgroups, _ := ioutil.ReadFile("/proc/self/cgroup")
|
||||
if os.IsExist(dockerEnvErr) || strings.Contains(string(cgroups), "docker") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *singleton) GetSystemStatus() *models.SystemStatus {
|
||||
status := models.SystemStatusEnumOk
|
||||
dbSchema := int(database.Version())
|
||||
@@ -458,8 +427,15 @@ func (s *singleton) GetSystemStatus() *models.SystemStatus {
|
||||
}
|
||||
|
||||
// Shutdown gracefully stops the manager
|
||||
func (s *singleton) Shutdown() error {
|
||||
func (s *singleton) Shutdown(code int) {
|
||||
// TODO: Each part of the manager needs to gracefully stop at some point
|
||||
// for now, we just close the database.
|
||||
return database.Close()
|
||||
err := database.Close()
|
||||
if err != nil {
|
||||
logger.Errorf("Error closing database: %s", err)
|
||||
if code == 0 {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
os.Exit(code)
|
||||
}
|
||||
|
||||
@@ -135,6 +135,8 @@ func (t *StashBoxPerformerTagTask) stashBoxPerformerTag(ctx context.Context) {
|
||||
if excluded["name"] && performer.Name != nil {
|
||||
value := sql.NullString{String: *performer.Name, Valid: true}
|
||||
partial.Name = &value
|
||||
checksum := utils.MD5FromString(*performer.Name)
|
||||
partial.Checksum = &checksum
|
||||
}
|
||||
if performer.Piercings != nil && !excluded["piercings"] {
|
||||
value := getNullString(performer.Piercings)
|
||||
@@ -145,7 +147,7 @@ func (t *StashBoxPerformerTagTask) stashBoxPerformerTag(ctx context.Context) {
|
||||
partial.Tattoos = &value
|
||||
}
|
||||
if performer.Twitter != nil && !excluded["twitter"] {
|
||||
value := getNullString(performer.Tattoos)
|
||||
value := getNullString(performer.Twitter)
|
||||
partial.Twitter = &value
|
||||
}
|
||||
if performer.URL != nil && !excluded["url"] {
|
||||
@@ -261,7 +263,7 @@ func getDate(val *string) models.SQLiteDate {
|
||||
if val == nil {
|
||||
return models.SQLiteDate{Valid: false}
|
||||
} else {
|
||||
return models.SQLiteDate{String: *val, Valid: false}
|
||||
return models.SQLiteDate{String: *val, Valid: true}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/stashapp/stash/pkg/gallery"
|
||||
"github.com/stashapp/stash/pkg/image"
|
||||
@@ -12,7 +13,12 @@ import (
|
||||
"github.com/stashapp/stash/pkg/scene"
|
||||
)
|
||||
|
||||
const separatorChars = `.\-_ `
|
||||
const (
|
||||
separatorChars = `.\-_ `
|
||||
|
||||
reNotLetterWordUnicode = `[^\p{L}\w\d]`
|
||||
reNotLetterWord = `[^\w\d]`
|
||||
)
|
||||
|
||||
func getPathQueryRegex(name string) string {
|
||||
// escape specific regex characters
|
||||
@@ -22,6 +28,13 @@ func getPathQueryRegex(name string) string {
|
||||
const separator = `[` + separatorChars + `]`
|
||||
|
||||
ret := strings.ReplaceAll(name, " ", separator+"*")
|
||||
|
||||
// \p{L} is specifically omitted here because of the performance hit when
|
||||
// including it. It does mean that paths where the name is bounded by
|
||||
// unicode letters will be returned. However, the results should be tested
|
||||
// by nameMatchesPath which does include \p{L}. The improvement in query
|
||||
// performance should be outweigh the performance hit of testing any extra
|
||||
// results.
|
||||
ret = `(?:^|_|[^\w\d])` + ret + `(?:$|_|[^\w\d])`
|
||||
return ret
|
||||
}
|
||||
@@ -36,7 +49,7 @@ func getPathWords(path string) []string {
|
||||
}
|
||||
|
||||
// handle path separators
|
||||
const separator = `(?:_|[^\w\d])+`
|
||||
const separator = `(?:_|[^\p{L}\w\d])+`
|
||||
re := regexp.MustCompile(separator)
|
||||
retStr = re.ReplaceAllString(retStr, " ")
|
||||
|
||||
@@ -52,29 +65,31 @@ func getPathWords(path string) []string {
|
||||
// we post-match afterwards, so we can afford to be a little loose
|
||||
// with the query
|
||||
// just use the first two characters
|
||||
ret = append(ret, w[0:2])
|
||||
// #2293 - need to convert to unicode runes for the substring, otherwise
|
||||
// the resulting string is corrupted.
|
||||
ret = append(ret, string([]rune(w)[0:2]))
|
||||
}
|
||||
}
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
// https://stackoverflow.com/a/53069799
|
||||
func allASCII(s string) bool {
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] > 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,10 @@ import "testing"
|
||||
|
||||
func Test_nameMatchesPath(t *testing.T) {
|
||||
const name = "first last"
|
||||
const unicodeName = "伏字"
|
||||
|
||||
tests := []struct {
|
||||
testName string
|
||||
name string
|
||||
path string
|
||||
want int
|
||||
@@ -13,62 +15,79 @@ func Test_nameMatchesPath(t *testing.T) {
|
||||
{
|
||||
"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)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,7 +40,10 @@ 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 {
|
||||
@@ -61,13 +66,16 @@ type Mutation struct {
|
||||
ImageDestroy bool "json:\"imageDestroy\" graphql:\"imageDestroy\""
|
||||
NewUser *string "json:\"newUser\" graphql:\"newUser\""
|
||||
ActivateNewUser *User "json:\"activateNewUser\" graphql:\"activateNewUser\""
|
||||
GenerateInviteCode string "json:\"generateInviteCode\" graphql:\"generateInviteCode\""
|
||||
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\""
|
||||
@@ -80,6 +88,9 @@ type Mutation struct {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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,9 +122,11 @@ 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"`
|
||||
// Is the edit considered destructive.
|
||||
Destructive bool `json:"destructive"`
|
||||
Status VoteStatusEnum `json:"status"`
|
||||
Applied bool `json:"applied"`
|
||||
Created time.Time `json:"created"`
|
||||
@@ -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"`
|
||||
Vote VoteTypeEnum `json:"vote"`
|
||||
}
|
||||
|
||||
type EyeColorCriterionInput struct {
|
||||
@@ -141,18 +195,24 @@ type Fingerprint struct {
|
||||
Submissions int `json:"submissions"`
|
||||
Created time.Time `json:"created"`
|
||||
Updated time.Time `json:"updated"`
|
||||
UserSubmitted bool `json:"user_submitted"`
|
||||
}
|
||||
|
||||
type FingerprintEditInput struct {
|
||||
UserIds []string `json:"user_ids"`
|
||||
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"`
|
||||
// @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"`
|
||||
@@ -273,6 +339,7 @@ type Performer struct {
|
||||
}
|
||||
|
||||
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"`
|
||||
@@ -633,12 +823,12 @@ type Studio struct {
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
@@ -652,8 +842,6 @@ type StudioEdit struct {
|
||||
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"`
|
||||
}
|
||||
@@ -664,7 +852,6 @@ 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"`
|
||||
}
|
||||
|
||||
@@ -690,7 +877,6 @@ type StudioUpdateInput 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"`
|
||||
}
|
||||
|
||||
@@ -705,6 +891,7 @@ type Tag struct {
|
||||
}
|
||||
|
||||
func (Tag) IsEditTarget() {}
|
||||
func (Tag) IsSceneDraftTag() {}
|
||||
|
||||
type TagCategory struct {
|
||||
ID string `json:"id"`
|
||||
@@ -746,7 +933,7 @@ type TagEdit struct {
|
||||
Description *string `json:"description"`
|
||||
AddedAliases []string `json:"added_aliases"`
|
||||
RemovedAliases []string `json:"removed_aliases"`
|
||||
CategoryID *string `json:"category_id"`
|
||||
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"`
|
||||
SiteID string `json:"site_id"`
|
||||
}
|
||||
|
||||
type User struct {
|
||||
@@ -802,11 +990,10 @@ type User struct {
|
||||
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"`
|
||||
// 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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,10 +36,8 @@ 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)
|
||||
@@ -58,27 +50,7 @@ func CheckAllowPublicWithoutAuth(c *config.Instance, r *http.Request) error {
|
||||
}
|
||||
}
|
||||
}
|
||||
} 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)
|
||||
}
|
||||
}
|
||||
} 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"+
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ?")
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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;"
|
||||
|
||||
50
scripts/generate_icons.sh
Executable file
50
scripts/generate_icons.sh
Executable file
@@ -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
|
||||
18
scripts/macos-bundle/Contents/Info.plist
Normal file
18
scripts/macos-bundle/Contents/Info.plist
Normal file
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>stash</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>icon.icns</string>
|
||||
<key>CFBundleTypeIconFile</key>
|
||||
<string>icon.icns</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>org.stashapp.stash</string>
|
||||
<key>NSHighResolutionCapable</key>
|
||||
<string>True</string>
|
||||
<key>LSUIElement</key>
|
||||
<string>1</string>
|
||||
</dict>
|
||||
</plist>
|
||||
BIN
scripts/macos-bundle/Contents/Resources/icon.icns
Normal file
BIN
scripts/macos-bundle/Contents/Resources/icon.icns
Normal file
Binary file not shown.
BIN
scripts/stash-logo.png
Normal file
BIN
scripts/stash-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 222 KiB |
@@ -3,7 +3,8 @@
|
||||
<head>
|
||||
<base href="/">
|
||||
<meta charset="utf-8" />
|
||||
<link rel="shortcut icon" href="/favicon.ico" />
|
||||
<link rel="shortcut icon" href="favicon.ico" />
|
||||
<link rel="apple-touch-icon" href="apple-touch-icon.png" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, maximum-scale=1"
|
||||
@@ -13,7 +14,7 @@
|
||||
manifest.json provides metadata used when your web app is installed on a
|
||||
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
|
||||
-->
|
||||
<link rel="manifest" href="/%BASE_URL%/manifest.json" />
|
||||
<link rel="manifest" href="manifest.json" />
|
||||
<title>Stash</title>
|
||||
<script>window.STASH_BASE_URL = "/%BASE_URL%/"</script>
|
||||
</head>
|
||||
|
||||
BIN
ui/v2.5/public/apple-touch-icon.png
Normal file
BIN
ui/v2.5/public/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 41 KiB |
BIN
ui/v2.5/public/favicon.png
Normal file
BIN
ui/v2.5/public/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
@@ -8,7 +8,8 @@
|
||||
"type": "image/x-icon"
|
||||
}
|
||||
],
|
||||
"start_url": ".",
|
||||
"start_url": "/",
|
||||
"scope": ".",
|
||||
"display": "standalone",
|
||||
"theme_color": "#000000",
|
||||
"background_color": "#ffffff"
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
{
|
||||
|
||||
41
ui/v2.5/src/components/Changelog/versions/v0130.md
Normal file
41
ui/v2.5/src/components/Changelog/versions/v0130.md
Normal file
@@ -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))
|
||||
@@ -51,12 +51,14 @@ export const GenerateDialog: React.FC<ISceneGenerateDialog> = ({
|
||||
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,
|
||||
|
||||
126
ui/v2.5/src/components/Dialogs/SubmitDraft.tsx
Normal file
126
ui/v2.5/src/components/Dialogs/SubmitDraft.tsx
Normal file
@@ -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<GQL.StashBox, "name" | "endpoint">[];
|
||||
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<IProps> = ({
|
||||
show,
|
||||
boxes,
|
||||
entity,
|
||||
query,
|
||||
onHide,
|
||||
}) => {
|
||||
const [submit, { data, error, loading }] = useMutation<Query, Variables>(
|
||||
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<HTMLSelectElement>) =>
|
||||
setSelectedBox(Number.parseInt(e.currentTarget.value) ?? 0);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
icon="paper-plane"
|
||||
header={intl.formatMessage({ id: "actions.submit_stash_box" })}
|
||||
isRunning={loading}
|
||||
show={show}
|
||||
accept={{
|
||||
onClick: onHide,
|
||||
}}
|
||||
>
|
||||
{data === undefined ? (
|
||||
<>
|
||||
<Form.Group className="form-row align-items-end">
|
||||
<Form.Label className="col-6">
|
||||
<FormattedMessage id="stashbox.selected_stash_box" />:
|
||||
</Form.Label>
|
||||
<Form.Control
|
||||
as="select"
|
||||
onChange={handleSelectBox}
|
||||
className="col-6"
|
||||
>
|
||||
{boxes.map((box, i) => (
|
||||
<option value={i} key={`${box.endpoint}-${i}`}>
|
||||
{box.name}
|
||||
</option>
|
||||
))}
|
||||
</Form.Control>
|
||||
</Form.Group>
|
||||
<Button onClick={handleSubmit}>
|
||||
<FormattedMessage id="actions.submit" />{" "}
|
||||
{`"${entity.name ?? entity.title}"`}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h6>
|
||||
<FormattedMessage id="stashbox.submission_successful" />
|
||||
</h6>
|
||||
<div>
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
href={`${getStashboxBase(
|
||||
boxes[selectedBox].endpoint
|
||||
)}drafts/${getResponseId(data)}`}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="stashbox.go_review_draft"
|
||||
values={{ endpoint_name: boxes[selectedBox].name }}
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{error !== undefined && (
|
||||
<>
|
||||
<h6 className="mt-2">
|
||||
<FormattedMessage id="stashbox.submission_failed" />
|
||||
</h6>
|
||||
<div>{error.message}</div>
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -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<IListOperationProps> = (
|
||||
|
||||
const checkboxRef = React.createRef<HTMLInputElement>();
|
||||
|
||||
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<IListOperationProps> = (
|
||||
}),
|
||||
};
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
// 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
|
||||
galleryInput.rating = getAggregateInputValue(rating, aggregateRating);
|
||||
galleryInput.studio_id = getAggregateInputValue(
|
||||
studioId,
|
||||
aggregateStudioId
|
||||
);
|
||||
}
|
||||
} 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<IListOperationProps> = (
|
||||
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;
|
||||
|
||||
@@ -111,7 +111,7 @@ export const GalleryCard: React.FC<IProps> = (props) => {
|
||||
function maybeRenderOrganized() {
|
||||
if (props.gallery.organized) {
|
||||
return (
|
||||
<div>
|
||||
<div className="organized">
|
||||
<Button className="minimal">
|
||||
<Icon icon="box" />
|
||||
</Button>
|
||||
|
||||
@@ -21,7 +21,9 @@ export const GalleryDetailPanel: React.FC<IGalleryDetailProps> = ({
|
||||
if (!gallery.details) return;
|
||||
return (
|
||||
<>
|
||||
<h6>Details</h6>
|
||||
<h6>
|
||||
<FormattedMessage id="details" />
|
||||
</h6>
|
||||
<p className="pre">{gallery.details}</p>
|
||||
</>
|
||||
);
|
||||
@@ -34,7 +36,12 @@ export const GalleryDetailPanel: React.FC<IGalleryDetailProps> = ({
|
||||
));
|
||||
return (
|
||||
<>
|
||||
<h6>Tags</h6>
|
||||
<h6>
|
||||
<FormattedMessage
|
||||
id="countables.tags"
|
||||
values={{ count: gallery.tags.length }}
|
||||
/>
|
||||
</h6>
|
||||
{tags}
|
||||
</>
|
||||
);
|
||||
@@ -53,7 +60,12 @@ export const GalleryDetailPanel: React.FC<IGalleryDetailProps> = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
<h6>Performers</h6>
|
||||
<h6>
|
||||
<FormattedMessage
|
||||
id="countables.performers"
|
||||
values={{ count: gallery.performers.length }}
|
||||
/>
|
||||
</h6>
|
||||
<div className="row justify-content-center gallery-performers">
|
||||
{cards}
|
||||
</div>
|
||||
@@ -83,18 +95,19 @@ export const GalleryDetailPanel: React.FC<IGalleryDetailProps> = ({
|
||||
) : undefined}
|
||||
{gallery.rating ? (
|
||||
<h6>
|
||||
Rating: <RatingStars value={gallery.rating} />
|
||||
<FormattedMessage id="rating" />:{" "}
|
||||
<RatingStars value={gallery.rating} />
|
||||
</h6>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
<h6>
|
||||
<FormattedMessage id="created_at" />:{" "}
|
||||
{TextUtils.formatDate(intl, gallery.created_at)}{" "}
|
||||
{TextUtils.formatDateTime(intl, gallery.created_at)}{" "}
|
||||
</h6>
|
||||
<h6>
|
||||
<FormattedMessage id="updated_at" />:{" "}
|
||||
{TextUtils.formatDate(intl, gallery.updated_at)}{" "}
|
||||
{TextUtils.formatDateTime(intl, gallery.updated_at)}{" "}
|
||||
</h6>
|
||||
</div>
|
||||
{gallery.studio && (
|
||||
|
||||
@@ -23,7 +23,7 @@ export const GalleryFileInfoPanel: React.FC<IGalleryFileInfoPanelProps> = (
|
||||
truncate
|
||||
/>
|
||||
<URLField
|
||||
id="path"
|
||||
id="media_info.downloaded_from"
|
||||
url={props.gallery.url}
|
||||
value={props.gallery.url}
|
||||
truncate
|
||||
|
||||
@@ -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.SlimImageDataFragment[];
|
||||
@@ -42,22 +50,12 @@ export const EditImagesDialog: React.FC<IListOperationProps> = (
|
||||
|
||||
const checkboxRef = React.createRef<HTMLInputElement>();
|
||||
|
||||
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<IListOperationProps> = (
|
||||
}),
|
||||
};
|
||||
|
||||
// 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
|
||||
imageInput.performer_ids = getAggregateInputIDs(
|
||||
performerMode,
|
||||
performerIds,
|
||||
aggregatePerformerIds
|
||||
);
|
||||
}
|
||||
} 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.tag_ids = getAggregateInputIDs(tagMode, tagIds, aggregateTagIds);
|
||||
|
||||
if (organized !== undefined) {
|
||||
imageInput.organized = organized;
|
||||
@@ -155,83 +101,6 @@ export const EditImagesDialog: React.FC<IListOperationProps> = (
|
||||
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;
|
||||
|
||||
@@ -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<IImageCardProps> = (
|
||||
@@ -49,7 +50,7 @@ export const ImageCard: React.FC<IImageCardProps> = (
|
||||
function maybeRenderOCounter() {
|
||||
if (props.image.o_counter) {
|
||||
return (
|
||||
<div>
|
||||
<div className="o-count">
|
||||
<Button className="minimal">
|
||||
<span className="fa-icon">
|
||||
<SweatDrops />
|
||||
@@ -61,10 +62,31 @@ export const ImageCard: React.FC<IImageCardProps> = (
|
||||
}
|
||||
}
|
||||
|
||||
function maybeRenderGallery() {
|
||||
if (props.image.galleries.length <= 0) return;
|
||||
|
||||
const popoverContent = props.image.galleries.map((gallery) => (
|
||||
<TagLink key={gallery.id} gallery={gallery} />
|
||||
));
|
||||
|
||||
return (
|
||||
<HoverPopover
|
||||
className="gallery-count"
|
||||
placement="bottom"
|
||||
content={popoverContent}
|
||||
>
|
||||
<Button className="minimal">
|
||||
<Icon icon="images" />
|
||||
<span>{props.image.galleries.length}</span>
|
||||
</Button>
|
||||
</HoverPopover>
|
||||
);
|
||||
}
|
||||
|
||||
function maybeRenderOrganized() {
|
||||
if (props.image.organized) {
|
||||
return (
|
||||
<div>
|
||||
<div className="organized">
|
||||
<Button className="minimal">
|
||||
<Icon icon="box" />
|
||||
</Button>
|
||||
@@ -78,6 +100,7 @@ export const ImageCard: React.FC<IImageCardProps> = (
|
||||
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<IImageCardProps> = (
|
||||
{maybeRenderTagPopoverButton()}
|
||||
{maybeRenderPerformerPopoverButton()}
|
||||
{maybeRenderOCounter()}
|
||||
{maybeRenderGallery()}
|
||||
{maybeRenderOrganized()}
|
||||
</ButtonGroup>
|
||||
</>
|
||||
@@ -119,6 +143,13 @@ export const ImageCard: React.FC<IImageCardProps> = (
|
||||
alt={props.image.title ?? ""}
|
||||
src={props.image.paths.thumbnail ?? ""}
|
||||
/>
|
||||
{props.onPreview ? (
|
||||
<div className="preview-button">
|
||||
<Button onClick={props.onPreview}>
|
||||
<Icon icon="search" />
|
||||
</Button>
|
||||
</div>
|
||||
) : undefined}
|
||||
</div>
|
||||
<RatingBanner rating={props.image.rating} />
|
||||
</>
|
||||
|
||||
@@ -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 = () => {
|
||||
</Nav.Item>
|
||||
<Nav.Item className="ml-auto">
|
||||
<OCounterButton
|
||||
loading={oLoading}
|
||||
value={image.o_counter || 0}
|
||||
onIncrement={onIncrementClick}
|
||||
onDecrement={onDecrementClick}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user