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
|
cancel-in-progress: true
|
||||||
|
|
||||||
env:
|
env:
|
||||||
COMPILER_IMAGE: stashapp/compiler:5
|
COMPILER_IMAGE: stashapp/compiler:6
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
@@ -91,12 +91,12 @@ jobs:
|
|||||||
- name: Compile for all supported platforms
|
- name: Compile for all supported platforms
|
||||||
run: |
|
run: |
|
||||||
docker exec -t build /bin/bash -c "make cross-compile-windows"
|
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-macos-intel"
|
||||||
docker exec -t build /bin/bash -c "make cross-compile-osx-applesilicon"
|
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"
|
||||||
docker exec -t build /bin/bash -c "make cross-compile-linux-arm64v8"
|
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-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
|
- name: Cleanup build container
|
||||||
run: docker rm -f -v build
|
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'}}
|
if: ${{ github.event_name == 'pull_request' && github.base_ref != 'refs/heads/develop' && github.base_ref != 'refs/heads/master'}}
|
||||||
uses: actions/upload-artifact@v2
|
uses: actions/upload-artifact@v2
|
||||||
with:
|
with:
|
||||||
name: stash-osx
|
name: stash-macos-intel
|
||||||
path: dist/stash-osx
|
path: dist/stash-macos-intel
|
||||||
|
|
||||||
- name: Upload Linux binary
|
- name: Upload Linux binary
|
||||||
# only upload binaries for pull requests
|
# only upload binaries for pull requests
|
||||||
@@ -145,13 +145,13 @@ jobs:
|
|||||||
automatic_release_tag: latest_develop
|
automatic_release_tag: latest_develop
|
||||||
title: "${{ env.STASH_VERSION }}: Latest development build"
|
title: "${{ env.STASH_VERSION }}: Latest development build"
|
||||||
files: |
|
files: |
|
||||||
dist/stash-osx
|
dist/stash-macos-intel
|
||||||
dist/stash-osx-applesilicon
|
dist/stash-macos-applesilicon
|
||||||
dist/stash-win.exe
|
dist/stash-win.exe
|
||||||
dist/stash-linux
|
dist/stash-linux
|
||||||
dist/stash-linux-arm64v8
|
dist/stash-linux-arm64v8
|
||||||
dist/stash-linux-arm32v7
|
dist/stash-linux-arm32v7
|
||||||
dist/stash-pi
|
dist/stash-linux-arm32v6
|
||||||
CHECKSUMS_SHA1
|
CHECKSUMS_SHA1
|
||||||
|
|
||||||
- name: Master release
|
- name: Master release
|
||||||
@@ -161,13 +161,13 @@ jobs:
|
|||||||
token: "${{ secrets.GITHUB_TOKEN }}"
|
token: "${{ secrets.GITHUB_TOKEN }}"
|
||||||
allow_override: true
|
allow_override: true
|
||||||
files: |
|
files: |
|
||||||
dist/stash-osx
|
dist/stash-macos-intel
|
||||||
dist/stash-osx-applesilicon
|
dist/stash-macos-applesilicon
|
||||||
dist/stash-win.exe
|
dist/stash-win.exe
|
||||||
dist/stash-linux
|
dist/stash-linux
|
||||||
dist/stash-linux-arm64v8
|
dist/stash-linux-arm64v8
|
||||||
dist/stash-linux-arm32v7
|
dist/stash-linux-arm32v7
|
||||||
dist/stash-pi
|
dist/stash-linux-arm32v6
|
||||||
CHECKSUMS_SHA1
|
CHECKSUMS_SHA1
|
||||||
gzip: false
|
gzip: false
|
||||||
|
|
||||||
|
|||||||
2
.github/workflows/golangci-lint.yml
vendored
2
.github/workflows/golangci-lint.yml
vendored
@@ -9,7 +9,7 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
|
|
||||||
env:
|
env:
|
||||||
COMPILER_IMAGE: stashapp/compiler:5
|
COMPILER_IMAGE: stashapp/compiler:6
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
golangci:
|
golangci:
|
||||||
|
|||||||
77
Makefile
77
Makefile
@@ -1,12 +1,12 @@
|
|||||||
IS_WIN =
|
IS_WIN_SHELL =
|
||||||
ifeq (${SHELL}, sh.exe)
|
ifeq (${SHELL}, sh.exe)
|
||||||
IS_WIN = true
|
IS_WIN_SHELL = true
|
||||||
endif
|
endif
|
||||||
ifeq (${SHELL}, cmd)
|
ifeq (${SHELL}, cmd)
|
||||||
IS_WIN = true
|
IS_WIN_SHELL = true
|
||||||
endif
|
endif
|
||||||
|
|
||||||
ifdef IS_WIN
|
ifdef IS_WIN_SHELL
|
||||||
SEPARATOR := &&
|
SEPARATOR := &&
|
||||||
SET := set
|
SET := set
|
||||||
else
|
else
|
||||||
@@ -14,6 +14,11 @@ else
|
|||||||
SET := export
|
SET := export
|
||||||
endif
|
endif
|
||||||
|
|
||||||
|
IS_WIN_OS =
|
||||||
|
ifeq ($(OS),Windows_NT)
|
||||||
|
IS_WIN_OS = true
|
||||||
|
endif
|
||||||
|
|
||||||
# set LDFLAGS environment variable to any extra ldflags required
|
# set LDFLAGS environment variable to any extra ldflags required
|
||||||
# set OUTPUT to generate a specific binary name
|
# set OUTPUT to generate a specific binary name
|
||||||
|
|
||||||
@@ -46,9 +51,13 @@ ifndef OFFICIAL_BUILD
|
|||||||
endif
|
endif
|
||||||
|
|
||||||
build: pre-build
|
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.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)')
|
$(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)"
|
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
|
# strips debug symbols from the release build
|
||||||
build-release: EXTRA_LDFLAGS := -s -w
|
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 CC := x86_64-w64-mingw32-gcc
|
||||||
cross-compile-windows: export CXX := x86_64-w64-mingw32-g++
|
cross-compile-windows: export CXX := x86_64-w64-mingw32-g++
|
||||||
cross-compile-windows: OUTPUT := -o dist/stash-win.exe
|
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-windows: build-release-static
|
||||||
|
|
||||||
cross-compile-osx-intel: export GOOS := darwin
|
cross-compile-macos-intel: export GOOS := darwin
|
||||||
cross-compile-osx-intel: export GOARCH := amd64
|
cross-compile-macos-intel: export GOARCH := amd64
|
||||||
cross-compile-osx-intel: export CC := o64-clang
|
cross-compile-macos-intel: export CC := o64-clang
|
||||||
cross-compile-osx-intel: export CXX := o64-clang++
|
cross-compile-macos-intel: export CXX := o64-clang++
|
||||||
cross-compile-osx-intel: OUTPUT := -o dist/stash-osx
|
cross-compile-macos-intel: OUTPUT := -o dist/stash-macos-intel
|
||||||
# can't use static build for OSX
|
# 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-macos-applesilicon: export GOOS := darwin
|
||||||
cross-compile-osx-applesilicon: export GOARCH := arm64
|
cross-compile-macos-applesilicon: export GOARCH := arm64
|
||||||
cross-compile-osx-applesilicon: export CC := oa64e-clang
|
cross-compile-macos-applesilicon: export CC := oa64e-clang
|
||||||
cross-compile-osx-applesilicon: export CXX := oa64e-clang++
|
cross-compile-macos-applesilicon: export CXX := oa64e-clang++
|
||||||
cross-compile-osx-applesilicon: OUTPUT := -o dist/stash-osx-applesilicon
|
cross-compile-macos-applesilicon: OUTPUT := -o dist/stash-macos-applesilicon
|
||||||
# can't use static build for OSX
|
# 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 GOOS := linux
|
||||||
cross-compile-linux: export GOARCH := amd64
|
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: OUTPUT := -o dist/stash-linux-arm32v7
|
||||||
cross-compile-linux-arm32v7: build-release-static
|
cross-compile-linux-arm32v7: build-release-static
|
||||||
|
|
||||||
cross-compile-pi: export GOOS := linux
|
cross-compile-linux-arm32v6: export GOOS := linux
|
||||||
cross-compile-pi: export GOARCH := arm
|
cross-compile-linux-arm32v6: export GOARCH := arm
|
||||||
cross-compile-pi: export GOARM := 6
|
cross-compile-linux-arm32v6: export GOARM := 6
|
||||||
cross-compile-pi: export CC := arm-linux-gnueabi-gcc
|
cross-compile-linux-arm32v6: export CC := arm-linux-gnueabi-gcc
|
||||||
cross-compile-pi: OUTPUT := -o dist/stash-pi
|
cross-compile-linux-arm32v6: OUTPUT := -o dist/stash-linux-arm32v6
|
||||||
cross-compile-pi: build-release-static
|
cross-compile-linux-arm32v6: build-release-static
|
||||||
|
|
||||||
cross-compile-all:
|
cross-compile-all:
|
||||||
make cross-compile-windows
|
make cross-compile-windows
|
||||||
make cross-compile-osx-intel
|
make cross-compile-macos
|
||||||
make cross-compile-osx-applesilicon
|
|
||||||
make cross-compile-linux
|
make cross-compile-linux
|
||||||
make cross-compile-linux-arm64v8
|
make cross-compile-linux-arm64v8
|
||||||
make cross-compile-linux-arm32v7
|
make cross-compile-linux-arm32v7
|
||||||
make cross-compile-pi
|
make cross-compile-linux-arm32v6
|
||||||
|
|
||||||
# Regenerates GraphQL files
|
# Regenerates GraphQL files
|
||||||
generate: generate-backend generate-frontend
|
generate: generate-backend generate-frontend
|
||||||
|
|||||||
26
README.md
26
README.md
@@ -2,7 +2,7 @@
|
|||||||
https://stashapp.cc
|
https://stashapp.cc
|
||||||
|
|
||||||
[](https://github.com/stashapp/stash/actions/workflows/build.yml)
|
[](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://goreportcard.com/report/github.com/stashapp/stash)
|
||||||
[](https://discord.gg/2TsNFKt)
|
[](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
|
<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>
|
[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>
|
||||||
|
|
||||||
## 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.
|
|
||||||
|
|
||||||
|
## 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
|
#### 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.
|
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
|
# Usage
|
||||||
|
|
||||||
## Quickstart Guide
|
## 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
|
# Translation
|
||||||
[](https://translate.stashapp.cc/engage/stash/)
|
[](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)
|
# Support (FAQ)
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ FROM --platform=$BUILDPLATFORM alpine:latest AS binary
|
|||||||
ARG TARGETPLATFORM
|
ARG TARGETPLATFORM
|
||||||
WORKDIR /
|
WORKDIR /
|
||||||
COPY stash-* /
|
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/arm/v7" ]; then BIN=stash-linux-arm32v7; \
|
||||||
elif [ "$TARGETPLATFORM" = "linux/arm64" ]; then BIN=stash-linux-arm64v8; \
|
elif [ "$TARGETPLATFORM" = "linux/arm64" ]; then BIN=stash-linux-arm64v8; \
|
||||||
elif [ "$TARGETPLATFORM" = "linux/amd64" ]; then BIN=stash-linux; \
|
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-gnueabi libc-dev-armel-cross linux-libc-dev-armel-cross \
|
||||||
gcc-arm-linux-gnueabihf libc-dev-armhf-cross \
|
gcc-arm-linux-gnueabihf libc-dev-armhf-cross \
|
||||||
gcc-aarch64-linux-gnu libc-dev-arm64-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/*;
|
rm -rf /var/lib/apt/lists/*;
|
||||||
|
|
||||||
# Cross compile setup
|
# Cross compile setup
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
user=stashapp
|
user=stashapp
|
||||||
repo=compiler
|
repo=compiler
|
||||||
version=5
|
version=6
|
||||||
|
|
||||||
latest:
|
latest:
|
||||||
docker build -t ${user}/${repo}:latest .
|
docker build -t ${user}/${repo}:latest .
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
Modified from https://github.com/bep/dockerfiles/tree/master/ci-goreleaser
|
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.
|
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.
|
||||||
|
|
||||||
A MacOS univeral binary can be created using `lipo -create -output stash-osx-universal stash-osx stash-osx-applesilicon`, available in the image.
|
|
||||||
|
|||||||
14
go.mod
14
go.mod
@@ -38,8 +38,8 @@ require (
|
|||||||
golang.org/x/crypto v0.0.0-20210817164053-32db794688a5
|
golang.org/x/crypto v0.0.0-20210817164053-32db794688a5
|
||||||
golang.org/x/image v0.0.0-20210220032944-ac19c3e999fb
|
golang.org/x/image v0.0.0-20210220032944-ac19c3e999fb
|
||||||
golang.org/x/net v0.0.0-20211123203042-d83791d6bcd9
|
golang.org/x/net v0.0.0-20211123203042-d83791d6bcd9
|
||||||
golang.org/x/sys v0.0.0-20210823070655-63515b42dcdf
|
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e
|
||||||
golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b // indirect
|
golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b
|
||||||
golang.org/x/text v0.3.7
|
golang.org/x/text v0.3.7
|
||||||
golang.org/x/tools v0.1.5 // indirect
|
golang.org/x/tools v0.1.5 // indirect
|
||||||
gopkg.in/sourcemap.v1 v1.0.5 // indirect
|
gopkg.in/sourcemap.v1 v1.0.5 // indirect
|
||||||
@@ -47,6 +47,11 @@ require (
|
|||||||
)
|
)
|
||||||
|
|
||||||
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/lucasb-eyer/go-colorful v1.2.0
|
||||||
github.com/vearutop/statigz v1.1.6
|
github.com/vearutop/statigz v1.1.6
|
||||||
github.com/vektah/gqlparser/v2 v2.0.1
|
github.com/vektah/gqlparser/v2 v2.0.1
|
||||||
@@ -55,10 +60,12 @@ require (
|
|||||||
require (
|
require (
|
||||||
github.com/agnivade/levenshtein v1.1.0 // indirect
|
github.com/agnivade/levenshtein v1.1.0 // indirect
|
||||||
github.com/antchfx/xpath v1.2.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/chromedp/sysutil v1.0.0 // indirect
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.0 // indirect
|
github.com/cpuguy83/go-md2man/v2 v2.0.0 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
github.com/fsnotify/fsnotify v1.5.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/httphead v0.1.0 // indirect
|
||||||
github.com/gobwas/pool v0.2.1 // indirect
|
github.com/gobwas/pool v0.2.1 // indirect
|
||||||
github.com/gobwas/ws v1.1.0-rc.5 // 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/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.1 // indirect
|
github.com/modern-go/reflect2 v1.0.1 // indirect
|
||||||
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // 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/pelletier/go-toml v1.9.4 // indirect
|
||||||
github.com/pkg/errors v0.9.1 // indirect
|
github.com/pkg/errors v0.9.1 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // 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/russross/blackfriday/v2 v2.0.1 // indirect
|
||||||
github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect
|
github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect
|
||||||
github.com/spf13/cast v1.4.1 // 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/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-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/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 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q=
|
||||||
github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE=
|
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=
|
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 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 h1:maB6vn6FqCxrpz4FqWdh4+lwpyZIQS7YEAUcHlgXVRs=
|
||||||
github.com/go-chi/chi v4.0.2+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ=
|
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 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-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
||||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/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 h1:ozyZYNQW3x3HtqT1jira07DN2PArx2v7/mN66gGcHOs=
|
||||||
github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
|
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-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/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.0.0-20190329151759-d478694a28d3/go.mod h1:3STtPUQYuzV0gBVOY3vy6CfMm/ljR4pABfrTeHNLHUY=
|
||||||
github.com/gobuffalo/depgen v0.1.0/go.mod h1:+ifsuy7fhi15RWncXQQKjWS9JPkdah5sZvtHc2RXGlg=
|
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.8.0/go.mod h1:H5KPZjojv4lE+QYImBI8xVtrBRgYrIVsaRPx4tDPEn4=
|
||||||
github.com/karrick/godirwalk v1.10.3/go.mod h1:RoGL9dQei4vP9ilrpETWE8CLOZ1kiN0LhBygSwrAsHA=
|
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/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.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q=
|
||||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
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=
|
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/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 h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
|
||||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
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/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.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||||
github.com/onsi/ginkgo v1.12.0/go.mod h1:oUhWkIvk5aDxtKvDDuw8gItl8pKl42LzjC9KZE0HfGg=
|
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/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.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU=
|
||||||
github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc=
|
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.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 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q=
|
||||||
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
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=
|
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.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
|
||||||
github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE=
|
github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE=
|
||||||
github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
|
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/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 v0.0.0-20181108003508-044398e4856c/go.mod h1:XDJAKZRPZ1CvBcN2aX5YOUTYGHki24fSF0Iv48Ibg0s=
|
||||||
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
|
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-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-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-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-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-20190415145633-3fd5a3612ccd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20190419153524-e8e3143a4f4a/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-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-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-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-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-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 h1:9zKuko04nR4gjZ4+DNjHqRlAJqbJETHwiNKDqTfOjfE=
|
||||||
golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ fragment ConfigGeneralData on ConfigGeneralResult {
|
|||||||
username
|
username
|
||||||
password
|
password
|
||||||
maxSessionAge
|
maxSessionAge
|
||||||
trustedProxies
|
|
||||||
logFile
|
logFile
|
||||||
logOut
|
logOut
|
||||||
logLevel
|
logLevel
|
||||||
@@ -52,8 +51,10 @@ fragment ConfigInterfaceData on ConfigInterfaceResult {
|
|||||||
soundOnPreview
|
soundOnPreview
|
||||||
wallShowTitle
|
wallShowTitle
|
||||||
wallPlayback
|
wallPlayback
|
||||||
|
showScrubber
|
||||||
maximumLoopDuration
|
maximumLoopDuration
|
||||||
noBrowser
|
noBrowser
|
||||||
|
notificationsEnabled
|
||||||
autostartVideo
|
autostartVideo
|
||||||
autostartVideoOnPlaySelected
|
autostartVideoOnPlaySelected
|
||||||
continuePlaylistDefault
|
continuePlaylistDefault
|
||||||
|
|||||||
@@ -22,6 +22,12 @@ mutation MovieUpdate($input: MovieUpdateInput!) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
mutation BulkMovieUpdate($input: BulkMovieUpdateInput!) {
|
||||||
|
bulkMovieUpdate(input: $input) {
|
||||||
|
...MovieData
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
mutation MovieDestroy($id: ID!) {
|
mutation MovieDestroy($id: ID!) {
|
||||||
movieDestroy(input: { id: $id })
|
movieDestroy(input: { id: $id })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,3 +5,11 @@ mutation SubmitStashBoxFingerprints($input: StashBoxFingerprintSubmissionInput!)
|
|||||||
mutation StashBoxBatchPerformerTag($input: StashBoxBatchPerformerTagInput!) {
|
mutation StashBoxBatchPerformerTag($input: StashBoxBatchPerformerTagInput!) {
|
||||||
stashBoxBatchPerformerTag(input: $input)
|
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
|
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', ..."
|
"Desired collation locale. Determines the order of the directory result. eg. 'en-US', 'pt-BR', ..."
|
||||||
locale: String = "en"
|
locale: String = "en"
|
||||||
): Directory!
|
): Directory!
|
||||||
|
validateStashBoxCredentials(input: StashBoxInput!): StashBoxValidationResult!
|
||||||
|
|
||||||
# System status
|
# System status
|
||||||
systemStatus: SystemStatus!
|
systemStatus: SystemStatus!
|
||||||
@@ -223,6 +224,7 @@ type Mutation {
|
|||||||
movieUpdate(input: MovieUpdateInput!): Movie
|
movieUpdate(input: MovieUpdateInput!): Movie
|
||||||
movieDestroy(input: MovieDestroyInput!): Boolean!
|
movieDestroy(input: MovieDestroyInput!): Boolean!
|
||||||
moviesDestroy(ids: [ID!]!): Boolean!
|
moviesDestroy(ids: [ID!]!): Boolean!
|
||||||
|
bulkMovieUpdate(input: BulkMovieUpdateInput!): [Movie!]
|
||||||
|
|
||||||
tagCreate(input: TagCreateInput!): Tag
|
tagCreate(input: TagCreateInput!): Tag
|
||||||
tagUpdate(input: TagUpdateInput!): Tag
|
tagUpdate(input: TagUpdateInput!): Tag
|
||||||
@@ -281,6 +283,11 @@ type Mutation {
|
|||||||
"""Submit fingerprints to stash-box instance"""
|
"""Submit fingerprints to stash-box instance"""
|
||||||
submitStashBoxFingerprints(input: StashBoxFingerprintSubmissionInput!): Boolean!
|
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"""
|
"""Backup the database. Optionally returns a link to download the database file"""
|
||||||
backupDatabase(input: BackupDatabaseInput!): String
|
backupDatabase(input: BackupDatabaseInput!): String
|
||||||
|
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ input ConfigGeneralInput {
|
|||||||
"""Maximum session cookie age"""
|
"""Maximum session cookie age"""
|
||||||
maxSessionAge: Int
|
maxSessionAge: Int
|
||||||
"""Comma separated list of proxies to allow traffic from"""
|
"""Comma separated list of proxies to allow traffic from"""
|
||||||
trustedProxies: [String!]
|
trustedProxies: [String!] @deprecated(reason: "no longer supported")
|
||||||
"""Name of the log file"""
|
"""Name of the log file"""
|
||||||
logFile: String
|
logFile: String
|
||||||
"""Whether to also output to stderr"""
|
"""Whether to also output to stderr"""
|
||||||
@@ -157,7 +157,7 @@ type ConfigGeneralResult {
|
|||||||
"""Maximum session cookie age"""
|
"""Maximum session cookie age"""
|
||||||
maxSessionAge: Int!
|
maxSessionAge: Int!
|
||||||
"""Comma separated list of proxies to allow traffic from"""
|
"""Comma separated list of proxies to allow traffic from"""
|
||||||
trustedProxies: [String!]!
|
trustedProxies: [String!] @deprecated(reason: "no longer supported")
|
||||||
"""Name of the log file"""
|
"""Name of the log file"""
|
||||||
logFile: String
|
logFile: String
|
||||||
"""Whether to also output to stderr"""
|
"""Whether to also output to stderr"""
|
||||||
@@ -207,6 +207,9 @@ input ConfigInterfaceInput {
|
|||||||
wallShowTitle: Boolean
|
wallShowTitle: Boolean
|
||||||
"""Wall playback type"""
|
"""Wall playback type"""
|
||||||
wallPlayback: String
|
wallPlayback: String
|
||||||
|
|
||||||
|
"""Show scene scrubber by default"""
|
||||||
|
showScrubber: Boolean
|
||||||
|
|
||||||
"""Maximum duration (in seconds) in which a scene video will loop in the scene player"""
|
"""Maximum duration (in seconds) in which a scene video will loop in the scene player"""
|
||||||
maximumLoopDuration: Int
|
maximumLoopDuration: Int
|
||||||
@@ -239,6 +242,8 @@ input ConfigInterfaceInput {
|
|||||||
funscriptOffset: Int
|
funscriptOffset: 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
|
noBrowser: Boolean
|
||||||
|
"""True if we should send notifications to the desktop"""
|
||||||
|
notificationsEnabled: Boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type ConfigDisableDropdownCreate {
|
type ConfigDisableDropdownCreate {
|
||||||
@@ -259,10 +264,15 @@ type ConfigInterfaceResult {
|
|||||||
"""Wall playback type"""
|
"""Wall playback type"""
|
||||||
wallPlayback: String
|
wallPlayback: String
|
||||||
|
|
||||||
|
"""Show scene scrubber by default"""
|
||||||
|
showScrubber: Boolean
|
||||||
|
|
||||||
"""Maximum duration (in seconds) in which a scene video will loop in the scene player"""
|
"""Maximum duration (in seconds) in which a scene video will loop in the scene player"""
|
||||||
maximumLoopDuration: Int
|
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
|
noBrowser: Boolean
|
||||||
|
"""True if we should send desktop notifications"""
|
||||||
|
notificationsEnabled: Boolean
|
||||||
"""If true, video will autostart on load in the scene player"""
|
"""If true, video will autostart on load in the scene player"""
|
||||||
autostartVideo: Boolean
|
autostartVideo: Boolean
|
||||||
"""If true, video will autostart when loading from play random or play selected"""
|
"""If true, video will autostart when loading from play random or play selected"""
|
||||||
@@ -391,3 +401,8 @@ type StashConfig {
|
|||||||
input GenerateAPIKeyInput {
|
input GenerateAPIKeyInput {
|
||||||
clear: Boolean
|
clear: Boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type StashBoxValidationResult {
|
||||||
|
valid: Boolean!
|
||||||
|
status: String!
|
||||||
|
}
|
||||||
|
|||||||
@@ -33,6 +33,12 @@ input ResolutionCriterionInput {
|
|||||||
modifier: CriterionModifier!
|
modifier: CriterionModifier!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
input PHashDuplicationCriterionInput {
|
||||||
|
duplicated: Boolean
|
||||||
|
"""Currently unimplemented"""
|
||||||
|
distance: Int
|
||||||
|
}
|
||||||
|
|
||||||
input PerformerFilterType {
|
input PerformerFilterType {
|
||||||
AND: PerformerFilterType
|
AND: PerformerFilterType
|
||||||
OR: PerformerFilterType
|
OR: PerformerFilterType
|
||||||
@@ -130,6 +136,8 @@ input SceneFilterType {
|
|||||||
organized: Boolean
|
organized: Boolean
|
||||||
"""Filter by o-counter"""
|
"""Filter by o-counter"""
|
||||||
o_counter: IntCriterionInput
|
o_counter: IntCriterionInput
|
||||||
|
"""Filter Scenes that have an exact phash match available"""
|
||||||
|
duplicated: PHashDuplicationCriterionInput
|
||||||
"""Filter by resolution"""
|
"""Filter by resolution"""
|
||||||
resolution: ResolutionCriterionInput
|
resolution: ResolutionCriterionInput
|
||||||
"""Filter by duration (in seconds)"""
|
"""Filter by duration (in seconds)"""
|
||||||
@@ -148,6 +156,10 @@ input SceneFilterType {
|
|||||||
tag_count: IntCriterionInput
|
tag_count: IntCriterionInput
|
||||||
"""Filter to only include scenes with performers with these tags"""
|
"""Filter to only include scenes with performers with these tags"""
|
||||||
performer_tags: HierarchicalMultiCriterionInput
|
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"""
|
"""Filter to only include scenes with these performers"""
|
||||||
performers: MultiCriterionInput
|
performers: MultiCriterionInput
|
||||||
"""Filter by performer count"""
|
"""Filter by performer count"""
|
||||||
@@ -243,6 +255,10 @@ input GalleryFilterType {
|
|||||||
performers: MultiCriterionInput
|
performers: MultiCriterionInput
|
||||||
"""Filter by performer count"""
|
"""Filter by performer count"""
|
||||||
performer_count: IntCriterionInput
|
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"""
|
"""Filter by number of images in this gallery"""
|
||||||
image_count: IntCriterionInput
|
image_count: IntCriterionInput
|
||||||
"""Filter by url"""
|
"""Filter by url"""
|
||||||
@@ -324,6 +340,8 @@ input ImageFilterType {
|
|||||||
performers: MultiCriterionInput
|
performers: MultiCriterionInput
|
||||||
"""Filter by performer count"""
|
"""Filter by performer count"""
|
||||||
performer_count: IntCriterionInput
|
performer_count: IntCriterionInput
|
||||||
|
"""Filter images that have performers that have been favorited"""
|
||||||
|
performer_favorite: Boolean
|
||||||
"""Filter to only include images with these galleries"""
|
"""Filter to only include images with these galleries"""
|
||||||
galleries: MultiCriterionInput
|
galleries: MultiCriterionInput
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,6 +54,14 @@ input MovieUpdateInput {
|
|||||||
back_image: String
|
back_image: String
|
||||||
}
|
}
|
||||||
|
|
||||||
|
input BulkMovieUpdateInput {
|
||||||
|
clientMutationId: String
|
||||||
|
ids: [ID!]
|
||||||
|
rating: Int
|
||||||
|
studio_id: ID
|
||||||
|
director: String
|
||||||
|
}
|
||||||
|
|
||||||
input MovieDestroyInput {
|
input MovieDestroyInput {
|
||||||
id: ID!
|
id: ID!
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,3 +24,8 @@ input StashBoxFingerprintSubmissionInput {
|
|||||||
scene_ids: [String!]!
|
scene_ids: [String!]!
|
||||||
stash_box_index: Int!
|
stash_box_index: Int!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
input StashBoxDraftSubmissionInput {
|
||||||
|
id: String!
|
||||||
|
stash_box_index: Int!
|
||||||
|
}
|
||||||
|
|||||||
@@ -156,3 +156,21 @@ query FindSceneByID($id: ID!) {
|
|||||||
mutation SubmitFingerprint($input: FingerprintSubmission!) {
|
mutation SubmitFingerprint($input: FingerprintSubmission!) {
|
||||||
submitFingerprint(input: $input)
|
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 (
|
import (
|
||||||
"embed"
|
"embed"
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"runtime/pprof"
|
"runtime/pprof"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
|
"github.com/apenwarr/fixconsole"
|
||||||
"github.com/stashapp/stash/pkg/api"
|
"github.com/stashapp/stash/pkg/api"
|
||||||
"github.com/stashapp/stash/pkg/logger"
|
|
||||||
"github.com/stashapp/stash/pkg/manager"
|
"github.com/stashapp/stash/pkg/manager"
|
||||||
|
|
||||||
_ "github.com/golang-migrate/migrate/v4/database/sqlite3"
|
_ "github.com/golang-migrate/migrate/v4/database/sqlite3"
|
||||||
@@ -22,18 +23,24 @@ var uiBox embed.FS
|
|||||||
//go:embed ui/login
|
//go:embed ui/login
|
||||||
var loginUIBox embed.FS
|
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() {
|
func main() {
|
||||||
manager.Initialize()
|
manager.Initialize()
|
||||||
api.Start(uiBox, loginUIBox)
|
api.Start(uiBox, loginUIBox)
|
||||||
|
|
||||||
// stop any profiling at exit
|
|
||||||
defer pprof.StopCPUProfile()
|
|
||||||
blockForever()
|
blockForever()
|
||||||
|
|
||||||
err := manager.GetInstance().Shutdown()
|
// stop any profiling at exit
|
||||||
if err != nil {
|
pprof.StopCPUProfile()
|
||||||
logger.Errorf("Error when closing: %s", err)
|
|
||||||
}
|
manager.GetInstance().Shutdown(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
func blockForever() {
|
func blockForever() {
|
||||||
|
|||||||
@@ -57,15 +57,10 @@ func authenticateHandler() func(http.Handler) http.Handler {
|
|||||||
|
|
||||||
if err := session.CheckAllowPublicWithoutAuth(c, r); err != nil {
|
if err := session.CheckAllowPublicWithoutAuth(c, r); err != nil {
|
||||||
var externalAccess session.ExternalAccessError
|
var externalAccess session.ExternalAccessError
|
||||||
var untrustedProxy session.UntrustedProxyError
|
|
||||||
switch {
|
switch {
|
||||||
case errors.As(err, &externalAccess):
|
case errors.As(err, &externalAccess):
|
||||||
securityActivateTripwireAccessedFromInternetWithoutAuth(c, externalAccess, w)
|
securityActivateTripwireAccessedFromInternetWithoutAuth(c, externalAccess, w)
|
||||||
return
|
return
|
||||||
case errors.As(err, &untrustedProxy):
|
|
||||||
logger.Warnf("Rejected request from untrusted proxy: %v", net.IP(untrustedProxy))
|
|
||||||
w.WriteHeader(http.StatusForbidden)
|
|
||||||
return
|
|
||||||
default:
|
default:
|
||||||
logger.Errorf("Error checking external access security: %v", err)
|
logger.Errorf("Error checking external access security: %v", err)
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
@@ -135,9 +130,4 @@ func securityActivateTripwireAccessedFromInternetWithoutAuth(c *config.Instance,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Error(err)
|
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",
|
"darwin/arm64": "stash-osx-applesilicon",
|
||||||
"linux/amd64": "stash-linux",
|
"linux/amd64": "stash-linux",
|
||||||
"windows/amd64": "stash-win.exe",
|
"windows/amd64": "stash-win.exe",
|
||||||
"linux/arm": "stash-pi",
|
"linux/arm": "stash-linux-arm32v6",
|
||||||
"linux/arm64": "stash-linux-arm64v8",
|
"linux/arm64": "stash-linux-arm64v8",
|
||||||
"linux/armv7": "stash-linux-arm32v7",
|
"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("sv-SE"),
|
||||||
language.MustParse("zh-CN"),
|
language.MustParse("zh-CN"),
|
||||||
language.MustParse("zh-TW"),
|
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
|
// 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)
|
c.Set(config.MaxSessionAge, *input.MaxSessionAge)
|
||||||
}
|
}
|
||||||
|
|
||||||
if input.TrustedProxies != nil {
|
|
||||||
c.Set(config.TrustedProxies, input.TrustedProxies)
|
|
||||||
}
|
|
||||||
|
|
||||||
if input.LogFile != nil {
|
if input.LogFile != nil {
|
||||||
c.Set(config.LogFile, input.LogFile)
|
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.NoBrowser, input.NoBrowser)
|
||||||
|
|
||||||
|
setBool(config.NotificationsEnabled, input.NotificationsEnabled)
|
||||||
|
|
||||||
|
setBool(config.ShowScrubber, input.ShowScrubber)
|
||||||
|
|
||||||
if input.WallPlayback != nil {
|
if input.WallPlayback != nil {
|
||||||
c.Set(config.WallPlayback, *input.WallPlayback)
|
c.Set(config.WallPlayback, *input.WallPlayback)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package api
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -220,6 +221,71 @@ func (r *mutationResolver) MovieUpdate(ctx context.Context, input models.MovieUp
|
|||||||
return r.getMovie(ctx, movie.ID)
|
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) {
|
func (r *mutationResolver) MovieDestroy(ctx context.Context, input models.MovieDestroyInput) (bool, error) {
|
||||||
id, err := strconv.Atoi(input.ID)
|
id, err := strconv.Atoi(input.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -27,3 +27,62 @@ func (r *mutationResolver) StashBoxBatchPerformerTag(ctx context.Context, input
|
|||||||
jobID := manager.GetInstance().StashBoxBatchPerformerTag(ctx, input)
|
jobID := manager.GetInstance().StashBoxBatchPerformerTag(ctx, input)
|
||||||
return strconv.Itoa(jobID), nil
|
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/stashapp/stash/pkg/manager/config"
|
"github.com/stashapp/stash/pkg/manager/config"
|
||||||
"github.com/stashapp/stash/pkg/models"
|
"github.com/stashapp/stash/pkg/models"
|
||||||
|
"github.com/stashapp/stash/pkg/scraper/stashbox"
|
||||||
"github.com/stashapp/stash/pkg/utils"
|
"github.com/stashapp/stash/pkg/utils"
|
||||||
"golang.org/x/text/collate"
|
"golang.org/x/text/collate"
|
||||||
)
|
)
|
||||||
@@ -83,7 +86,6 @@ func makeConfigGeneralResult() *models.ConfigGeneralResult {
|
|||||||
Username: config.GetUsername(),
|
Username: config.GetUsername(),
|
||||||
Password: config.GetPasswordHash(),
|
Password: config.GetPasswordHash(),
|
||||||
MaxSessionAge: config.GetMaxSessionAge(),
|
MaxSessionAge: config.GetMaxSessionAge(),
|
||||||
TrustedProxies: config.GetTrustedProxies(),
|
|
||||||
LogFile: &logFile,
|
LogFile: &logFile,
|
||||||
LogOut: config.GetLogOut(),
|
LogOut: config.GetLogOut(),
|
||||||
LogLevel: config.GetLogLevel(),
|
LogLevel: config.GetLogLevel(),
|
||||||
@@ -107,8 +109,10 @@ func makeConfigInterfaceResult() *models.ConfigInterfaceResult {
|
|||||||
menuItems := config.GetMenuItems()
|
menuItems := config.GetMenuItems()
|
||||||
soundOnPreview := config.GetSoundOnPreview()
|
soundOnPreview := config.GetSoundOnPreview()
|
||||||
wallShowTitle := config.GetWallShowTitle()
|
wallShowTitle := config.GetWallShowTitle()
|
||||||
|
showScrubber := config.GetShowScrubber()
|
||||||
wallPlayback := config.GetWallPlayback()
|
wallPlayback := config.GetWallPlayback()
|
||||||
noBrowser := config.GetNoBrowser()
|
noBrowser := config.GetNoBrowser()
|
||||||
|
notificationsEnabled := config.GetNotificationsEnabled()
|
||||||
maximumLoopDuration := config.GetMaximumLoopDuration()
|
maximumLoopDuration := config.GetMaximumLoopDuration()
|
||||||
autostartVideo := config.GetAutostartVideo()
|
autostartVideo := config.GetAutostartVideo()
|
||||||
autostartVideoOnPlaySelected := config.GetAutostartVideoOnPlaySelected()
|
autostartVideoOnPlaySelected := config.GetAutostartVideoOnPlaySelected()
|
||||||
@@ -129,8 +133,10 @@ func makeConfigInterfaceResult() *models.ConfigInterfaceResult {
|
|||||||
SoundOnPreview: &soundOnPreview,
|
SoundOnPreview: &soundOnPreview,
|
||||||
WallShowTitle: &wallShowTitle,
|
WallShowTitle: &wallShowTitle,
|
||||||
WallPlayback: &wallPlayback,
|
WallPlayback: &wallPlayback,
|
||||||
|
ShowScrubber: &showScrubber,
|
||||||
MaximumLoopDuration: &maximumLoopDuration,
|
MaximumLoopDuration: &maximumLoopDuration,
|
||||||
NoBrowser: &noBrowser,
|
NoBrowser: &noBrowser,
|
||||||
|
NotificationsEnabled: ¬ificationsEnabled,
|
||||||
AutostartVideo: &autostartVideo,
|
AutostartVideo: &autostartVideo,
|
||||||
ShowStudioAsText: &showStudioAsText,
|
ShowStudioAsText: &showStudioAsText,
|
||||||
AutostartVideoOnPlaySelected: &autostartVideoOnPlaySelected,
|
AutostartVideoOnPlaySelected: &autostartVideoOnPlaySelected,
|
||||||
@@ -188,3 +194,38 @@ func makeConfigDefaultsResult() *models.ConfigDefaultSettingsResult {
|
|||||||
DeleteGenerated: &deleteGeneratedDefault,
|
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"
|
||||||
"github.com/go-chi/chi/middleware"
|
"github.com/go-chi/chi/middleware"
|
||||||
"github.com/gorilla/websocket"
|
"github.com/gorilla/websocket"
|
||||||
"github.com/pkg/browser"
|
|
||||||
|
"github.com/go-chi/httplog"
|
||||||
"github.com/rs/cors"
|
"github.com/rs/cors"
|
||||||
|
"github.com/stashapp/stash/pkg/desktop"
|
||||||
"github.com/stashapp/stash/pkg/logger"
|
"github.com/stashapp/stash/pkg/logger"
|
||||||
"github.com/stashapp/stash/pkg/manager"
|
"github.com/stashapp/stash/pkg/manager"
|
||||||
"github.com/stashapp/stash/pkg/manager/config"
|
"github.com/stashapp/stash/pkg/manager/config"
|
||||||
@@ -36,7 +38,6 @@ import (
|
|||||||
var version string
|
var version string
|
||||||
var buildstamp string
|
var buildstamp string
|
||||||
var githash string
|
var githash string
|
||||||
var officialBuild string
|
|
||||||
|
|
||||||
func Start(uiBox embed.FS, loginUIBox embed.FS) {
|
func Start(uiBox embed.FS, loginUIBox embed.FS) {
|
||||||
initialiseImages()
|
initialiseImages()
|
||||||
@@ -52,7 +53,10 @@ func Start(uiBox embed.FS, loginUIBox embed.FS) {
|
|||||||
|
|
||||||
c := config.GetInstance()
|
c := config.GetInstance()
|
||||||
if c.GetLogAccess() {
|
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(SecurityHeadersMiddleware)
|
||||||
r.Use(middleware.DefaultCompress)
|
r.Use(middleware.DefaultCompress)
|
||||||
@@ -184,6 +188,7 @@ func Start(uiBox embed.FS, loginUIBox embed.FS) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
customUILocation := c.GetCustomUILocation()
|
customUILocation := c.GetCustomUILocation()
|
||||||
|
static := statigz.FileServer(uiBox)
|
||||||
|
|
||||||
// Serve the web app
|
// Serve the web app
|
||||||
r.HandleFunc("/*", func(w http.ResponseWriter, r *http.Request) {
|
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
|
r.URL.Path = uiRootDir + r.URL.Path
|
||||||
|
|
||||||
statigz.FileServer(uiBox).ServeHTTP(w, r)
|
static.ServeHTTP(w, r)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -245,25 +250,16 @@ func Start(uiBox embed.FS, loginUIBox embed.FS) {
|
|||||||
TLSConfig: tlsConfig,
|
TLSConfig: tlsConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
printVersion()
|
||||||
|
go printLatestVersion(context.TODO())
|
||||||
|
logger.Infof("stash is listening on " + address)
|
||||||
|
if tlsConfig != nil {
|
||||||
|
displayAddress = "https://" + displayAddress + "/"
|
||||||
|
} else {
|
||||||
|
displayAddress = "http://" + displayAddress + "/"
|
||||||
|
}
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
printVersion()
|
|
||||||
printLatestVersion(context.TODO())
|
|
||||||
logger.Infof("stash is listening on " + address)
|
|
||||||
if tlsConfig != nil {
|
|
||||||
displayAddress = "https://" + displayAddress + "/"
|
|
||||||
} else {
|
|
||||||
displayAddress = "http://" + displayAddress + "/"
|
|
||||||
}
|
|
||||||
|
|
||||||
// This can be done before actually starting the server, as modern browsers will
|
|
||||||
// automatically reload the page if a local port is closed at page load and then opened.
|
|
||||||
if !c.GetNoBrowser() && manager.GetInstance().IsDesktop() {
|
|
||||||
err = browser.OpenURL(displayAddress)
|
|
||||||
if err != nil {
|
|
||||||
logger.Error("Could not open browser: " + err.Error())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if tlsConfig != nil {
|
if tlsConfig != nil {
|
||||||
logger.Infof("stash is running at " + displayAddress)
|
logger.Infof("stash is running at " + displayAddress)
|
||||||
logger.Error(server.ListenAndServeTLS("", ""))
|
logger.Error(server.ListenAndServeTLS("", ""))
|
||||||
@@ -271,12 +267,14 @@ func Start(uiBox embed.FS, loginUIBox embed.FS) {
|
|||||||
logger.Infof("stash is running at " + displayAddress)
|
logger.Infof("stash is running at " + displayAddress)
|
||||||
logger.Error(server.ListenAndServe())
|
logger.Error(server.ListenAndServe())
|
||||||
}
|
}
|
||||||
|
manager.GetInstance().Shutdown(0)
|
||||||
}()
|
}()
|
||||||
|
desktop.Start(manager.GetInstance(), &FaviconProvider{uiBox: uiBox})
|
||||||
}
|
}
|
||||||
|
|
||||||
func printVersion() {
|
func printVersion() {
|
||||||
versionString := githash
|
versionString := githash
|
||||||
if IsOfficialBuild() {
|
if config.IsOfficialBuild() {
|
||||||
versionString += " - Official Build"
|
versionString += " - Official Build"
|
||||||
} else {
|
} else {
|
||||||
versionString += " - Unofficial Build"
|
versionString += " - Unofficial Build"
|
||||||
@@ -287,10 +285,6 @@ func printVersion() {
|
|||||||
fmt.Printf("stash version: %s - %s\n", versionString, buildstamp)
|
fmt.Printf("stash version: %s - %s\n", versionString, buildstamp)
|
||||||
}
|
}
|
||||||
|
|
||||||
func IsOfficialBuild() bool {
|
|
||||||
return officialBuild == "true"
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetVersion() (string, string, string) {
|
func GetVersion() (string, string, string) {
|
||||||
return version, githash, buildstamp
|
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("Referrer-Policy", "same-origin")
|
||||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
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("X-XSS-Protection", "1")
|
||||||
w.Header().Set("Content-Security-Policy", cspDirectives)
|
w.Header().Set("Content-Security-Policy", cspDirectives)
|
||||||
|
|
||||||
@@ -385,13 +378,7 @@ func BaseURLMiddleware(next http.Handler) http.Handler {
|
|||||||
}
|
}
|
||||||
prefix := getProxyPrefix(r.Header)
|
prefix := getProxyPrefix(r.Header)
|
||||||
|
|
||||||
port := ""
|
baseURL := scheme + "://" + r.Host + prefix
|
||||||
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
|
|
||||||
|
|
||||||
externalHost := config.GetInstance().GetExternalHost()
|
externalHost := config.GetInstance().GetExternalHost()
|
||||||
if externalHost != "" {
|
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"
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/stashapp/stash/pkg/desktop"
|
||||||
"github.com/stashapp/stash/pkg/logger"
|
"github.com/stashapp/stash/pkg/logger"
|
||||||
"github.com/stashapp/stash/pkg/utils"
|
"github.com/stashapp/stash/pkg/utils"
|
||||||
)
|
)
|
||||||
@@ -202,7 +203,9 @@ func pathBinaryHasCorrectFlags() bool {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
bytes, _ := exec.Command(ffmpegPath).CombinedOutput()
|
cmd := exec.Command(ffmpegPath)
|
||||||
|
desktop.HideExecShell(cmd)
|
||||||
|
bytes, _ := cmd.CombinedOutput()
|
||||||
output := string(bytes)
|
output := string(bytes)
|
||||||
hasOpus := strings.Contains(output, "--enable-libopus")
|
hasOpus := strings.Contains(output, "--enable-libopus")
|
||||||
hasVpx := strings.Contains(output, "--enable-libvpx")
|
hasVpx := strings.Contains(output, "--enable-libvpx")
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/stashapp/stash/pkg/desktop"
|
||||||
"github.com/stashapp/stash/pkg/logger"
|
"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())
|
logger.Error("FFMPEG stdout not available: " + err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
desktop.HideExecShell(cmd)
|
||||||
if err = cmd.Start(); err != nil {
|
if err = cmd.Start(); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -141,6 +143,7 @@ func (e *Encoder) run(sourcePath string, args []string, stdin io.Reader) (string
|
|||||||
cmd.Stderr = &stderr
|
cmd.Stderr = &stderr
|
||||||
cmd.Stdin = stdin
|
cmd.Stdin = stdin
|
||||||
|
|
||||||
|
desktop.HideExecShell(cmd)
|
||||||
if err := cmd.Start(); err != nil {
|
if err := cmd.Start(); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
|
|
||||||
type SpriteScreenshotOptions struct {
|
type SpriteScreenshotOptions struct {
|
||||||
Time float64
|
Time float64
|
||||||
|
Frame int
|
||||||
Width int
|
Width int
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,3 +37,31 @@ func (e *Encoder) SpriteScreenshot(probeResult VideoFile, options SpriteScreensh
|
|||||||
|
|
||||||
return img, err
|
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"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/stashapp/stash/pkg/desktop"
|
||||||
"github.com/stashapp/stash/pkg/logger"
|
"github.com/stashapp/stash/pkg/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -218,6 +219,7 @@ type VideoFile struct {
|
|||||||
Height int
|
Height int
|
||||||
FrameRate float64
|
FrameRate float64
|
||||||
Rotation int64
|
Rotation int64
|
||||||
|
FrameCount int64
|
||||||
|
|
||||||
AudioCodec string
|
AudioCodec string
|
||||||
}
|
}
|
||||||
@@ -228,7 +230,9 @@ type FFProbe string
|
|||||||
// Execute exec command and bind result to struct.
|
// Execute exec command and bind result to struct.
|
||||||
func (f *FFProbe) NewVideoFile(videoPath string, stripExt bool) (*VideoFile, error) {
|
func (f *FFProbe) NewVideoFile(videoPath string, stripExt bool) (*VideoFile, error) {
|
||||||
args := []string{"-v", "quiet", "-print_format", "json", "-show_format", "-show_streams", "-show_error", videoPath}
|
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("FFProbe encountered an error with <%s>.\nError JSON:\n%s\nError: %s", videoPath, string(out), err.Error())
|
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)
|
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) {
|
func parse(filePath string, probeJSON *FFProbeJSON, stripExt bool) (*VideoFile, error) {
|
||||||
if probeJSON == nil {
|
if probeJSON == nil {
|
||||||
return nil, fmt.Errorf("failed to get ffprobe json for <%s>", filePath)
|
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.Comment = probeJSON.Format.Tags.Comment
|
||||||
|
|
||||||
result.Bitrate, _ = strconv.ParseInt(probeJSON.Format.BitRate, 10, 64)
|
result.Bitrate, _ = strconv.ParseInt(probeJSON.Format.BitRate, 10, 64)
|
||||||
|
|
||||||
result.Container = probeJSON.Format.FormatName
|
result.Container = probeJSON.Format.FormatName
|
||||||
duration, _ := strconv.ParseFloat(probeJSON.Format.Duration, 64)
|
duration, _ := strconv.ParseFloat(probeJSON.Format.Duration, 64)
|
||||||
result.Duration = math.Round(duration*100) / 100
|
result.Duration = math.Round(duration*100) / 100
|
||||||
@@ -288,6 +310,15 @@ func parse(filePath string, probeJSON *FFProbeJSON, stripExt bool) (*VideoFile,
|
|||||||
if videoStream != nil {
|
if videoStream != nil {
|
||||||
result.VideoStream = videoStream
|
result.VideoStream = videoStream
|
||||||
result.VideoCodec = videoStream.CodecName
|
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)
|
result.VideoBitrate, _ = strconv.ParseInt(videoStream.BitRate, 10, 64)
|
||||||
var framerate float64
|
var framerate float64
|
||||||
if strings.Contains(videoStream.AvgFrameRate, "/") {
|
if strings.Contains(videoStream.AvgFrameRate, "/") {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/stashapp/stash/pkg/desktop"
|
||||||
"github.com/stashapp/stash/pkg/logger"
|
"github.com/stashapp/stash/pkg/logger"
|
||||||
"github.com/stashapp/stash/pkg/models"
|
"github.com/stashapp/stash/pkg/models"
|
||||||
)
|
)
|
||||||
@@ -220,6 +221,7 @@ func (e *Encoder) stream(probeResult VideoFile, options TranscodeStreamOptions)
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
desktop.HideExecShell(cmd)
|
||||||
if err = cmd.Start(); err != nil {
|
if err = cmd.Start(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ type FFProbeStream struct {
|
|||||||
Level int `json:"level,omitempty"`
|
Level int `json:"level,omitempty"`
|
||||||
NalLengthSize string `json:"nal_length_size,omitempty"`
|
NalLengthSize string `json:"nal_length_size,omitempty"`
|
||||||
NbFrames string `json:"nb_frames"`
|
NbFrames string `json:"nb_frames"`
|
||||||
|
NbReadFrames string `json:"nb_read_frames"`
|
||||||
PixFmt string `json:"pix_fmt,omitempty"`
|
PixFmt string `json:"pix_fmt,omitempty"`
|
||||||
Profile string `json:"profile"`
|
Profile string `json:"profile"`
|
||||||
RFrameRate string `json:"r_frame_rate"`
|
RFrameRate string `json:"r_frame_rate"`
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -117,6 +118,19 @@ func (o Scanner) generateHashes(f *models.File, file SourceFile, regenerate bool
|
|||||||
if o.CalculateOSHash && (regenerate || f.OSHash == "") {
|
if o.CalculateOSHash && (regenerate || f.OSHash == "") {
|
||||||
logger.Infof("Calculating oshash for %s ...", f.Path)
|
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()
|
src, err = file.Open()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
@@ -130,7 +144,7 @@ func (o Scanner) generateHashes(f *models.File, file SourceFile, regenerate bool
|
|||||||
|
|
||||||
// regenerate hash
|
// regenerate hash
|
||||||
var oshash string
|
var oshash string
|
||||||
oshash, err = o.Hasher.OSHash(seekSrc, file.FileInfo().Size())
|
oshash, err = o.Hasher.OSHash(seekSrc, size)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, fmt.Errorf("error generating oshash for %s: %w", file.Path(), err)
|
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 {
|
if result == nil {
|
||||||
logger.Infof("Unable to identify %s", scene.Path)
|
logger.Debugf("Unable to identify %s", scene.Path)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,7 +176,7 @@ func (t *SceneIdentifier) modifyScene(ctx context.Context, txnManager models.Tra
|
|||||||
|
|
||||||
// don't update anything if nothing was set
|
// don't update anything if nothing was set
|
||||||
if updater.IsEmpty() {
|
if updater.IsEmpty() {
|
||||||
logger.Infof("Nothing to set for %s", s.Path)
|
logger.Debugf("Nothing to set for %s", s.Path)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"os/exec"
|
"os/exec"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/stashapp/stash/pkg/desktop"
|
||||||
"github.com/stashapp/stash/pkg/logger"
|
"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.Stderr = &stderr
|
||||||
cmd.Stdin = stdin
|
cmd.Stdin = stdin
|
||||||
|
|
||||||
|
desktop.HideExecShell(cmd)
|
||||||
if err := cmd.Start(); err != nil {
|
if err := cmd.Start(); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,13 @@ package job
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/stashapp/stash/pkg/desktop"
|
||||||
"github.com/stashapp/stash/pkg/utils"
|
"github.com/stashapp/stash/pkg/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -204,9 +208,14 @@ func (m *Manager) onJobFinish(job *Job) {
|
|||||||
} else {
|
} else {
|
||||||
job.Status = StatusFinished
|
job.Status = StatusFinished
|
||||||
}
|
}
|
||||||
|
|
||||||
t := time.Now()
|
t := time.Now()
|
||||||
job.EndTime = &t
|
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) {
|
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.TimestampFormat = "2006-01-02 15:04:05"
|
||||||
customFormatter.ForceColors = true
|
customFormatter.ForceColors = true
|
||||||
customFormatter.FullTimestamp = true
|
customFormatter.FullTimestamp = true
|
||||||
|
logger.SetOutput(os.Stderr)
|
||||||
logger.SetFormatter(customFormatter)
|
logger.SetFormatter(customFormatter)
|
||||||
|
|
||||||
// #1837 - trigger the console to use color-mode since it won't be
|
// #1837 - trigger the console to use color-mode since it won't be
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ import (
|
|||||||
"github.com/stashapp/stash/pkg/utils"
|
"github.com/stashapp/stash/pkg/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var officialBuild string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
Stash = "stash"
|
Stash = "stash"
|
||||||
Cache = "cache"
|
Cache = "cache"
|
||||||
@@ -133,6 +135,9 @@ const (
|
|||||||
ShowStudioAsText = "show_studio_as_text"
|
ShowStudioAsText = "show_studio_as_text"
|
||||||
CSSEnabled = "cssEnabled"
|
CSSEnabled = "cssEnabled"
|
||||||
|
|
||||||
|
ShowScrubber = "show_scrubber"
|
||||||
|
showScrubberDefault = true
|
||||||
|
|
||||||
WallPlayback = "wall_playback"
|
WallPlayback = "wall_playback"
|
||||||
defaultWallPlayback = "video"
|
defaultWallPlayback = "video"
|
||||||
|
|
||||||
@@ -147,7 +152,6 @@ const (
|
|||||||
FunscriptOffset = "funscript_offset"
|
FunscriptOffset = "funscript_offset"
|
||||||
|
|
||||||
// Security
|
// Security
|
||||||
TrustedProxies = "trusted_proxies"
|
|
||||||
dangerousAllowPublicWithoutAuth = "dangerous_allow_public_without_auth"
|
dangerousAllowPublicWithoutAuth = "dangerous_allow_public_without_auth"
|
||||||
dangerousAllowPublicWithoutAuthDefault = "false"
|
dangerousAllowPublicWithoutAuthDefault = "false"
|
||||||
SecurityTripwireAccessedFromPublicInternet = "security_tripwire_accessed_from_public_internet"
|
SecurityTripwireAccessedFromPublicInternet = "security_tripwire_accessed_from_public_internet"
|
||||||
@@ -179,8 +183,12 @@ const (
|
|||||||
deleteGeneratedDefaultDefault = true
|
deleteGeneratedDefaultDefault = true
|
||||||
|
|
||||||
// Desktop Integration Options
|
// Desktop Integration Options
|
||||||
NoBrowser = "noBrowser"
|
NoBrowser = "noBrowser"
|
||||||
NoBrowserDefault = false
|
NoBrowserDefault = false
|
||||||
|
NotificationsEnabled = "notifications_enabled"
|
||||||
|
NotificationsEnabledDefault = true
|
||||||
|
ShowOneTimeMovedNotification = "show_one_time_moved_notification"
|
||||||
|
ShowOneTimeMovedNotificationDefault = false
|
||||||
|
|
||||||
// File upload options
|
// File upload options
|
||||||
MaxUploadSize = "max_upload_size"
|
MaxUploadSize = "max_upload_size"
|
||||||
@@ -212,6 +220,10 @@ func (s *StashBoxError) Error() string {
|
|||||||
return "Stash-box: " + s.msg
|
return "Stash-box: " + s.msg
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func IsOfficialBuild() bool {
|
||||||
|
return officialBuild == "true"
|
||||||
|
}
|
||||||
|
|
||||||
type Instance struct {
|
type Instance struct {
|
||||||
// main instance - backed by config file
|
// main instance - backed by config file
|
||||||
main *viper.Viper
|
main *viper.Viper
|
||||||
@@ -222,8 +234,9 @@ type Instance struct {
|
|||||||
|
|
||||||
cpuProfilePath string
|
cpuProfilePath string
|
||||||
isNewSystem bool
|
isNewSystem bool
|
||||||
certFile string
|
// configUpdates chan int
|
||||||
keyFile string
|
certFile string
|
||||||
|
keyFile string
|
||||||
sync.RWMutex
|
sync.RWMutex
|
||||||
// deadlock.RWMutex // for deadlock testing/issues
|
// deadlock.RWMutex // for deadlock testing/issues
|
||||||
}
|
}
|
||||||
@@ -271,7 +284,25 @@ func (i *Instance) GetNoBrowser() bool {
|
|||||||
return i.getBool(NoBrowser)
|
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{}) {
|
func (i *Instance) Set(key string, value interface{}) {
|
||||||
|
// if key == MenuItems {
|
||||||
|
// i.configUpdates <- 0
|
||||||
|
// }
|
||||||
i.Lock()
|
i.Lock()
|
||||||
defer i.Unlock()
|
defer i.Unlock()
|
||||||
i.main.Set(key, value)
|
i.main.Set(key, value)
|
||||||
@@ -367,6 +398,18 @@ func (i *Instance) getBool(key string) bool {
|
|||||||
return i.viper(key).GetBool(key)
|
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 {
|
func (i *Instance) getInt(key string) int {
|
||||||
i.RLock()
|
i.RLock()
|
||||||
defer i.RUnlock()
|
defer i.RUnlock()
|
||||||
@@ -531,16 +574,7 @@ func (i *Instance) GetScraperCDPPath() string {
|
|||||||
// GetScraperCertCheck returns true if the scraper should check for insecure
|
// GetScraperCertCheck returns true if the scraper should check for insecure
|
||||||
// certificates when fetching an image or a page.
|
// certificates when fetching an image or a page.
|
||||||
func (i *Instance) GetScraperCertCheck() bool {
|
func (i *Instance) GetScraperCertCheck() bool {
|
||||||
ret := true
|
return i.getBoolDefault(ScraperCertCheck, true)
|
||||||
i.RLock()
|
|
||||||
defer i.RUnlock()
|
|
||||||
|
|
||||||
v := i.viper(ScraperCertCheck)
|
|
||||||
if v.IsSet(ScraperCertCheck) {
|
|
||||||
ret = v.GetBool(ScraperCertCheck)
|
|
||||||
}
|
|
||||||
|
|
||||||
return ret
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (i *Instance) GetScraperExcludeTagPatterns() []string {
|
func (i *Instance) GetScraperExcludeTagPatterns() []string {
|
||||||
@@ -820,6 +854,10 @@ func (i *Instance) GetWallPlayback() string {
|
|||||||
return ret
|
return ret
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (i *Instance) GetShowScrubber() bool {
|
||||||
|
return i.getBoolDefault(ShowScrubber, showScrubberDefault)
|
||||||
|
}
|
||||||
|
|
||||||
func (i *Instance) GetMaximumLoopDuration() int {
|
func (i *Instance) GetMaximumLoopDuration() int {
|
||||||
return i.getInt(MaximumLoopDuration)
|
return i.getInt(MaximumLoopDuration)
|
||||||
}
|
}
|
||||||
@@ -829,16 +867,7 @@ func (i *Instance) GetAutostartVideo() bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (i *Instance) GetAutostartVideoOnPlaySelected() bool {
|
func (i *Instance) GetAutostartVideoOnPlaySelected() bool {
|
||||||
i.Lock()
|
return i.getBoolDefault(AutostartVideoOnPlaySelected, autostartVideoOnPlaySelectedDefault)
|
||||||
defer i.Unlock()
|
|
||||||
|
|
||||||
ret := autostartVideoOnPlaySelectedDefault
|
|
||||||
v := i.viper(AutostartVideoOnPlaySelected)
|
|
||||||
if v.IsSet(AutostartVideoOnPlaySelected) {
|
|
||||||
ret = v.GetBool(AutostartVideoOnPlaySelected)
|
|
||||||
}
|
|
||||||
|
|
||||||
return ret
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (i *Instance) GetContinuePlaylistDefault() bool {
|
func (i *Instance) GetContinuePlaylistDefault() bool {
|
||||||
@@ -926,16 +955,7 @@ func (i *Instance) GetDeleteFileDefault() bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (i *Instance) GetDeleteGeneratedDefault() bool {
|
func (i *Instance) GetDeleteGeneratedDefault() bool {
|
||||||
i.RLock()
|
return i.getBoolDefault(DeleteGeneratedDefault, deleteGeneratedDefaultDefault)
|
||||||
defer i.RUnlock()
|
|
||||||
ret := deleteGeneratedDefaultDefault
|
|
||||||
|
|
||||||
v := i.viper(DeleteGeneratedDefault)
|
|
||||||
if v.IsSet(DeleteGeneratedDefault) {
|
|
||||||
ret = v.GetBool(DeleteGeneratedDefault)
|
|
||||||
}
|
|
||||||
|
|
||||||
return ret
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetDefaultIdentifySettings returns the default Identify task settings.
|
// GetDefaultIdentifySettings returns the default Identify task settings.
|
||||||
@@ -1014,12 +1034,6 @@ func (i *Instance) GetDefaultGenerateSettings() *models.GenerateMetadataOptions
|
|||||||
return nil
|
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.
|
// GetDangerousAllowPublicWithoutAuth determines if the security feature is enabled.
|
||||||
// See https://github.com/stashapp/stash/wiki/Authentication-Required-When-Accessing-Stash-From-the-Internet
|
// See https://github.com/stashapp/stash/wiki/Authentication-Required-When-Accessing-Stash-From-the-Internet
|
||||||
func (i *Instance) GetDangerousAllowPublicWithoutAuth() bool {
|
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
|
// in addition to writing to a log file. Logging will be output to the
|
||||||
// terminal if file logging is disabled. Defaults to true.
|
// terminal if file logging is disabled. Defaults to true.
|
||||||
func (i *Instance) GetLogOut() bool {
|
func (i *Instance) GetLogOut() bool {
|
||||||
i.RLock()
|
return i.getBoolDefault(LogOut, defaultLogOut)
|
||||||
defer i.RUnlock()
|
|
||||||
|
|
||||||
ret := defaultLogOut
|
|
||||||
v := i.viper(LogOut)
|
|
||||||
|
|
||||||
if v.IsSet(LogOut) {
|
|
||||||
ret = v.GetBool(LogOut)
|
|
||||||
}
|
|
||||||
|
|
||||||
return ret
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetLogLevel returns the lowest log level to write to the log.
|
// 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.
|
// GetLogAccess returns true if http requests should be logged to the terminal.
|
||||||
// HTTP requests are not logged to the log file. Defaults to true.
|
// HTTP requests are not logged to the log file. Defaults to true.
|
||||||
func (i *Instance) GetLogAccess() bool {
|
func (i *Instance) GetLogAccess() bool {
|
||||||
i.RLock()
|
return i.getBoolDefault(LogAccess, defaultLogAccess)
|
||||||
defer i.RUnlock()
|
|
||||||
ret := defaultLogAccess
|
|
||||||
|
|
||||||
v := i.viper(LogAccess)
|
|
||||||
if v.IsSet(LogAccess) {
|
|
||||||
ret = v.GetBool(LogAccess)
|
|
||||||
}
|
|
||||||
|
|
||||||
return ret
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Max allowed graphql upload size in megabytes
|
// 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(Generated, i.main.GetString(Metadata))
|
||||||
|
|
||||||
i.main.SetDefault(NoBrowser, NoBrowserDefault)
|
i.main.SetDefault(NoBrowser, NoBrowserDefault)
|
||||||
|
i.main.SetDefault(NotificationsEnabled, NotificationsEnabledDefault)
|
||||||
|
i.main.SetDefault(ShowOneTimeMovedNotification, ShowOneTimeMovedNotificationDefault)
|
||||||
|
|
||||||
// Set default scrapers and plugins paths
|
// Set default scrapers and plugins paths
|
||||||
i.main.SetDefault(ScrapersPath, defaultScrapersPath)
|
i.main.SetDefault(ScrapersPath, defaultScrapersPath)
|
||||||
@@ -1217,6 +1214,12 @@ func (i *Instance) setExistingSystemDefaults() error {
|
|||||||
i.main.Set(NoBrowser, true)
|
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 {
|
if configDirtied {
|
||||||
return i.main.WriteConfig()
|
return i.main.WriteConfig()
|
||||||
}
|
}
|
||||||
@@ -1254,4 +1257,5 @@ func (i *Instance) setInitialConfig(write bool) error {
|
|||||||
|
|
||||||
func (i *Instance) FinalizeSetup() {
|
func (i *Instance) FinalizeSetup() {
|
||||||
i.isNewSystem = false
|
i.isNewSystem = false
|
||||||
|
// i.configUpdates <- 0
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,7 +99,6 @@ func TestConcurrentConfigAccess(t *testing.T) {
|
|||||||
i.Set(DefaultIdentifySettings, i.GetDefaultIdentifySettings())
|
i.Set(DefaultIdentifySettings, i.GetDefaultIdentifySettings())
|
||||||
i.Set(DeleteGeneratedDefault, i.GetDeleteGeneratedDefault())
|
i.Set(DeleteGeneratedDefault, i.GetDeleteGeneratedDefault())
|
||||||
i.Set(DeleteFileDefault, i.GetDeleteFileDefault())
|
i.Set(DeleteFileDefault, i.GetDeleteFileDefault())
|
||||||
i.Set(TrustedProxies, i.GetTrustedProxies())
|
|
||||||
i.Set(dangerousAllowPublicWithoutAuth, i.GetDangerousAllowPublicWithoutAuth())
|
i.Set(dangerousAllowPublicWithoutAuth, i.GetDangerousAllowPublicWithoutAuth())
|
||||||
i.Set(SecurityTripwireAccessedFromPublicInternet, i.GetSecurityTripwireAccessedFromPublicInternet())
|
i.Set(SecurityTripwireAccessedFromPublicInternet, i.GetSecurityTripwireAccessedFromPublicInternet())
|
||||||
i.Set(DisableDropdownCreatePerformer, i.GetDisableDropdownCreate().Performer)
|
i.Set(DisableDropdownCreatePerformer, i.GetDisableDropdownCreate().Performer)
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ func Initialize() (*Instance, error) {
|
|||||||
_ = GetInstance()
|
_ = GetInstance()
|
||||||
instance.overrides = overrides
|
instance.overrides = overrides
|
||||||
instance.cpuProfilePath = flags.cpuProfilePath
|
instance.cpuProfilePath = flags.cpuProfilePath
|
||||||
|
// instance.configUpdates = make(chan int)
|
||||||
|
|
||||||
if err = initConfig(instance, flags); err != nil {
|
if err = initConfig(instance, flags); err != nil {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ func excludeFiles(files []string, patterns []string) ([]string, int) {
|
|||||||
|
|
||||||
func matchFileRegex(file string, fileRegexps []*regexp.Regexp) bool {
|
func matchFileRegex(file string, fileRegexps []*regexp.Regexp) bool {
|
||||||
for _, regPattern := range fileRegexps {
|
for _, regPattern := range fileRegexps {
|
||||||
if regPattern.MatchString(strings.ToLower(file)) {
|
if regPattern.MatchString(file) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -60,7 +60,10 @@ func generateRegexps(patterns []string) []*regexp.Regexp {
|
|||||||
var fileRegexps []*regexp.Regexp
|
var fileRegexps []*regexp.Regexp
|
||||||
|
|
||||||
for _, pattern := range patterns {
|
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 {
|
if err != nil {
|
||||||
logger.Errorf("Exclude :%v", err)
|
logger.Errorf("Exclude :%v", err)
|
||||||
} else {
|
} else {
|
||||||
@@ -78,7 +81,7 @@ func generateRegexps(patterns []string) []*regexp.Regexp {
|
|||||||
|
|
||||||
func matchFileSimple(file string, regExps []*regexp.Regexp) bool {
|
func matchFileSimple(file string, regExps []*regexp.Regexp) bool {
|
||||||
for _, regPattern := range regExps {
|
for _, regPattern := range regExps {
|
||||||
if regPattern.MatchString(strings.ToLower(file)) {
|
if regPattern.MatchString(file) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,9 @@ var excludeTestFilenames = []string{
|
|||||||
"\\\\network\\videos\\filename windows network.mp4",
|
"\\\\network\\videos\\filename windows network.mp4",
|
||||||
"\\\\network\\share\\windows network wanted.mp4",
|
"\\\\network\\share\\windows network wanted.mp4",
|
||||||
"\\\\network\\share\\windows network wanted sample.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 {
|
var excludeTests = []struct {
|
||||||
testPattern []string
|
testPattern []string
|
||||||
@@ -42,6 +44,10 @@ var excludeTests = []struct {
|
|||||||
{[]string{"^\\\\\\\\network"}, 4}, // windows net share
|
{[]string{"^\\\\\\\\network"}, 4}, // windows net share
|
||||||
{[]string{"\\\\private\\\\"}, 1}, // windows net share
|
{[]string{"\\\\private\\\\"}, 1}, // windows net share
|
||||||
{[]string{"\\\\private\\\\", "sample\\.mp4"}, 3}, // 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) {
|
func TestExcludeFiles(t *testing.T) {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/stashapp/stash/pkg/desktop"
|
||||||
"github.com/stashapp/stash/pkg/ffmpeg"
|
"github.com/stashapp/stash/pkg/ffmpeg"
|
||||||
"github.com/stashapp/stash/pkg/logger"
|
"github.com/stashapp/stash/pkg/logger"
|
||||||
"github.com/stashapp/stash/pkg/utils"
|
"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...)
|
command := exec.Command(string(instance.FFMPEG), args...)
|
||||||
|
desktop.HideExecShell(command)
|
||||||
var stdErrBuffer bytes.Buffer
|
var stdErrBuffer bytes.Buffer
|
||||||
command.Stderr = &stdErrBuffer // Frames go to stderr rather than stdout
|
command.Stderr = &stdErrBuffer // Frames go to stderr rather than stdout
|
||||||
if err := command.Run(); err == nil {
|
if err := command.Run(); err == nil {
|
||||||
@@ -112,6 +114,12 @@ func (g *GeneratorInfo) configure() error {
|
|||||||
return err
|
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
|
g.NthFrame = g.NumberOfFrames / g.ChunkCount
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ type SpriteGenerator struct {
|
|||||||
VTTOutputPath string
|
VTTOutputPath string
|
||||||
Rows int
|
Rows int
|
||||||
Columns int
|
Columns int
|
||||||
|
SlowSeek bool // use alternate seek function, very slow!
|
||||||
|
|
||||||
Overwrite bool
|
Overwrite bool
|
||||||
}
|
}
|
||||||
@@ -34,17 +35,33 @@ func NewSpriteGenerator(videoFile ffmpeg.VideoFile, videoChecksum string, imageO
|
|||||||
if !exists {
|
if !exists {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
slowSeek := false
|
||||||
|
chunkCount := rows * cols
|
||||||
|
|
||||||
// FFMPEG bombs out if we try to request 89 snapshots from a 2 second video
|
// For files with small duration / low frame count try to seek using frame number intead of seconds
|
||||||
if videoFile.Duration < 3 {
|
if videoFile.Duration < 5 || (0 < videoFile.FrameCount && videoFile.FrameCount <= int64(chunkCount)) { // some files can have FrameCount == 0, only use SlowSeek if duration < 5
|
||||||
return nil, errors.New("video too short to create sprite")
|
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)
|
generator, err := newGeneratorInfo(videoFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
generator.ChunkCount = rows * cols
|
generator.ChunkCount = chunkCount
|
||||||
if err := generator.configure(); err != nil {
|
if err := generator.configure(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -55,6 +72,7 @@ func NewSpriteGenerator(videoFile ffmpeg.VideoFile, videoChecksum string, imageO
|
|||||||
ImageOutputPath: imageOutputPath,
|
ImageOutputPath: imageOutputPath,
|
||||||
VTTOutputPath: vttOutputPath,
|
VTTOutputPath: vttOutputPath,
|
||||||
Rows: rows,
|
Rows: rows,
|
||||||
|
SlowSeek: slowSeek,
|
||||||
Columns: cols,
|
Columns: cols,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
@@ -75,23 +93,51 @@ func (g *SpriteGenerator) generateSpriteImage(encoder *ffmpeg.Encoder) error {
|
|||||||
if !g.Overwrite && g.imageExists() {
|
if !g.Overwrite && g.imageExists() {
|
||||||
return nil
|
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
|
var images []image.Image
|
||||||
for i := 0; i < g.Info.ChunkCount; i++ {
|
|
||||||
time := float64(i) * stepSize
|
|
||||||
|
|
||||||
options := ffmpeg.SpriteScreenshotOptions{
|
if !g.SlowSeek {
|
||||||
Time: time,
|
logger.Infof("[generator] generating sprite image for %s", g.Info.VideoFile.Path)
|
||||||
Width: 160,
|
// generate `ChunkCount` thumbnails
|
||||||
|
stepSize := g.Info.VideoFile.Duration / float64(g.Info.ChunkCount)
|
||||||
|
|
||||||
|
for i := 0; i < g.Info.ChunkCount; i++ {
|
||||||
|
time := float64(i) * stepSize
|
||||||
|
|
||||||
|
options := ffmpeg.SpriteScreenshotOptions{
|
||||||
|
Time: time,
|
||||||
|
Width: 160,
|
||||||
|
}
|
||||||
|
|
||||||
|
img, err := encoder.SpriteScreenshot(g.Info.VideoFile, options)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
images = append(images, img)
|
||||||
}
|
}
|
||||||
img, err := encoder.SpriteScreenshot(g.Info.VideoFile, options)
|
} else {
|
||||||
if err != nil {
|
logger.Infof("[generator] generating sprite image for %s (%d frames)", g.Info.VideoFile.Path, g.Info.VideoFile.FrameCount)
|
||||||
return err
|
|
||||||
|
stepFrame := float64(g.Info.VideoFile.FrameCount-1) / float64(g.Info.ChunkCount)
|
||||||
|
|
||||||
|
for i := 0; i < g.Info.ChunkCount; i++ {
|
||||||
|
// generate exactly `ChunkCount` thumbnails, using duplicate frames if needed
|
||||||
|
frame := math.Round(float64(i) * stepFrame)
|
||||||
|
if frame >= math.MaxInt || frame <= math.MinInt {
|
||||||
|
return errors.New("invalid frame number conversion")
|
||||||
|
}
|
||||||
|
options := ffmpeg.SpriteScreenshotOptions{
|
||||||
|
Frame: int(frame),
|
||||||
|
Width: 160,
|
||||||
|
}
|
||||||
|
img, err := encoder.SpriteScreenshotSlow(g.Info.VideoFile, options)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
images = append(images, img)
|
||||||
}
|
}
|
||||||
images = append(images, img)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(images) == 0 {
|
if len(images) == 0 {
|
||||||
@@ -132,7 +178,15 @@ func (g *SpriteGenerator) generateSpriteVTT(encoder *ffmpeg.Encoder) error {
|
|||||||
width := image.Width / g.Columns
|
width := image.Width / g.Columns
|
||||||
height := image.Height / g.Rows
|
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", ""}
|
vttLines := []string{"WEBVTT", ""}
|
||||||
for index := 0; index < g.Info.ChunkCount; index++ {
|
for index := 0; index < g.Info.ChunkCount; index++ {
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package manager
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"archive/zip"
|
"archive/zip"
|
||||||
|
"github.com/stashapp/stash/pkg/file"
|
||||||
|
"github.com/stashapp/stash/pkg/manager/config"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/stashapp/stash/pkg/logger"
|
"github.com/stashapp/stash/pkg/logger"
|
||||||
@@ -14,20 +16,26 @@ func walkGalleryZip(path string, walkFunc func(file *zip.File) error) error {
|
|||||||
}
|
}
|
||||||
defer readCloser.Close()
|
defer readCloser.Close()
|
||||||
|
|
||||||
for _, file := range readCloser.File {
|
excludeImgRegex := generateRegexps(config.GetInstance().GetImageExcludes())
|
||||||
if file.FileInfo().IsDir() {
|
|
||||||
|
for _, f := range readCloser.File {
|
||||||
|
if f.FileInfo().IsDir() {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if strings.Contains(file.Name, "__MACOSX") {
|
if strings.Contains(f.Name, "__MACOSX") {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if !isImage(file.Name) {
|
if !isImage(f.Name) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
err := walkFunc(file)
|
if matchFileRegex(file.ZipFile(path, f).Path(), excludeImgRegex) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
err := walkFunc(f)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,12 +4,9 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/ioutil"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
|
||||||
"runtime/pprof"
|
"runtime/pprof"
|
||||||
"strings"
|
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -407,34 +404,6 @@ func (s *singleton) Migrate(ctx context.Context, input models.MigrateInput) erro
|
|||||||
return nil
|
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 {
|
func (s *singleton) GetSystemStatus() *models.SystemStatus {
|
||||||
status := models.SystemStatusEnumOk
|
status := models.SystemStatusEnumOk
|
||||||
dbSchema := int(database.Version())
|
dbSchema := int(database.Version())
|
||||||
@@ -458,8 +427,15 @@ func (s *singleton) GetSystemStatus() *models.SystemStatus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Shutdown gracefully stops the manager
|
// 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
|
// TODO: Each part of the manager needs to gracefully stop at some point
|
||||||
// for now, we just close the database.
|
// 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 {
|
if excluded["name"] && performer.Name != nil {
|
||||||
value := sql.NullString{String: *performer.Name, Valid: true}
|
value := sql.NullString{String: *performer.Name, Valid: true}
|
||||||
partial.Name = &value
|
partial.Name = &value
|
||||||
|
checksum := utils.MD5FromString(*performer.Name)
|
||||||
|
partial.Checksum = &checksum
|
||||||
}
|
}
|
||||||
if performer.Piercings != nil && !excluded["piercings"] {
|
if performer.Piercings != nil && !excluded["piercings"] {
|
||||||
value := getNullString(performer.Piercings)
|
value := getNullString(performer.Piercings)
|
||||||
@@ -145,7 +147,7 @@ func (t *StashBoxPerformerTagTask) stashBoxPerformerTag(ctx context.Context) {
|
|||||||
partial.Tattoos = &value
|
partial.Tattoos = &value
|
||||||
}
|
}
|
||||||
if performer.Twitter != nil && !excluded["twitter"] {
|
if performer.Twitter != nil && !excluded["twitter"] {
|
||||||
value := getNullString(performer.Tattoos)
|
value := getNullString(performer.Twitter)
|
||||||
partial.Twitter = &value
|
partial.Twitter = &value
|
||||||
}
|
}
|
||||||
if performer.URL != nil && !excluded["url"] {
|
if performer.URL != nil && !excluded["url"] {
|
||||||
@@ -261,7 +263,7 @@ func getDate(val *string) models.SQLiteDate {
|
|||||||
if val == nil {
|
if val == nil {
|
||||||
return models.SQLiteDate{Valid: false}
|
return models.SQLiteDate{Valid: false}
|
||||||
} else {
|
} else {
|
||||||
return models.SQLiteDate{String: *val, Valid: false}
|
return models.SQLiteDate{String: *val, Valid: true}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
"github.com/stashapp/stash/pkg/gallery"
|
"github.com/stashapp/stash/pkg/gallery"
|
||||||
"github.com/stashapp/stash/pkg/image"
|
"github.com/stashapp/stash/pkg/image"
|
||||||
@@ -12,7 +13,12 @@ import (
|
|||||||
"github.com/stashapp/stash/pkg/scene"
|
"github.com/stashapp/stash/pkg/scene"
|
||||||
)
|
)
|
||||||
|
|
||||||
const separatorChars = `.\-_ `
|
const (
|
||||||
|
separatorChars = `.\-_ `
|
||||||
|
|
||||||
|
reNotLetterWordUnicode = `[^\p{L}\w\d]`
|
||||||
|
reNotLetterWord = `[^\w\d]`
|
||||||
|
)
|
||||||
|
|
||||||
func getPathQueryRegex(name string) string {
|
func getPathQueryRegex(name string) string {
|
||||||
// escape specific regex characters
|
// escape specific regex characters
|
||||||
@@ -22,6 +28,13 @@ func getPathQueryRegex(name string) string {
|
|||||||
const separator = `[` + separatorChars + `]`
|
const separator = `[` + separatorChars + `]`
|
||||||
|
|
||||||
ret := strings.ReplaceAll(name, " ", separator+"*")
|
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])`
|
ret = `(?:^|_|[^\w\d])` + ret + `(?:$|_|[^\w\d])`
|
||||||
return ret
|
return ret
|
||||||
}
|
}
|
||||||
@@ -36,7 +49,7 @@ func getPathWords(path string) []string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// handle path separators
|
// handle path separators
|
||||||
const separator = `(?:_|[^\w\d])+`
|
const separator = `(?:_|[^\p{L}\w\d])+`
|
||||||
re := regexp.MustCompile(separator)
|
re := regexp.MustCompile(separator)
|
||||||
retStr = re.ReplaceAllString(retStr, " ")
|
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
|
// we post-match afterwards, so we can afford to be a little loose
|
||||||
// with the query
|
// with the query
|
||||||
// just use the first two characters
|
// 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
|
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.
|
// nameMatchesPath returns the index in the path for the right-most match.
|
||||||
// Returns -1 if not found.
|
// Returns -1 if not found.
|
||||||
func nameMatchesPath(name, path string) int {
|
func nameMatchesPath(name, path string) int {
|
||||||
// escape specific regex characters
|
// #2363 - optimisation: only use unicode character regexp if path contains
|
||||||
name = regexp.QuoteMeta(name)
|
// unicode characters
|
||||||
|
re := nameToRegexp(name, !allASCII(path))
|
||||||
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)
|
|
||||||
found := re.FindAllStringIndex(path, -1)
|
found := re.FindAllStringIndex(path, -1)
|
||||||
|
|
||||||
if found == nil {
|
if found == nil {
|
||||||
@@ -84,6 +99,39 @@ func nameMatchesPath(name, path string) int {
|
|||||||
return found[len(found)-1][0]
|
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) {
|
func PathToPerformers(path string, performerReader models.PerformerReader) ([]*models.Performer, error) {
|
||||||
words := getPathWords(path)
|
words := getPathWords(path)
|
||||||
performers, err := performerReader.QueryForAutoTag(words)
|
performers, err := performerReader.QueryForAutoTag(words)
|
||||||
@@ -199,8 +247,13 @@ func PathToScenes(name string, paths []string, sceneReader models.SceneReader) (
|
|||||||
}
|
}
|
||||||
|
|
||||||
var ret []*models.Scene
|
var ret []*models.Scene
|
||||||
|
|
||||||
|
// paths may have unicode characters
|
||||||
|
const useUnicode = true
|
||||||
|
|
||||||
|
r := nameToRegexp(name, useUnicode)
|
||||||
for _, p := range scenes {
|
for _, p := range scenes {
|
||||||
if nameMatchesPath(name, p.Path) != -1 {
|
if regexpMatchesPath(r, p.Path) != -1 {
|
||||||
ret = append(ret, p)
|
ret = append(ret, p)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -231,8 +284,13 @@ func PathToImages(name string, paths []string, imageReader models.ImageReader) (
|
|||||||
}
|
}
|
||||||
|
|
||||||
var ret []*models.Image
|
var ret []*models.Image
|
||||||
|
|
||||||
|
// paths may have unicode characters
|
||||||
|
const useUnicode = true
|
||||||
|
|
||||||
|
r := nameToRegexp(name, useUnicode)
|
||||||
for _, p := range images {
|
for _, p := range images {
|
||||||
if nameMatchesPath(name, p.Path) != -1 {
|
if regexpMatchesPath(r, p.Path) != -1 {
|
||||||
ret = append(ret, p)
|
ret = append(ret, p)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -263,8 +321,13 @@ func PathToGalleries(name string, paths []string, galleryReader models.GalleryRe
|
|||||||
}
|
}
|
||||||
|
|
||||||
var ret []*models.Gallery
|
var ret []*models.Gallery
|
||||||
|
|
||||||
|
// paths may have unicode characters
|
||||||
|
const useUnicode = true
|
||||||
|
|
||||||
|
r := nameToRegexp(name, useUnicode)
|
||||||
for _, p := range gallerys {
|
for _, p := range gallerys {
|
||||||
if nameMatchesPath(name, p.Path.String) != -1 {
|
if regexpMatchesPath(r, p.Path.String) != -1 {
|
||||||
ret = append(ret, p)
|
ret = append(ret, p)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,71 +4,90 @@ import "testing"
|
|||||||
|
|
||||||
func Test_nameMatchesPath(t *testing.T) {
|
func Test_nameMatchesPath(t *testing.T) {
|
||||||
const name = "first last"
|
const name = "first last"
|
||||||
|
const unicodeName = "伏字"
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
testName string
|
||||||
path string
|
name string
|
||||||
want int
|
path string
|
||||||
|
want int
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
"exact",
|
"exact",
|
||||||
name,
|
name,
|
||||||
|
name,
|
||||||
0,
|
0,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"partial",
|
"partial",
|
||||||
|
name,
|
||||||
"first",
|
"first",
|
||||||
-1,
|
-1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"separator",
|
"separator",
|
||||||
|
name,
|
||||||
"first.last",
|
"first.last",
|
||||||
0,
|
0,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"separator",
|
"separator",
|
||||||
|
name,
|
||||||
"first-last",
|
"first-last",
|
||||||
0,
|
0,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"separator",
|
"separator",
|
||||||
|
name,
|
||||||
"first_last",
|
"first_last",
|
||||||
0,
|
0,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"separators",
|
"separators",
|
||||||
|
name,
|
||||||
"first.-_ last",
|
"first.-_ last",
|
||||||
0,
|
0,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"within string",
|
"within string",
|
||||||
|
name,
|
||||||
"before_first last/after",
|
"before_first last/after",
|
||||||
6,
|
6,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"not within string",
|
"not within string",
|
||||||
|
name,
|
||||||
"beforefirst last/after",
|
"beforefirst last/after",
|
||||||
-1,
|
-1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"not within string",
|
"not within string",
|
||||||
|
name,
|
||||||
"before/first lastafter",
|
"before/first lastafter",
|
||||||
-1,
|
-1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"not within string",
|
"not within string",
|
||||||
|
name,
|
||||||
"first last1",
|
"first last1",
|
||||||
-1,
|
-1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"not within string",
|
"not within string",
|
||||||
|
name,
|
||||||
"1first last",
|
"1first last",
|
||||||
-1,
|
-1,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"unicode",
|
||||||
|
unicodeName,
|
||||||
|
unicodeName,
|
||||||
|
0,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.testName, func(t *testing.T) {
|
||||||
if got := nameMatchesPath(name, tt.path); got != tt.want {
|
if got := nameMatchesPath(tt.name, tt.path); got != tt.want {
|
||||||
t.Errorf("nameMatchesPath() = %v, want %v", got, tt.want)
|
t.Errorf("nameMatchesPath() = %v, want %v", got, tt.want)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"os/exec"
|
"os/exec"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
|
"github.com/stashapp/stash/pkg/desktop"
|
||||||
"github.com/stashapp/stash/pkg/logger"
|
"github.com/stashapp/stash/pkg/logger"
|
||||||
"github.com/stashapp/stash/pkg/plugin/common"
|
"github.com/stashapp/stash/pkg/plugin/common"
|
||||||
)
|
)
|
||||||
@@ -87,6 +88,7 @@ func (t *rawPluginTask) Start() error {
|
|||||||
|
|
||||||
t.waitGroup.Add(1)
|
t.waitGroup.Add(1)
|
||||||
t.done = make(chan bool, 1)
|
t.done = make(chan bool, 1)
|
||||||
|
desktop.HideExecShell(cmd)
|
||||||
if err = cmd.Start(); err != nil {
|
if err = cmd.Start(); err != nil {
|
||||||
return fmt.Errorf("error running plugin: %v", err)
|
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 {
|
if err := fileDeleter.Files([]string{scene.Path}); err != nil {
|
||||||
return err
|
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 {
|
if deleteGenerated {
|
||||||
|
|||||||
@@ -840,7 +840,7 @@ func (s mappedScraper) processScene(ctx context.Context, q mappedQuery, r mapped
|
|||||||
for _, p := range performerTagResults {
|
for _, p := range performerTagResults {
|
||||||
tag := &models.ScrapedTag{}
|
tag := &models.ScrapedTag{}
|
||||||
p.apply(tag)
|
p.apply(tag)
|
||||||
ret.Tags = append(ret.Tags, tag)
|
performer.Tags = append(performer.Tags, tag)
|
||||||
}
|
}
|
||||||
|
|
||||||
ret.Performers = append(ret.Performers, performer)
|
ret.Performers = append(ret.Performers, performer)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/stashapp/stash/pkg/desktop"
|
||||||
"github.com/stashapp/stash/pkg/logger"
|
"github.com/stashapp/stash/pkg/logger"
|
||||||
"github.com/stashapp/stash/pkg/models"
|
"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())
|
logger.Error("Scraper stdout not available: " + err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
desktop.HideExecShell(cmd)
|
||||||
if err = cmd.Start(); err != nil {
|
if err = cmd.Start(); err != nil {
|
||||||
logger.Error("Error running scraper script: " + err.Error())
|
logger.Error("Error running scraper script: " + err.Error())
|
||||||
return errors.New("error running scraper script")
|
return errors.New("error running scraper script")
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ type Query struct {
|
|||||||
FindScenesByFingerprints []*Scene "json:\"findScenesByFingerprints\" graphql:\"findScenesByFingerprints\""
|
FindScenesByFingerprints []*Scene "json:\"findScenesByFingerprints\" graphql:\"findScenesByFingerprints\""
|
||||||
FindScenesByFullFingerprints []*Scene "json:\"findScenesByFullFingerprints\" graphql:\"findScenesByFullFingerprints\""
|
FindScenesByFullFingerprints []*Scene "json:\"findScenesByFullFingerprints\" graphql:\"findScenesByFullFingerprints\""
|
||||||
QueryScenes QueryScenesResultType "json:\"queryScenes\" graphql:\"queryScenes\""
|
QueryScenes QueryScenesResultType "json:\"queryScenes\" graphql:\"queryScenes\""
|
||||||
|
FindSite *Site "json:\"findSite\" graphql:\"findSite\""
|
||||||
|
QuerySites QuerySitesResultType "json:\"querySites\" graphql:\"querySites\""
|
||||||
FindEdit *Edit "json:\"findEdit\" graphql:\"findEdit\""
|
FindEdit *Edit "json:\"findEdit\" graphql:\"findEdit\""
|
||||||
QueryEdits QueryEditsResultType "json:\"queryEdits\" graphql:\"queryEdits\""
|
QueryEdits QueryEditsResultType "json:\"queryEdits\" graphql:\"queryEdits\""
|
||||||
FindUser *User "json:\"findUser\" graphql:\"findUser\""
|
FindUser *User "json:\"findUser\" graphql:\"findUser\""
|
||||||
@@ -38,48 +40,57 @@ type Query struct {
|
|||||||
Me *User "json:\"me\" graphql:\"me\""
|
Me *User "json:\"me\" graphql:\"me\""
|
||||||
SearchPerformer []*Performer "json:\"searchPerformer\" graphql:\"searchPerformer\""
|
SearchPerformer []*Performer "json:\"searchPerformer\" graphql:\"searchPerformer\""
|
||||||
SearchScene []*Scene "json:\"searchScene\" graphql:\"searchScene\""
|
SearchScene []*Scene "json:\"searchScene\" graphql:\"searchScene\""
|
||||||
|
FindDraft *Draft "json:\"findDraft\" graphql:\"findDraft\""
|
||||||
|
FindDrafts []*Draft "json:\"findDrafts\" graphql:\"findDrafts\""
|
||||||
Version Version "json:\"version\" graphql:\"version\""
|
Version Version "json:\"version\" graphql:\"version\""
|
||||||
|
GetConfig StashBoxConfig "json:\"getConfig\" graphql:\"getConfig\""
|
||||||
}
|
}
|
||||||
|
|
||||||
type Mutation struct {
|
type Mutation struct {
|
||||||
SceneCreate *Scene "json:\"sceneCreate\" graphql:\"sceneCreate\""
|
SceneCreate *Scene "json:\"sceneCreate\" graphql:\"sceneCreate\""
|
||||||
SceneUpdate *Scene "json:\"sceneUpdate\" graphql:\"sceneUpdate\""
|
SceneUpdate *Scene "json:\"sceneUpdate\" graphql:\"sceneUpdate\""
|
||||||
SceneDestroy bool "json:\"sceneDestroy\" graphql:\"sceneDestroy\""
|
SceneDestroy bool "json:\"sceneDestroy\" graphql:\"sceneDestroy\""
|
||||||
PerformerCreate *Performer "json:\"performerCreate\" graphql:\"performerCreate\""
|
PerformerCreate *Performer "json:\"performerCreate\" graphql:\"performerCreate\""
|
||||||
PerformerUpdate *Performer "json:\"performerUpdate\" graphql:\"performerUpdate\""
|
PerformerUpdate *Performer "json:\"performerUpdate\" graphql:\"performerUpdate\""
|
||||||
PerformerDestroy bool "json:\"performerDestroy\" graphql:\"performerDestroy\""
|
PerformerDestroy bool "json:\"performerDestroy\" graphql:\"performerDestroy\""
|
||||||
StudioCreate *Studio "json:\"studioCreate\" graphql:\"studioCreate\""
|
StudioCreate *Studio "json:\"studioCreate\" graphql:\"studioCreate\""
|
||||||
StudioUpdate *Studio "json:\"studioUpdate\" graphql:\"studioUpdate\""
|
StudioUpdate *Studio "json:\"studioUpdate\" graphql:\"studioUpdate\""
|
||||||
StudioDestroy bool "json:\"studioDestroy\" graphql:\"studioDestroy\""
|
StudioDestroy bool "json:\"studioDestroy\" graphql:\"studioDestroy\""
|
||||||
TagCreate *Tag "json:\"tagCreate\" graphql:\"tagCreate\""
|
TagCreate *Tag "json:\"tagCreate\" graphql:\"tagCreate\""
|
||||||
TagUpdate *Tag "json:\"tagUpdate\" graphql:\"tagUpdate\""
|
TagUpdate *Tag "json:\"tagUpdate\" graphql:\"tagUpdate\""
|
||||||
TagDestroy bool "json:\"tagDestroy\" graphql:\"tagDestroy\""
|
TagDestroy bool "json:\"tagDestroy\" graphql:\"tagDestroy\""
|
||||||
UserCreate *User "json:\"userCreate\" graphql:\"userCreate\""
|
UserCreate *User "json:\"userCreate\" graphql:\"userCreate\""
|
||||||
UserUpdate *User "json:\"userUpdate\" graphql:\"userUpdate\""
|
UserUpdate *User "json:\"userUpdate\" graphql:\"userUpdate\""
|
||||||
UserDestroy bool "json:\"userDestroy\" graphql:\"userDestroy\""
|
UserDestroy bool "json:\"userDestroy\" graphql:\"userDestroy\""
|
||||||
ImageCreate *Image "json:\"imageCreate\" graphql:\"imageCreate\""
|
ImageCreate *Image "json:\"imageCreate\" graphql:\"imageCreate\""
|
||||||
ImageDestroy bool "json:\"imageDestroy\" graphql:\"imageDestroy\""
|
ImageDestroy bool "json:\"imageDestroy\" graphql:\"imageDestroy\""
|
||||||
NewUser *string "json:\"newUser\" graphql:\"newUser\""
|
NewUser *string "json:\"newUser\" graphql:\"newUser\""
|
||||||
ActivateNewUser *User "json:\"activateNewUser\" graphql:\"activateNewUser\""
|
ActivateNewUser *User "json:\"activateNewUser\" graphql:\"activateNewUser\""
|
||||||
GenerateInviteCode string "json:\"generateInviteCode\" graphql:\"generateInviteCode\""
|
GenerateInviteCode *string "json:\"generateInviteCode\" graphql:\"generateInviteCode\""
|
||||||
RescindInviteCode bool "json:\"rescindInviteCode\" graphql:\"rescindInviteCode\""
|
RescindInviteCode bool "json:\"rescindInviteCode\" graphql:\"rescindInviteCode\""
|
||||||
GrantInvite int "json:\"grantInvite\" graphql:\"grantInvite\""
|
GrantInvite int "json:\"grantInvite\" graphql:\"grantInvite\""
|
||||||
RevokeInvite int "json:\"revokeInvite\" graphql:\"revokeInvite\""
|
RevokeInvite int "json:\"revokeInvite\" graphql:\"revokeInvite\""
|
||||||
TagCategoryCreate *TagCategory "json:\"tagCategoryCreate\" graphql:\"tagCategoryCreate\""
|
TagCategoryCreate *TagCategory "json:\"tagCategoryCreate\" graphql:\"tagCategoryCreate\""
|
||||||
TagCategoryUpdate *TagCategory "json:\"tagCategoryUpdate\" graphql:\"tagCategoryUpdate\""
|
TagCategoryUpdate *TagCategory "json:\"tagCategoryUpdate\" graphql:\"tagCategoryUpdate\""
|
||||||
TagCategoryDestroy bool "json:\"tagCategoryDestroy\" graphql:\"tagCategoryDestroy\""
|
TagCategoryDestroy bool "json:\"tagCategoryDestroy\" graphql:\"tagCategoryDestroy\""
|
||||||
RegenerateAPIKey string "json:\"regenerateAPIKey\" graphql:\"regenerateAPIKey\""
|
SiteCreate *Site "json:\"siteCreate\" graphql:\"siteCreate\""
|
||||||
ResetPassword bool "json:\"resetPassword\" graphql:\"resetPassword\""
|
SiteUpdate *Site "json:\"siteUpdate\" graphql:\"siteUpdate\""
|
||||||
ChangePassword bool "json:\"changePassword\" graphql:\"changePassword\""
|
SiteDestroy bool "json:\"siteDestroy\" graphql:\"siteDestroy\""
|
||||||
SceneEdit Edit "json:\"sceneEdit\" graphql:\"sceneEdit\""
|
RegenerateAPIKey string "json:\"regenerateAPIKey\" graphql:\"regenerateAPIKey\""
|
||||||
PerformerEdit Edit "json:\"performerEdit\" graphql:\"performerEdit\""
|
ResetPassword bool "json:\"resetPassword\" graphql:\"resetPassword\""
|
||||||
StudioEdit Edit "json:\"studioEdit\" graphql:\"studioEdit\""
|
ChangePassword bool "json:\"changePassword\" graphql:\"changePassword\""
|
||||||
TagEdit Edit "json:\"tagEdit\" graphql:\"tagEdit\""
|
SceneEdit Edit "json:\"sceneEdit\" graphql:\"sceneEdit\""
|
||||||
EditVote Edit "json:\"editVote\" graphql:\"editVote\""
|
PerformerEdit Edit "json:\"performerEdit\" graphql:\"performerEdit\""
|
||||||
EditComment Edit "json:\"editComment\" graphql:\"editComment\""
|
StudioEdit Edit "json:\"studioEdit\" graphql:\"studioEdit\""
|
||||||
ApplyEdit Edit "json:\"applyEdit\" graphql:\"applyEdit\""
|
TagEdit Edit "json:\"tagEdit\" graphql:\"tagEdit\""
|
||||||
CancelEdit Edit "json:\"cancelEdit\" graphql:\"cancelEdit\""
|
EditVote Edit "json:\"editVote\" graphql:\"editVote\""
|
||||||
SubmitFingerprint bool "json:\"submitFingerprint\" graphql:\"submitFingerprint\""
|
EditComment Edit "json:\"editComment\" graphql:\"editComment\""
|
||||||
|
ApplyEdit Edit "json:\"applyEdit\" graphql:\"applyEdit\""
|
||||||
|
CancelEdit Edit "json:\"cancelEdit\" graphql:\"cancelEdit\""
|
||||||
|
SubmitFingerprint bool "json:\"submitFingerprint\" graphql:\"submitFingerprint\""
|
||||||
|
SubmitSceneDraft DraftSubmissionStatus "json:\"submitSceneDraft\" graphql:\"submitSceneDraft\""
|
||||||
|
SubmitPerformerDraft DraftSubmissionStatus "json:\"submitPerformerDraft\" graphql:\"submitPerformerDraft\""
|
||||||
|
DestroyDraft bool "json:\"destroyDraft\" graphql:\"destroyDraft\""
|
||||||
}
|
}
|
||||||
type URLFragment struct {
|
type URLFragment struct {
|
||||||
URL string "json:\"url\" graphql:\"url\""
|
URL string "json:\"url\" graphql:\"url\""
|
||||||
@@ -180,60 +191,43 @@ type FindSceneByID struct {
|
|||||||
type SubmitFingerprintPayload struct {
|
type SubmitFingerprintPayload struct {
|
||||||
SubmitFingerprint bool "json:\"submitFingerprint\" graphql:\"submitFingerprint\""
|
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!) {
|
const FindSceneByFingerprintQuery = `query FindSceneByFingerprint ($fingerprint: FingerprintQueryInput!) {
|
||||||
findSceneByFingerprint(fingerprint: $fingerprint) {
|
findSceneByFingerprint(fingerprint: $fingerprint) {
|
||||||
... SceneFragment
|
... SceneFragment
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fragment FuzzyDateFragment on FuzzyDate {
|
fragment BodyModificationFragment on BodyModification {
|
||||||
date
|
location
|
||||||
accuracy
|
description
|
||||||
}
|
}
|
||||||
fragment SceneFragment on Scene {
|
fragment FingerprintFragment on Fingerprint {
|
||||||
id
|
algorithm
|
||||||
title
|
hash
|
||||||
details
|
|
||||||
duration
|
duration
|
||||||
date
|
|
||||||
urls {
|
|
||||||
... URLFragment
|
|
||||||
}
|
|
||||||
images {
|
|
||||||
... ImageFragment
|
|
||||||
}
|
|
||||||
studio {
|
|
||||||
... StudioFragment
|
|
||||||
}
|
|
||||||
tags {
|
|
||||||
... TagFragment
|
|
||||||
}
|
|
||||||
performers {
|
|
||||||
... PerformerAppearanceFragment
|
|
||||||
}
|
|
||||||
fingerprints {
|
|
||||||
... FingerprintFragment
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
fragment URLFragment on URL {
|
fragment URLFragment on URL {
|
||||||
url
|
url
|
||||||
type
|
type
|
||||||
}
|
}
|
||||||
fragment StudioFragment on Studio {
|
fragment TagFragment on Tag {
|
||||||
name
|
name
|
||||||
id
|
id
|
||||||
urls {
|
|
||||||
... URLFragment
|
|
||||||
}
|
|
||||||
images {
|
|
||||||
... ImageFragment
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fragment PerformerAppearanceFragment on PerformerAppearance {
|
|
||||||
as
|
|
||||||
performer {
|
|
||||||
... PerformerFragment
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
fragment PerformerFragment on Performer {
|
fragment PerformerFragment on Performer {
|
||||||
id
|
id
|
||||||
@@ -269,15 +263,9 @@ fragment PerformerFragment on Performer {
|
|||||||
... BodyModificationFragment
|
... BodyModificationFragment
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fragment ImageFragment on Image {
|
fragment FuzzyDateFragment on FuzzyDate {
|
||||||
id
|
date
|
||||||
url
|
accuracy
|
||||||
width
|
|
||||||
height
|
|
||||||
}
|
|
||||||
fragment TagFragment on Tag {
|
|
||||||
name
|
|
||||||
id
|
|
||||||
}
|
}
|
||||||
fragment MeasurementsFragment on Measurements {
|
fragment MeasurementsFragment on Measurements {
|
||||||
band_size
|
band_size
|
||||||
@@ -285,14 +273,52 @@ fragment MeasurementsFragment on Measurements {
|
|||||||
waist
|
waist
|
||||||
hip
|
hip
|
||||||
}
|
}
|
||||||
fragment BodyModificationFragment on BodyModification {
|
fragment SceneFragment on Scene {
|
||||||
location
|
id
|
||||||
description
|
title
|
||||||
}
|
details
|
||||||
fragment FingerprintFragment on Fingerprint {
|
|
||||||
algorithm
|
|
||||||
hash
|
|
||||||
duration
|
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
|
... SceneFragment
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fragment ImageFragment on Image {
|
|
||||||
id
|
|
||||||
url
|
|
||||||
width
|
|
||||||
height
|
|
||||||
}
|
|
||||||
fragment StudioFragment on Studio {
|
fragment StudioFragment on Studio {
|
||||||
name
|
name
|
||||||
id
|
id
|
||||||
@@ -330,10 +350,6 @@ fragment StudioFragment on Studio {
|
|||||||
... ImageFragment
|
... ImageFragment
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fragment TagFragment on Tag {
|
|
||||||
name
|
|
||||||
id
|
|
||||||
}
|
|
||||||
fragment PerformerFragment on Performer {
|
fragment PerformerFragment on Performer {
|
||||||
id
|
id
|
||||||
name
|
name
|
||||||
@@ -378,6 +394,35 @@ fragment MeasurementsFragment on Measurements {
|
|||||||
waist
|
waist
|
||||||
hip
|
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 {
|
fragment SceneFragment on Scene {
|
||||||
id
|
id
|
||||||
title
|
title
|
||||||
@@ -403,25 +448,6 @@ fragment SceneFragment on Scene {
|
|||||||
... FingerprintFragment
|
... 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) {
|
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
|
... SceneFragment
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
fragment FingerprintFragment on Fingerprint {
|
||||||
|
algorithm
|
||||||
|
hash
|
||||||
|
duration
|
||||||
|
}
|
||||||
fragment URLFragment on URL {
|
fragment URLFragment on URL {
|
||||||
url
|
url
|
||||||
type
|
type
|
||||||
@@ -456,14 +487,21 @@ fragment TagFragment on Tag {
|
|||||||
name
|
name
|
||||||
id
|
id
|
||||||
}
|
}
|
||||||
fragment FuzzyDateFragment on FuzzyDate {
|
fragment PerformerAppearanceFragment on PerformerAppearance {
|
||||||
date
|
as
|
||||||
accuracy
|
performer {
|
||||||
|
... PerformerFragment
|
||||||
|
}
|
||||||
}
|
}
|
||||||
fragment FingerprintFragment on Fingerprint {
|
fragment MeasurementsFragment on Measurements {
|
||||||
algorithm
|
band_size
|
||||||
hash
|
cup_size
|
||||||
duration
|
waist
|
||||||
|
hip
|
||||||
|
}
|
||||||
|
fragment BodyModificationFragment on BodyModification {
|
||||||
|
location
|
||||||
|
description
|
||||||
}
|
}
|
||||||
fragment SceneFragment on Scene {
|
fragment SceneFragment on Scene {
|
||||||
id
|
id
|
||||||
@@ -500,12 +538,6 @@ fragment StudioFragment on Studio {
|
|||||||
... ImageFragment
|
... ImageFragment
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fragment PerformerAppearanceFragment on PerformerAppearance {
|
|
||||||
as
|
|
||||||
performer {
|
|
||||||
... PerformerFragment
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fragment PerformerFragment on Performer {
|
fragment PerformerFragment on Performer {
|
||||||
id
|
id
|
||||||
name
|
name
|
||||||
@@ -540,15 +572,9 @@ fragment PerformerFragment on Performer {
|
|||||||
... BodyModificationFragment
|
... BodyModificationFragment
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fragment MeasurementsFragment on Measurements {
|
fragment FuzzyDateFragment on FuzzyDate {
|
||||||
band_size
|
date
|
||||||
cup_size
|
accuracy
|
||||||
waist
|
|
||||||
hip
|
|
||||||
}
|
|
||||||
fragment BodyModificationFragment on BodyModification {
|
|
||||||
location
|
|
||||||
description
|
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -570,30 +596,6 @@ const SearchPerformerQuery = `query SearchPerformer ($term: String!) {
|
|||||||
... PerformerFragment
|
... 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 {
|
fragment PerformerFragment on Performer {
|
||||||
id
|
id
|
||||||
name
|
name
|
||||||
@@ -628,6 +630,30 @@ fragment PerformerFragment on Performer {
|
|||||||
... BodyModificationFragment
|
... 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) {
|
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
|
... 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 {
|
fragment URLFragment on URL {
|
||||||
url
|
url
|
||||||
type
|
type
|
||||||
@@ -706,46 +698,6 @@ fragment BodyModificationFragment on BodyModification {
|
|||||||
location
|
location
|
||||||
description
|
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 {
|
fragment PerformerFragment on Performer {
|
||||||
id
|
id
|
||||||
name
|
name
|
||||||
@@ -780,6 +732,34 @@ fragment PerformerFragment on Performer {
|
|||||||
... BodyModificationFragment
|
... 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 {
|
fragment MeasurementsFragment on Measurements {
|
||||||
band_size
|
band_size
|
||||||
cup_size
|
cup_size
|
||||||
@@ -820,9 +800,21 @@ fragment URLFragment on URL {
|
|||||||
url
|
url
|
||||||
type
|
type
|
||||||
}
|
}
|
||||||
fragment BodyModificationFragment on BodyModification {
|
fragment ImageFragment on Image {
|
||||||
location
|
id
|
||||||
description
|
url
|
||||||
|
width
|
||||||
|
height
|
||||||
|
}
|
||||||
|
fragment StudioFragment on Studio {
|
||||||
|
name
|
||||||
|
id
|
||||||
|
urls {
|
||||||
|
... URLFragment
|
||||||
|
}
|
||||||
|
images {
|
||||||
|
... ImageFragment
|
||||||
|
}
|
||||||
}
|
}
|
||||||
fragment PerformerAppearanceFragment on PerformerAppearance {
|
fragment PerformerAppearanceFragment on PerformerAppearance {
|
||||||
as
|
as
|
||||||
@@ -830,9 +822,43 @@ fragment PerformerAppearanceFragment on PerformerAppearance {
|
|||||||
... PerformerFragment
|
... PerformerFragment
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fragment FuzzyDateFragment on FuzzyDate {
|
fragment PerformerFragment on Performer {
|
||||||
date
|
id
|
||||||
accuracy
|
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
|
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"
|
"github.com/99designs/gqlgen/graphql"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type DraftData interface {
|
||||||
|
IsDraftData()
|
||||||
|
}
|
||||||
|
|
||||||
type EditDetails interface {
|
type EditDetails interface {
|
||||||
IsEditDetails()
|
IsEditDetails()
|
||||||
}
|
}
|
||||||
@@ -19,6 +23,18 @@ type EditTarget interface {
|
|||||||
IsEditTarget()
|
IsEditTarget()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SceneDraftPerformer interface {
|
||||||
|
IsSceneDraftPerformer()
|
||||||
|
}
|
||||||
|
|
||||||
|
type SceneDraftStudio interface {
|
||||||
|
IsSceneDraftStudio()
|
||||||
|
}
|
||||||
|
|
||||||
|
type SceneDraftTag interface {
|
||||||
|
IsSceneDraftTag()
|
||||||
|
}
|
||||||
|
|
||||||
type ActivateNewUserInput struct {
|
type ActivateNewUserInput struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
@@ -60,6 +76,37 @@ type DateCriterionInput struct {
|
|||||||
Modifier CriterionModifier `json:"modifier"`
|
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 {
|
type Edit struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
User *User `json:"user"`
|
User *User `json:"user"`
|
||||||
@@ -75,13 +122,15 @@ type Edit struct {
|
|||||||
// Entity specific options
|
// Entity specific options
|
||||||
Options *PerformerEditOptions `json:"options"`
|
Options *PerformerEditOptions `json:"options"`
|
||||||
Comments []*EditComment `json:"comments"`
|
Comments []*EditComment `json:"comments"`
|
||||||
Votes []*VoteComment `json:"votes"`
|
Votes []*EditVote `json:"votes"`
|
||||||
// = Accepted - Rejected
|
// = Accepted - Rejected
|
||||||
VoteCount int `json:"vote_count"`
|
VoteCount int `json:"vote_count"`
|
||||||
Status VoteStatusEnum `json:"status"`
|
// Is the edit considered destructive.
|
||||||
Applied bool `json:"applied"`
|
Destructive bool `json:"destructive"`
|
||||||
Created time.Time `json:"created"`
|
Status VoteStatusEnum `json:"status"`
|
||||||
Updated time.Time `json:"updated"`
|
Applied bool `json:"applied"`
|
||||||
|
Created time.Time `json:"created"`
|
||||||
|
Updated time.Time `json:"updated"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type EditComment struct {
|
type EditComment struct {
|
||||||
@@ -123,10 +172,15 @@ type EditInput struct {
|
|||||||
Comment *string `json:"comment"`
|
Comment *string `json:"comment"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type EditVote struct {
|
||||||
|
User *User `json:"user"`
|
||||||
|
Date time.Time `json:"date"`
|
||||||
|
Vote VoteTypeEnum `json:"vote"`
|
||||||
|
}
|
||||||
|
|
||||||
type EditVoteInput struct {
|
type EditVoteInput struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Comment *string `json:"comment"`
|
Vote VoteTypeEnum `json:"vote"`
|
||||||
Type VoteTypeEnum `json:"type"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type EyeColorCriterionInput struct {
|
type EyeColorCriterionInput struct {
|
||||||
@@ -135,24 +189,30 @@ type EyeColorCriterionInput struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Fingerprint struct {
|
type Fingerprint struct {
|
||||||
Hash string `json:"hash"`
|
Hash string `json:"hash"`
|
||||||
Algorithm FingerprintAlgorithm `json:"algorithm"`
|
Algorithm FingerprintAlgorithm `json:"algorithm"`
|
||||||
Duration int `json:"duration"`
|
Duration int `json:"duration"`
|
||||||
Submissions int `json:"submissions"`
|
Submissions int `json:"submissions"`
|
||||||
Created time.Time `json:"created"`
|
Created time.Time `json:"created"`
|
||||||
Updated time.Time `json:"updated"`
|
Updated time.Time `json:"updated"`
|
||||||
|
UserSubmitted bool `json:"user_submitted"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type FingerprintEditInput struct {
|
type FingerprintEditInput struct {
|
||||||
Hash string `json:"hash"`
|
UserIds []string `json:"user_ids"`
|
||||||
Algorithm FingerprintAlgorithm `json:"algorithm"`
|
Hash string `json:"hash"`
|
||||||
Duration int `json:"duration"`
|
Algorithm FingerprintAlgorithm `json:"algorithm"`
|
||||||
Submissions int `json:"submissions"`
|
Duration int `json:"duration"`
|
||||||
Created time.Time `json:"created"`
|
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 {
|
type FingerprintInput struct {
|
||||||
|
// assumes current user if omitted. Ignored for non-modify Users
|
||||||
|
UserIds []string `json:"user_ids"`
|
||||||
Hash string `json:"hash"`
|
Hash string `json:"hash"`
|
||||||
Algorithm FingerprintAlgorithm `json:"algorithm"`
|
Algorithm FingerprintAlgorithm `json:"algorithm"`
|
||||||
Duration int `json:"duration"`
|
Duration int `json:"duration"`
|
||||||
@@ -166,6 +226,7 @@ type FingerprintQueryInput struct {
|
|||||||
type FingerprintSubmission struct {
|
type FingerprintSubmission struct {
|
||||||
SceneID string `json:"scene_id"`
|
SceneID string `json:"scene_id"`
|
||||||
Fingerprint *FingerprintInput `json:"fingerprint"`
|
Fingerprint *FingerprintInput `json:"fingerprint"`
|
||||||
|
Unmatch *bool `json:"unmatch"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type FuzzyDate struct {
|
type FuzzyDate struct {
|
||||||
@@ -238,6 +299,11 @@ type MultiIDCriterionInput struct {
|
|||||||
Modifier CriterionModifier `json:"modifier"`
|
Modifier CriterionModifier `json:"modifier"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type MultiStringCriterionInput struct {
|
||||||
|
Value []string `json:"value"`
|
||||||
|
Modifier CriterionModifier `json:"modifier"`
|
||||||
|
}
|
||||||
|
|
||||||
type NewUserInput struct {
|
type NewUserInput struct {
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
InviteKey *string `json:"invite_key"`
|
InviteKey *string `json:"invite_key"`
|
||||||
@@ -272,7 +338,8 @@ type Performer struct {
|
|||||||
Studios []*PerformerStudio `json:"studios"`
|
Studios []*PerformerStudio `json:"studios"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (Performer) IsEditTarget() {}
|
func (Performer) IsEditTarget() {}
|
||||||
|
func (Performer) IsSceneDraftPerformer() {}
|
||||||
|
|
||||||
type PerformerAppearance struct {
|
type PerformerAppearance struct {
|
||||||
Performer *Performer `json:"performer"`
|
Performer *Performer `json:"performer"`
|
||||||
@@ -305,12 +372,55 @@ type PerformerCreateInput struct {
|
|||||||
Tattoos []*BodyModificationInput `json:"tattoos"`
|
Tattoos []*BodyModificationInput `json:"tattoos"`
|
||||||
Piercings []*BodyModificationInput `json:"piercings"`
|
Piercings []*BodyModificationInput `json:"piercings"`
|
||||||
ImageIds []string `json:"image_ids"`
|
ImageIds []string `json:"image_ids"`
|
||||||
|
DraftID *string `json:"draft_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type PerformerDestroyInput struct {
|
type PerformerDestroyInput struct {
|
||||||
ID string `json:"id"`
|
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 {
|
type PerformerEdit struct {
|
||||||
Name *string `json:"name"`
|
Name *string `json:"name"`
|
||||||
Disambiguation *string `json:"disambiguation"`
|
Disambiguation *string `json:"disambiguation"`
|
||||||
@@ -340,6 +450,7 @@ type PerformerEdit struct {
|
|||||||
RemovedPiercings []*BodyModification `json:"removed_piercings"`
|
RemovedPiercings []*BodyModification `json:"removed_piercings"`
|
||||||
AddedImages []*Image `json:"added_images"`
|
AddedImages []*Image `json:"added_images"`
|
||||||
RemovedImages []*Image `json:"removed_images"`
|
RemovedImages []*Image `json:"removed_images"`
|
||||||
|
DraftID *string `json:"draft_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (PerformerEdit) IsEditDetails() {}
|
func (PerformerEdit) IsEditDetails() {}
|
||||||
@@ -363,6 +474,7 @@ type PerformerEditDetailsInput struct {
|
|||||||
Tattoos []*BodyModificationInput `json:"tattoos"`
|
Tattoos []*BodyModificationInput `json:"tattoos"`
|
||||||
Piercings []*BodyModificationInput `json:"piercings"`
|
Piercings []*BodyModificationInput `json:"piercings"`
|
||||||
ImageIds []string `json:"image_ids"`
|
ImageIds []string `json:"image_ids"`
|
||||||
|
DraftID *string `json:"draft_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type PerformerEditInput struct {
|
type PerformerEditInput struct {
|
||||||
@@ -459,6 +571,11 @@ type QueryScenesResultType struct {
|
|||||||
Scenes []*Scene `json:"scenes"`
|
Scenes []*Scene `json:"scenes"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type QuerySitesResultType struct {
|
||||||
|
Count int `json:"count"`
|
||||||
|
Sites []*Site `json:"sites"`
|
||||||
|
}
|
||||||
|
|
||||||
type QuerySpec struct {
|
type QuerySpec struct {
|
||||||
Page *int `json:"page"`
|
Page *int `json:"page"`
|
||||||
PerPage *int `json:"per_page"`
|
PerPage *int `json:"per_page"`
|
||||||
@@ -514,6 +631,7 @@ type Scene struct {
|
|||||||
Duration *int `json:"duration"`
|
Duration *int `json:"duration"`
|
||||||
Director *string `json:"director"`
|
Director *string `json:"director"`
|
||||||
Deleted bool `json:"deleted"`
|
Deleted bool `json:"deleted"`
|
||||||
|
Edits []*Edit `json:"edits"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (Scene) IsEditTarget() {}
|
func (Scene) IsEditTarget() {}
|
||||||
@@ -536,13 +654,39 @@ type SceneDestroyInput struct {
|
|||||||
ID string `json:"id"`
|
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 {
|
type SceneEdit struct {
|
||||||
Title *string `json:"title"`
|
Title *string `json:"title"`
|
||||||
Details *string `json:"details"`
|
Details *string `json:"details"`
|
||||||
AddedUrls []*URL `json:"added_urls"`
|
AddedUrls []*URL `json:"added_urls"`
|
||||||
RemovedUrls []*URL `json:"removed_urls"`
|
RemovedUrls []*URL `json:"removed_urls"`
|
||||||
Date *string `json:"date"`
|
Date *string `json:"date"`
|
||||||
StudioID *string `json:"studio_id"`
|
Studio *Studio `json:"studio"`
|
||||||
// Added or modified performer appearance entries
|
// Added or modified performer appearance entries
|
||||||
AddedPerformers []*PerformerAppearance `json:"added_performers"`
|
AddedPerformers []*PerformerAppearance `json:"added_performers"`
|
||||||
RemovedPerformers []*PerformerAppearance `json:"removed_performers"`
|
RemovedPerformers []*PerformerAppearance `json:"removed_performers"`
|
||||||
@@ -554,6 +698,7 @@ type SceneEdit struct {
|
|||||||
RemovedFingerprints []*Fingerprint `json:"removed_fingerprints"`
|
RemovedFingerprints []*Fingerprint `json:"removed_fingerprints"`
|
||||||
Duration *int `json:"duration"`
|
Duration *int `json:"duration"`
|
||||||
Director *string `json:"director"`
|
Director *string `json:"director"`
|
||||||
|
DraftID *string `json:"draft_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (SceneEdit) IsEditDetails() {}
|
func (SceneEdit) IsEditDetails() {}
|
||||||
@@ -567,9 +712,10 @@ type SceneEditDetailsInput struct {
|
|||||||
Performers []*PerformerAppearanceInput `json:"performers"`
|
Performers []*PerformerAppearanceInput `json:"performers"`
|
||||||
TagIds []string `json:"tag_ids"`
|
TagIds []string `json:"tag_ids"`
|
||||||
ImageIds []string `json:"image_ids"`
|
ImageIds []string `json:"image_ids"`
|
||||||
Fingerprints []*FingerprintEditInput `json:"fingerprints"`
|
|
||||||
Duration *int `json:"duration"`
|
Duration *int `json:"duration"`
|
||||||
Director *string `json:"director"`
|
Director *string `json:"director"`
|
||||||
|
Fingerprints []*FingerprintInput `json:"fingerprints"`
|
||||||
|
DraftID *string `json:"draft_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type SceneEditInput struct {
|
type SceneEditInput struct {
|
||||||
@@ -599,7 +745,7 @@ type SceneFilterType struct {
|
|||||||
// Filter to include scenes with performer appearing as alias
|
// Filter to include scenes with performer appearing as alias
|
||||||
Alias *StringCriterionInput `json:"alias"`
|
Alias *StringCriterionInput `json:"alias"`
|
||||||
// Filter to only include scenes with these fingerprints
|
// Filter to only include scenes with these fingerprints
|
||||||
Fingerprints *MultiIDCriterionInput `json:"fingerprints"`
|
Fingerprints *MultiStringCriterionInput `json:"fingerprints"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type SceneUpdateInput struct {
|
type SceneUpdateInput struct {
|
||||||
@@ -617,6 +763,50 @@ type SceneUpdateInput struct {
|
|||||||
Director *string `json:"director"`
|
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 {
|
type StringCriterionInput struct {
|
||||||
Value string `json:"value"`
|
Value string `json:"value"`
|
||||||
Modifier CriterionModifier `json:"modifier"`
|
Modifier CriterionModifier `json:"modifier"`
|
||||||
@@ -632,14 +822,14 @@ type Studio struct {
|
|||||||
Deleted bool `json:"deleted"`
|
Deleted bool `json:"deleted"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (Studio) IsEditTarget() {}
|
func (Studio) IsEditTarget() {}
|
||||||
|
func (Studio) IsSceneDraftStudio() {}
|
||||||
|
|
||||||
type StudioCreateInput struct {
|
type StudioCreateInput struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Urls []*URLInput `json:"urls"`
|
Urls []*URLInput `json:"urls"`
|
||||||
ParentID *string `json:"parent_id"`
|
ParentID *string `json:"parent_id"`
|
||||||
ChildStudioIds []string `json:"child_studio_ids"`
|
ImageIds []string `json:"image_ids"`
|
||||||
ImageIds []string `json:"image_ids"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type StudioDestroyInput struct {
|
type StudioDestroyInput struct {
|
||||||
@@ -649,23 +839,20 @@ type StudioDestroyInput struct {
|
|||||||
type StudioEdit struct {
|
type StudioEdit struct {
|
||||||
Name *string `json:"name"`
|
Name *string `json:"name"`
|
||||||
// Added and modified URLs
|
// Added and modified URLs
|
||||||
AddedUrls []*URL `json:"added_urls"`
|
AddedUrls []*URL `json:"added_urls"`
|
||||||
RemovedUrls []*URL `json:"removed_urls"`
|
RemovedUrls []*URL `json:"removed_urls"`
|
||||||
Parent *Studio `json:"parent"`
|
Parent *Studio `json:"parent"`
|
||||||
AddedChildStudios []*Studio `json:"added_child_studios"`
|
AddedImages []*Image `json:"added_images"`
|
||||||
RemovedChildStudios []*Studio `json:"removed_child_studios"`
|
RemovedImages []*Image `json:"removed_images"`
|
||||||
AddedImages []*Image `json:"added_images"`
|
|
||||||
RemovedImages []*Image `json:"removed_images"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (StudioEdit) IsEditDetails() {}
|
func (StudioEdit) IsEditDetails() {}
|
||||||
|
|
||||||
type StudioEditDetailsInput struct {
|
type StudioEditDetailsInput struct {
|
||||||
Name *string `json:"name"`
|
Name *string `json:"name"`
|
||||||
Urls []*URLInput `json:"urls"`
|
Urls []*URLInput `json:"urls"`
|
||||||
ParentID *string `json:"parent_id"`
|
ParentID *string `json:"parent_id"`
|
||||||
ChildStudioIds []string `json:"child_studio_ids"`
|
ImageIds []string `json:"image_ids"`
|
||||||
ImageIds []string `json:"image_ids"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type StudioEditInput struct {
|
type StudioEditInput struct {
|
||||||
@@ -686,12 +873,11 @@ type StudioFilterType struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type StudioUpdateInput struct {
|
type StudioUpdateInput struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name *string `json:"name"`
|
Name *string `json:"name"`
|
||||||
Urls []*URLInput `json:"urls"`
|
Urls []*URLInput `json:"urls"`
|
||||||
ParentID *string `json:"parent_id"`
|
ParentID *string `json:"parent_id"`
|
||||||
ChildStudioIds []string `json:"child_studio_ids"`
|
ImageIds []string `json:"image_ids"`
|
||||||
ImageIds []string `json:"image_ids"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type Tag struct {
|
type Tag struct {
|
||||||
@@ -704,7 +890,8 @@ type Tag struct {
|
|||||||
Category *TagCategory `json:"category"`
|
Category *TagCategory `json:"category"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (Tag) IsEditTarget() {}
|
func (Tag) IsEditTarget() {}
|
||||||
|
func (Tag) IsSceneDraftTag() {}
|
||||||
|
|
||||||
type TagCategory struct {
|
type TagCategory struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
@@ -742,11 +929,11 @@ type TagDestroyInput struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type TagEdit struct {
|
type TagEdit struct {
|
||||||
Name *string `json:"name"`
|
Name *string `json:"name"`
|
||||||
Description *string `json:"description"`
|
Description *string `json:"description"`
|
||||||
AddedAliases []string `json:"added_aliases"`
|
AddedAliases []string `json:"added_aliases"`
|
||||||
RemovedAliases []string `json:"removed_aliases"`
|
RemovedAliases []string `json:"removed_aliases"`
|
||||||
CategoryID *string `json:"category_id"`
|
Category *TagCategory `json:"category"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (TagEdit) IsEditDetails() {}
|
func (TagEdit) IsEditDetails() {}
|
||||||
@@ -786,11 +973,12 @@ type TagUpdateInput struct {
|
|||||||
type URL struct {
|
type URL struct {
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
|
Site *Site `json:"site"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type URLInput struct {
|
type URLInput struct {
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
Type string `json:"type"`
|
SiteID string `json:"site_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type User struct {
|
type User struct {
|
||||||
@@ -801,12 +989,11 @@ type User struct {
|
|||||||
// Should not be visible to other users
|
// Should not be visible to other users
|
||||||
Email *string `json:"email"`
|
Email *string `json:"email"`
|
||||||
// Should not be visible to other users
|
// Should not be visible to other users
|
||||||
APIKey *string `json:"api_key"`
|
APIKey *string `json:"api_key"`
|
||||||
SuccessfulEdits int `json:"successful_edits"`
|
// Vote counts by type
|
||||||
UnsuccessfulEdits int `json:"unsuccessful_edits"`
|
VoteCount *UserVoteCount `json:"vote_count"`
|
||||||
SuccessfulVotes int `json:"successful_votes"`
|
// Edit counts by status
|
||||||
// Votes on unsuccessful edits
|
EditCount *UserEditCount `json:"edit_count"`
|
||||||
UnsuccessfulVotes int `json:"unsuccessful_votes"`
|
|
||||||
// Calls to the API from this user over a configurable time period
|
// Calls to the API from this user over a configurable time period
|
||||||
APICalls int `json:"api_calls"`
|
APICalls int `json:"api_calls"`
|
||||||
InvitedBy *User `json:"invited_by"`
|
InvitedBy *User `json:"invited_by"`
|
||||||
@@ -834,6 +1021,16 @@ type UserDestroyInput struct {
|
|||||||
ID string `json:"id"`
|
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 {
|
type UserFilterType struct {
|
||||||
// Filter to search user name - assumes like query unless quoted
|
// Filter to search user name - assumes like query unless quoted
|
||||||
Name *string `json:"name"`
|
Name *string `json:"name"`
|
||||||
@@ -866,19 +1063,21 @@ type UserUpdateInput struct {
|
|||||||
Email *string `json:"email"`
|
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 {
|
type Version struct {
|
||||||
Hash string `json:"hash"`
|
Hash string `json:"hash"`
|
||||||
BuildTime string `json:"build_time"`
|
BuildTime string `json:"build_time"`
|
||||||
|
BuildType string `json:"build_type"`
|
||||||
Version string `json:"version"`
|
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
|
type BreastTypeEnum string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -1435,6 +1634,7 @@ const (
|
|||||||
RoleEnumInvite RoleEnum = "INVITE"
|
RoleEnumInvite RoleEnum = "INVITE"
|
||||||
// May grant and rescind invite tokens and resind invite keys
|
// May grant and rescind invite tokens and resind invite keys
|
||||||
RoleEnumManageInvites RoleEnum = "MANAGE_INVITES"
|
RoleEnumManageInvites RoleEnum = "MANAGE_INVITES"
|
||||||
|
RoleEnumBot RoleEnum = "BOT"
|
||||||
)
|
)
|
||||||
|
|
||||||
var AllRoleEnum = []RoleEnum{
|
var AllRoleEnum = []RoleEnum{
|
||||||
@@ -1445,11 +1645,12 @@ var AllRoleEnum = []RoleEnum{
|
|||||||
RoleEnumAdmin,
|
RoleEnumAdmin,
|
||||||
RoleEnumInvite,
|
RoleEnumInvite,
|
||||||
RoleEnumManageInvites,
|
RoleEnumManageInvites,
|
||||||
|
RoleEnumBot,
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e RoleEnum) IsValid() bool {
|
func (e RoleEnum) IsValid() bool {
|
||||||
switch e {
|
switch e {
|
||||||
case RoleEnumRead, RoleEnumVote, RoleEnumEdit, RoleEnumModify, RoleEnumAdmin, RoleEnumInvite, RoleEnumManageInvites:
|
case RoleEnumRead, RoleEnumVote, RoleEnumEdit, RoleEnumModify, RoleEnumAdmin, RoleEnumInvite, RoleEnumManageInvites, RoleEnumBot:
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
@@ -1605,6 +1806,49 @@ func (e TargetTypeEnum) MarshalGQL(w io.Writer) {
|
|||||||
fmt.Fprint(w, strconv.Quote(e.String()))
|
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
|
type VoteStatusEnum string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -1613,6 +1857,8 @@ const (
|
|||||||
VoteStatusEnumPending VoteStatusEnum = "PENDING"
|
VoteStatusEnumPending VoteStatusEnum = "PENDING"
|
||||||
VoteStatusEnumImmediateAccepted VoteStatusEnum = "IMMEDIATE_ACCEPTED"
|
VoteStatusEnumImmediateAccepted VoteStatusEnum = "IMMEDIATE_ACCEPTED"
|
||||||
VoteStatusEnumImmediateRejected VoteStatusEnum = "IMMEDIATE_REJECTED"
|
VoteStatusEnumImmediateRejected VoteStatusEnum = "IMMEDIATE_REJECTED"
|
||||||
|
VoteStatusEnumFailed VoteStatusEnum = "FAILED"
|
||||||
|
VoteStatusEnumCanceled VoteStatusEnum = "CANCELED"
|
||||||
)
|
)
|
||||||
|
|
||||||
var AllVoteStatusEnum = []VoteStatusEnum{
|
var AllVoteStatusEnum = []VoteStatusEnum{
|
||||||
@@ -1621,11 +1867,13 @@ var AllVoteStatusEnum = []VoteStatusEnum{
|
|||||||
VoteStatusEnumPending,
|
VoteStatusEnumPending,
|
||||||
VoteStatusEnumImmediateAccepted,
|
VoteStatusEnumImmediateAccepted,
|
||||||
VoteStatusEnumImmediateRejected,
|
VoteStatusEnumImmediateRejected,
|
||||||
|
VoteStatusEnumFailed,
|
||||||
|
VoteStatusEnumCanceled,
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e VoteStatusEnum) IsValid() bool {
|
func (e VoteStatusEnum) IsValid() bool {
|
||||||
switch e {
|
switch e {
|
||||||
case VoteStatusEnumAccepted, VoteStatusEnumRejected, VoteStatusEnumPending, VoteStatusEnumImmediateAccepted, VoteStatusEnumImmediateRejected:
|
case VoteStatusEnumAccepted, VoteStatusEnumRejected, VoteStatusEnumPending, VoteStatusEnumImmediateAccepted, VoteStatusEnumImmediateRejected, VoteStatusEnumFailed, VoteStatusEnumCanceled:
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
@@ -1655,7 +1903,7 @@ func (e VoteStatusEnum) MarshalGQL(w io.Writer) {
|
|||||||
type VoteTypeEnum string
|
type VoteTypeEnum string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
VoteTypeEnumComment VoteTypeEnum = "COMMENT"
|
VoteTypeEnumAbstain VoteTypeEnum = "ABSTAIN"
|
||||||
VoteTypeEnumAccept VoteTypeEnum = "ACCEPT"
|
VoteTypeEnumAccept VoteTypeEnum = "ACCEPT"
|
||||||
VoteTypeEnumReject VoteTypeEnum = "REJECT"
|
VoteTypeEnumReject VoteTypeEnum = "REJECT"
|
||||||
// Immediately accepts the edit - bypassing the vote
|
// Immediately accepts the edit - bypassing the vote
|
||||||
@@ -1665,7 +1913,7 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var AllVoteTypeEnum = []VoteTypeEnum{
|
var AllVoteTypeEnum = []VoteTypeEnum{
|
||||||
VoteTypeEnumComment,
|
VoteTypeEnumAbstain,
|
||||||
VoteTypeEnumAccept,
|
VoteTypeEnumAccept,
|
||||||
VoteTypeEnumReject,
|
VoteTypeEnumReject,
|
||||||
VoteTypeEnumImmediateAccept,
|
VoteTypeEnumImmediateAccept,
|
||||||
@@ -1674,7 +1922,7 @@ var AllVoteTypeEnum = []VoteTypeEnum{
|
|||||||
|
|
||||||
func (e VoteTypeEnum) IsValid() bool {
|
func (e VoteTypeEnum) IsValid() bool {
|
||||||
switch e {
|
switch e {
|
||||||
case VoteTypeEnumComment, VoteTypeEnumAccept, VoteTypeEnumReject, VoteTypeEnumImmediateAccept, VoteTypeEnumImmediateReject:
|
case VoteTypeEnumAbstain, VoteTypeEnumAccept, VoteTypeEnumReject, VoteTypeEnumImmediateAccept, VoteTypeEnumImmediateReject:
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -1,14 +1,20 @@
|
|||||||
package stashbox
|
package stashbox
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"mime/multipart"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/Yamashou/gqlgenc/client"
|
"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/logger"
|
||||||
"github.com/stashapp/stash/pkg/match"
|
"github.com/stashapp/stash/pkg/match"
|
||||||
@@ -66,6 +72,18 @@ func (c Client) QueryStashBoxScene(ctx context.Context, queryStr string) ([]*mod
|
|||||||
return ret, nil
|
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
|
// FindStashBoxScenesByFingerprints queries stash-box for scenes using every
|
||||||
// scene's MD5/OSHASH checksum, or PHash, and returns results in the same order
|
// scene's MD5/OSHASH checksum, or PHash, and returns results in the same order
|
||||||
// as the input slice.
|
// as the input slice.
|
||||||
@@ -78,6 +96,7 @@ func (c Client) FindStashBoxScenesByFingerprints(ctx context.Context, sceneIDs [
|
|||||||
var fingerprints []*graphql.FingerprintQueryInput
|
var fingerprints []*graphql.FingerprintQueryInput
|
||||||
// map fingerprints to their scene index
|
// map fingerprints to their scene index
|
||||||
fpToScene := make(map[string][]int)
|
fpToScene := make(map[string][]int)
|
||||||
|
phashToScene := make(map[int64][]int)
|
||||||
|
|
||||||
if err := c.txnManager.WithReadTxn(ctx, func(r models.ReaderRepository) error {
|
if err := c.txnManager.WithReadTxn(ctx, func(r models.ReaderRepository) error {
|
||||||
qb := r.Scene()
|
qb := r.Scene()
|
||||||
@@ -115,6 +134,7 @@ func (c Client) FindStashBoxScenesByFingerprints(ctx context.Context, sceneIDs [
|
|||||||
Algorithm: graphql.FingerprintAlgorithmPhash,
|
Algorithm: graphql.FingerprintAlgorithmPhash,
|
||||||
})
|
})
|
||||||
fpToScene[phashStr] = append(fpToScene[phashStr], index)
|
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))
|
ret := make([][]*models.ScrapedScene, len(sceneIDs))
|
||||||
for _, s := range allScenes {
|
for _, s := range allScenes {
|
||||||
var addedTo []int
|
var addedTo []int
|
||||||
for _, fp := range s.Fingerprints {
|
|
||||||
sceneIndexes := fpToScene[fp.Hash]
|
addScene := func(sceneIndexes []int) {
|
||||||
for _, index := range sceneIndexes {
|
for _, index := range sceneIndexes {
|
||||||
if !utils.IntInclude(addedTo, index) {
|
if !utils.IntInclude(addedTo, index) {
|
||||||
addedTo = append(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
|
return ret, nil
|
||||||
@@ -468,7 +506,7 @@ func findURL(urls []*graphql.URLFragment, urlType string) *string {
|
|||||||
|
|
||||||
func enumToStringPtr(e fmt.Stringer, titleCase bool) *string {
|
func enumToStringPtr(e fmt.Stringer, titleCase bool) *string {
|
||||||
if e != nil {
|
if e != nil {
|
||||||
ret := e.String()
|
ret := strings.ReplaceAll(e.String(), "_", " ")
|
||||||
if titleCase {
|
if titleCase {
|
||||||
ret = strings.Title(strings.ToLower(ret))
|
ret = strings.Title(strings.ToLower(ret))
|
||||||
}
|
}
|
||||||
@@ -478,6 +516,28 @@ func enumToStringPtr(e fmt.Stringer, titleCase bool) *string {
|
|||||||
return nil
|
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 {
|
func formatMeasurements(m graphql.MeasurementsFragment) *string {
|
||||||
if m.BandSize != nil && m.CupSize != nil && m.Hip != nil && m.Waist != nil {
|
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)
|
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 {
|
if p.Gender != nil {
|
||||||
sp.Gender = enumToStringPtr(p.Gender, false)
|
sp.Gender = translateGender(p.Gender)
|
||||||
}
|
}
|
||||||
|
|
||||||
if p.Ethnicity != nil {
|
if p.Ethnicity != nil {
|
||||||
@@ -731,3 +791,274 @@ func (c Client) FindStashBoxPerformerByName(ctx context.Context, name string) (*
|
|||||||
|
|
||||||
return ret, nil
|
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"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"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
|
// 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
|
// 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
|
// 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 {
|
if !driverOptions.UseCDP {
|
||||||
return nil, fmt.Errorf("url shouldn't be fetched through CDP")
|
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) {
|
if isCDPPathHTTP(globalConfig) || isCDPPathWS(globalConfig) {
|
||||||
remote := cdpPath
|
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 CDPPath is http(s) then we need to get the websocket URL
|
||||||
if isCDPPathHTTP(globalConfig) {
|
if isCDPPathHTTP(globalConfig) {
|
||||||
var err error
|
var err error
|
||||||
@@ -150,7 +178,7 @@ func urlFromCDP(ctx context.Context, url string, driverOptions scraperDriverOpti
|
|||||||
setCDPCookies(driverOptions),
|
setCDPCookies(driverOptions),
|
||||||
printCDPCookies(driverOptions, "Cookies found"),
|
printCDPCookies(driverOptions, "Cookies found"),
|
||||||
network.SetExtraHTTPHeaders(network.Headers(headers)),
|
network.SetExtraHTTPHeaders(network.Headers(headers)),
|
||||||
chromedp.Navigate(url),
|
chromedp.Navigate(urlCDP),
|
||||||
chromedp.Sleep(sleepDuration),
|
chromedp.Sleep(sleepDuration),
|
||||||
setCDPClicks(driverOptions),
|
setCDPClicks(driverOptions),
|
||||||
chromedp.OuterHTML("html", &res, chromedp.ByQuery),
|
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())
|
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 {
|
func CheckAllowPublicWithoutAuth(c *config.Instance, r *http.Request) error {
|
||||||
if !c.HasCredentials() && !c.GetDangerousAllowPublicWithoutAuth() && !c.IsNewSystem() {
|
if !c.HasCredentials() && !c.GetDangerousAllowPublicWithoutAuth() && !c.IsNewSystem() {
|
||||||
requestIPString, _, err := net.SplitHostPort(r.RemoteAddr)
|
requestIPString, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||||
@@ -42,43 +36,21 @@ func CheckAllowPublicWithoutAuth(c *config.Instance, r *http.Request) error {
|
|||||||
|
|
||||||
if r.Header.Get("X-FORWARDED-FOR") != "" {
|
if r.Header.Get("X-FORWARDED-FOR") != "" {
|
||||||
// Request was proxied
|
// Request was proxied
|
||||||
trustedProxies := c.GetTrustedProxies()
|
|
||||||
proxyChain := strings.Split(r.Header.Get("X-FORWARDED-FOR"), ", ")
|
proxyChain := strings.Split(r.Header.Get("X-FORWARDED-FOR"), ", ")
|
||||||
|
|
||||||
if len(trustedProxies) == 0 {
|
// validate proxies against local network only
|
||||||
// validate proxies against local network only
|
if !isLocalIP(requestIP) {
|
||||||
if !isLocalIP(requestIP) {
|
return ExternalAccessError(requestIP)
|
||||||
return ExternalAccessError(requestIP)
|
|
||||||
} else {
|
|
||||||
// Safe to validate X-Forwarded-For
|
|
||||||
for i := range proxyChain {
|
|
||||||
ip := net.ParseIP(proxyChain[i])
|
|
||||||
if !isLocalIP(ip) {
|
|
||||||
return ExternalAccessError(ip)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// validate proxies against trusted proxies list
|
// Safe to validate X-Forwarded-For
|
||||||
if isIPTrustedProxy(requestIP, trustedProxies) {
|
for i := range proxyChain {
|
||||||
// Safe to validate X-Forwarded-For
|
ip := net.ParseIP(proxyChain[i])
|
||||||
// validate backwards, as only the last one is not attacker-controlled
|
if !isLocalIP(ip) {
|
||||||
for i := len(proxyChain) - 1; i >= 0; i-- {
|
return ExternalAccessError(ip)
|
||||||
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
|
} else if !isLocalIP(requestIP) { // request was not proxied
|
||||||
return ExternalAccessError(requestIP)
|
return ExternalAccessError(requestIP)
|
||||||
}
|
}
|
||||||
@@ -104,18 +76,6 @@ func isLocalIP(requestIP net.IP) bool {
|
|||||||
return requestIP.IsPrivate() || requestIP.IsLoopback() || requestIP.IsLinkLocalUnicast() || cgNatAddrSpace.Contains(requestIP)
|
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) {
|
func LogExternalAccessError(err ExternalAccessError) {
|
||||||
logger.Errorf("Stash has been accessed from the internet (public IP %s), without authentication. \n"+
|
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"+
|
"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 {
|
testCases := []struct {
|
||||||
proxyChain string
|
proxyChain string
|
||||||
err error
|
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
|
// test invalid request IPs
|
||||||
invalidIPs := []string{"192.168.1.a:9999", "192.168.1.1"}
|
invalidIPs := []string{"192.168.1.a:9999", "192.168.1.1"}
|
||||||
@@ -134,11 +101,6 @@ func TestCheckAllowPublicWithoutAuth(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
err := CheckAllowPublicWithoutAuth(c, r)
|
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 {
|
if err == nil {
|
||||||
t.Errorf("[%s]: expected error", remoteAddr)
|
t.Errorf("[%s]: expected error", remoteAddr)
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -391,13 +391,7 @@ func stringCriterionHandler(c *models.StringCriterionInput, column string) crite
|
|||||||
case models.CriterionModifierNotNull:
|
case models.CriterionModifierNotNull:
|
||||||
f.addWhere("(" + column + " IS NOT NULL AND TRIM(" + column + ") != '')")
|
f.addWhere("(" + column + " IS NOT NULL AND TRIM(" + column + ") != '')")
|
||||||
default:
|
default:
|
||||||
clause, count := getSimpleCriterionClause(modifier, "?")
|
panic("unsupported string filter modifier")
|
||||||
|
|
||||||
if count == 1 {
|
|
||||||
f.addWhere(column+" "+clause, c.Value)
|
|
||||||
} else {
|
|
||||||
f.addWhere(column + " " + clause)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -220,6 +220,8 @@ func (qb *galleryQueryBuilder) makeFilter(galleryFilter *models.GalleryFilterTyp
|
|||||||
query.handleCriterion(galleryPerformerTagsCriterionHandler(qb, galleryFilter.PerformerTags))
|
query.handleCriterion(galleryPerformerTagsCriterionHandler(qb, galleryFilter.PerformerTags))
|
||||||
query.handleCriterion(galleryAverageResolutionCriterionHandler(qb, galleryFilter.AverageResolution))
|
query.handleCriterion(galleryAverageResolutionCriterionHandler(qb, galleryFilter.AverageResolution))
|
||||||
query.handleCriterion(galleryImageCountCriterionHandler(qb, galleryFilter.ImageCount))
|
query.handleCriterion(galleryImageCountCriterionHandler(qb, galleryFilter.ImageCount))
|
||||||
|
query.handleCriterion(galleryPerformerFavoriteCriterionHandler(galleryFilter.PerformerFavorite))
|
||||||
|
query.handleCriterion(galleryPerformerAgeCriterionHandler(galleryFilter.PerformerAge))
|
||||||
|
|
||||||
return query
|
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 {
|
func galleryAverageResolutionCriterionHandler(qb *galleryQueryBuilder, resolution *models.ResolutionCriterionInput) criterionHandlerFunc {
|
||||||
return func(f *filterBuilder) {
|
return func(f *filterBuilder) {
|
||||||
if resolution != nil && resolution.Value.IsValid() {
|
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(imagePerformerCountCriterionHandler(qb, imageFilter.PerformerCount))
|
||||||
query.handleCriterion(imageStudioCriterionHandler(qb, imageFilter.Studios))
|
query.handleCriterion(imageStudioCriterionHandler(qb, imageFilter.Studios))
|
||||||
query.handleCriterion(imagePerformerTagsCriterionHandler(qb, imageFilter.PerformerTags))
|
query.handleCriterion(imagePerformerTagsCriterionHandler(qb, imageFilter.PerformerTags))
|
||||||
|
query.handleCriterion(imagePerformerFavoriteCriterionHandler(imageFilter.PerformerFavorite))
|
||||||
|
|
||||||
return query
|
return query
|
||||||
}
|
}
|
||||||
@@ -446,6 +447,26 @@ func imagePerformerCountCriterionHandler(qb *imageQueryBuilder, performerCount *
|
|||||||
return h.handler(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 {
|
func imageStudioCriterionHandler(qb *imageQueryBuilder, studios *models.HierarchicalMultiCriterionInput) criterionHandlerFunc {
|
||||||
h := hierarchicalMultiCriterionHandlerBuilder{
|
h := hierarchicalMultiCriterionHandlerBuilder{
|
||||||
tx: qb.tx,
|
tx: qb.tx,
|
||||||
|
|||||||
@@ -21,6 +21,12 @@ WHERE performers_tags.tag_id = ?
|
|||||||
GROUP BY performers_tags.performer_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 {
|
type performerQueryBuilder struct {
|
||||||
repository
|
repository
|
||||||
}
|
}
|
||||||
@@ -184,7 +190,7 @@ func (qb *performerQueryBuilder) QueryForAutoTag(words []string) ([]*models.Perf
|
|||||||
var args []interface{}
|
var args []interface{}
|
||||||
|
|
||||||
whereClauses = append(whereClauses, "name regexp ?")
|
whereClauses = append(whereClauses, "name regexp ?")
|
||||||
args = append(args, "^[\\w][.\\-_ ]")
|
args = append(args, singleFirstCharacterRegex)
|
||||||
|
|
||||||
for _, w := range words {
|
for _, w := range words {
|
||||||
whereClauses = append(whereClauses, "name like ?")
|
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 {
|
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...)
|
rows, err := r.tx.Queryx(query, args...)
|
||||||
|
|
||||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
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(sceneStudioCriterionHandler(qb, sceneFilter.Studios))
|
||||||
query.handleCriterion(sceneMoviesCriterionHandler(qb, sceneFilter.Movies))
|
query.handleCriterion(sceneMoviesCriterionHandler(qb, sceneFilter.Movies))
|
||||||
query.handleCriterion(scenePerformerTagsCriterionHandler(qb, sceneFilter.PerformerTags))
|
query.handleCriterion(scenePerformerTagsCriterionHandler(qb, sceneFilter.PerformerTags))
|
||||||
|
query.handleCriterion(scenePerformerFavoriteCriterionHandler(sceneFilter.PerformerFavorite))
|
||||||
|
query.handleCriterion(scenePerformerAgeCriterionHandler(sceneFilter.PerformerAge))
|
||||||
|
query.handleCriterion(scenePhashDuplicatedCriterionHandler(sceneFilter.Duplicated))
|
||||||
|
|
||||||
return query
|
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 {
|
func durationCriterionHandler(durationFilter *models.IntCriterionInput, column string) criterionHandlerFunc {
|
||||||
return func(f *filterBuilder) {
|
return func(f *filterBuilder) {
|
||||||
if durationFilter != nil {
|
if durationFilter != nil {
|
||||||
@@ -642,6 +660,43 @@ func scenePerformerCountCriterionHandler(qb *sceneQueryBuilder, performerCount *
|
|||||||
return h.handler(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 {
|
func sceneStudioCriterionHandler(qb *sceneQueryBuilder, studios *models.HierarchicalMultiCriterionInput) criterionHandlerFunc {
|
||||||
h := hierarchicalMultiCriterionHandlerBuilder{
|
h := hierarchicalMultiCriterionHandlerBuilder{
|
||||||
tx: qb.tx,
|
tx: qb.tx,
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/stashapp/stash/pkg/logger"
|
|
||||||
"github.com/stashapp/stash/pkg/models"
|
"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:
|
case strings.Compare(sort, "filesize") == 0:
|
||||||
colName := getColumn(tableName, "size")
|
colName := getColumn(tableName, "size")
|
||||||
return " ORDER BY cast(" + colName + " as integer) " + direction
|
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):
|
case strings.HasPrefix(sort, randomSeedPrefix):
|
||||||
// seed as a parameter from the UI
|
// seed as a parameter from the UI
|
||||||
// turn the provided seed into a float
|
// turn the provided seed into a float
|
||||||
@@ -149,54 +152,39 @@ func getInBinding(length int) string {
|
|||||||
return "(" + bindings + ")"
|
return "(" + bindings + ")"
|
||||||
}
|
}
|
||||||
|
|
||||||
func getSimpleCriterionClause(criterionModifier models.CriterionModifier, rhs string) (string, int) {
|
func getIntCriterionWhereClause(column string, input models.IntCriterionInput) (string, []interface{}) {
|
||||||
if modifier := criterionModifier.String(); criterionModifier.IsValid() {
|
return getIntWhereClause(column, input.Modifier, input.Value, input.Value2)
|
||||||
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{}) {
|
func getIntWhereClause(column string, modifier models.CriterionModifier, value int, upper *int) (string, []interface{}) {
|
||||||
binding, _ := getSimpleCriterionClause(input.Modifier, "?")
|
if upper == nil {
|
||||||
var args []interface{}
|
u := 0
|
||||||
|
upper = &u
|
||||||
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}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
// returns where clause and having clause
|
||||||
|
|||||||
@@ -145,7 +145,6 @@ func (qb *studioQueryBuilder) QueryForAutoTag(words []string) ([]*models.Studio,
|
|||||||
var args []interface{}
|
var args []interface{}
|
||||||
|
|
||||||
// always include names that begin with a single character
|
// always include names that begin with a single character
|
||||||
singleFirstCharacterRegex := "^[\\w][.\\-_ ]"
|
|
||||||
whereClauses = append(whereClauses, "studios.name regexp ? OR COALESCE(studio_aliases.alias, '') regexp ?")
|
whereClauses = append(whereClauses, "studios.name regexp ? OR COALESCE(studio_aliases.alias, '') regexp ?")
|
||||||
args = append(args, singleFirstCharacterRegex, singleFirstCharacterRegex)
|
args = append(args, singleFirstCharacterRegex, singleFirstCharacterRegex)
|
||||||
|
|
||||||
|
|||||||
@@ -236,7 +236,6 @@ func (qb *tagQueryBuilder) QueryForAutoTag(words []string) ([]*models.Tag, error
|
|||||||
var args []interface{}
|
var args []interface{}
|
||||||
|
|
||||||
// always include names that begin with a single character
|
// always include names that begin with a single character
|
||||||
singleFirstCharacterRegex := "^[\\w][.\\-_ ]"
|
|
||||||
whereClauses = append(whereClauses, "tags.name regexp ? OR COALESCE(tag_aliases.alias, '') regexp ?")
|
whereClauses = append(whereClauses, "tags.name regexp ? OR COALESCE(tag_aliases.alias, '') regexp ?")
|
||||||
args = append(args, singleFirstCharacterRegex, singleFirstCharacterRegex)
|
args = append(args, singleFirstCharacterRegex, singleFirstCharacterRegex)
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
# "stashapp/compiler:develop" "stashapp/compiler:4"
|
COMPILER_CONTAINER="stashapp/compiler:6"
|
||||||
COMPILER_CONTAINER="stashapp/compiler:5"
|
|
||||||
|
|
||||||
BUILD_DATE=`go run -mod=vendor scripts/getDate.go`
|
BUILD_DATE=`go run -mod=vendor scripts/getDate.go`
|
||||||
GITHASH=`git rev-parse --short HEAD`
|
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\""
|
SETENV="BUILD_DATE=\"$BUILD_DATE\" GITHASH=$GITHASH STASH_VERSION=\"$STASH_VERSION\""
|
||||||
SETUP="export CGO_ENABLED=1;"
|
SETUP="export CGO_ENABLED=1;"
|
||||||
WINDOWS="echo '=== Building Windows binary ==='; $SETENV make cross-compile-windows;"
|
WINDOWS="echo '=== Building Windows binary ==='; $SETENV make cross-compile-windows;"
|
||||||
DARWIN="echo '=== Building OSX binary ==='; $SETENV make cross-compile-osx-intel;"
|
DARWIN="echo '=== Building OSX binary ==='; $SETENV make cross-compile-macos-intel;"
|
||||||
DARWIN_ARM64="echo '=== Building OSX (arm64) binary ==='; $SETENV make cross-compile-osx-applesilicon;"
|
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_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_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;"
|
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>
|
<head>
|
||||||
<base href="/">
|
<base href="/">
|
||||||
<meta charset="utf-8" />
|
<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
|
<meta
|
||||||
name="viewport"
|
name="viewport"
|
||||||
content="width=device-width, initial-scale=1, maximum-scale=1"
|
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
|
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/
|
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>
|
<title>Stash</title>
|
||||||
<script>window.STASH_BASE_URL = "/%BASE_URL%/"</script>
|
<script>window.STASH_BASE_URL = "/%BASE_URL%/"</script>
|
||||||
</head>
|
</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"
|
"type": "image/x-icon"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"start_url": ".",
|
"start_url": "/",
|
||||||
|
"scope": ".",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
"theme_color": "#000000",
|
"theme_color": "#000000",
|
||||||
"background_color": "#ffffff"
|
"background_color": "#ffffff"
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import V090 from "./versions/v090.md";
|
|||||||
import V0100 from "./versions/v0100.md";
|
import V0100 from "./versions/v0100.md";
|
||||||
import V0110 from "./versions/v0110.md";
|
import V0110 from "./versions/v0110.md";
|
||||||
import V0120 from "./versions/v0120.md";
|
import V0120 from "./versions/v0120.md";
|
||||||
|
import V0130 from "./versions/v0130.md";
|
||||||
import { MarkdownPage } from "../Shared/MarkdownPage";
|
import { MarkdownPage } from "../Shared/MarkdownPage";
|
||||||
|
|
||||||
// to avoid use of explicit any
|
// to avoid use of explicit any
|
||||||
@@ -53,9 +54,9 @@ const Changelog: React.FC = () => {
|
|||||||
// after new release:
|
// after new release:
|
||||||
// add entry to releases, using the current* fields
|
// add entry to releases, using the current* fields
|
||||||
// then update the current fields.
|
// then update the current fields.
|
||||||
const currentVersion = stashVersion || "v0.12.0";
|
const currentVersion = stashVersion || "v0.13.0";
|
||||||
const currentDate = buildDate;
|
const currentDate = buildDate;
|
||||||
const currentPage = V0120;
|
const currentPage = V0130;
|
||||||
|
|
||||||
const releases: IStashRelease[] = [
|
const releases: IStashRelease[] = [
|
||||||
{
|
{
|
||||||
@@ -64,9 +65,14 @@ const Changelog: React.FC = () => {
|
|||||||
page: currentPage,
|
page: currentPage,
|
||||||
defaultOpen: true,
|
defaultOpen: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
version: "v0.12.0",
|
||||||
|
date: "2021-12-29",
|
||||||
|
page: V0120,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
version: "v0.11.0",
|
version: "v0.11.0",
|
||||||
date: "2021-11-15",
|
date: "2021-11-16",
|
||||||
page: V0110,
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// combine the defaults with the system preview generation settings
|
||||||
if (configuration?.defaults.generate) {
|
if (configuration?.defaults.generate) {
|
||||||
const { generate } = configuration.defaults;
|
const { generate } = configuration.defaults;
|
||||||
setOptions(withoutTypename(generate));
|
setOptions(withoutTypename(generate));
|
||||||
setConfigRead(true);
|
setConfigRead(true);
|
||||||
} else if (configuration?.general) {
|
}
|
||||||
// backwards compatibility
|
|
||||||
|
if (configuration?.general) {
|
||||||
const { general } = configuration;
|
const { general } = configuration;
|
||||||
setOptions((existing) => ({
|
setOptions((existing) => ({
|
||||||
...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 { FormUtils } from "src/utils";
|
||||||
import MultiSet from "../Shared/MultiSet";
|
import MultiSet from "../Shared/MultiSet";
|
||||||
import { RatingStars } from "../Scenes/SceneDetails/RatingStars";
|
import { RatingStars } from "../Scenes/SceneDetails/RatingStars";
|
||||||
|
import {
|
||||||
|
getAggregateInputIDs,
|
||||||
|
getAggregateInputValue,
|
||||||
|
getAggregatePerformerIds,
|
||||||
|
getAggregateRating,
|
||||||
|
getAggregateStudioId,
|
||||||
|
getAggregateTagIds,
|
||||||
|
} from "src/utils/bulkUpdate";
|
||||||
|
|
||||||
interface IListOperationProps {
|
interface IListOperationProps {
|
||||||
selected: GQL.SlimGalleryDataFragment[];
|
selected: GQL.SlimGalleryDataFragment[];
|
||||||
@@ -42,22 +50,12 @@ export const EditGalleriesDialog: React.FC<IListOperationProps> = (
|
|||||||
|
|
||||||
const checkboxRef = React.createRef<HTMLInputElement>();
|
const checkboxRef = React.createRef<HTMLInputElement>();
|
||||||
|
|
||||||
function makeBulkUpdateIds(
|
|
||||||
ids: string[],
|
|
||||||
mode: GQL.BulkUpdateIdMode
|
|
||||||
): GQL.BulkUpdateIds {
|
|
||||||
return {
|
|
||||||
mode,
|
|
||||||
ids,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function getGalleryInput(): GQL.BulkGalleryUpdateInput {
|
function getGalleryInput(): GQL.BulkGalleryUpdateInput {
|
||||||
// need to determine what we are actually setting on each gallery
|
// need to determine what we are actually setting on each gallery
|
||||||
const aggregateRating = getRating(props.selected);
|
const aggregateRating = getAggregateRating(props.selected);
|
||||||
const aggregateStudioId = getStudioId(props.selected);
|
const aggregateStudioId = getAggregateStudioId(props.selected);
|
||||||
const aggregatePerformerIds = getPerformerIds(props.selected);
|
const aggregatePerformerIds = getAggregatePerformerIds(props.selected);
|
||||||
const aggregateTagIds = getTagIds(props.selected);
|
const aggregateTagIds = getAggregateTagIds(props.selected);
|
||||||
|
|
||||||
const galleryInput: GQL.BulkGalleryUpdateInput = {
|
const galleryInput: GQL.BulkGalleryUpdateInput = {
|
||||||
ids: props.selected.map((gallery) => {
|
ids: props.selected.map((gallery) => {
|
||||||
@@ -65,67 +63,22 @@ export const EditGalleriesDialog: React.FC<IListOperationProps> = (
|
|||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
// if rating is undefined
|
galleryInput.rating = getAggregateInputValue(rating, aggregateRating);
|
||||||
if (rating === undefined) {
|
galleryInput.studio_id = getAggregateInputValue(
|
||||||
// and all galleries have the same rating, then we are unsetting the rating.
|
studioId,
|
||||||
if (aggregateRating) {
|
aggregateStudioId
|
||||||
// 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
|
galleryInput.performer_ids = getAggregateInputIDs(
|
||||||
if (studioId === undefined) {
|
performerMode,
|
||||||
// and all galleries have the same studioId,
|
performerIds,
|
||||||
// then unset the studioId, otherwise ignoring studioId
|
aggregatePerformerIds
|
||||||
if (aggregateStudioId) {
|
);
|
||||||
// null to unset studio_id
|
galleryInput.tag_ids = getAggregateInputIDs(
|
||||||
galleryInput.studio_id = null;
|
tagMode,
|
||||||
}
|
tagIds,
|
||||||
} else {
|
aggregateTagIds
|
||||||
// if studioId is set, then we are setting it
|
);
|
||||||
galleryInput.studio_id = studioId;
|
|
||||||
}
|
|
||||||
|
|
||||||
// if performerIds are empty
|
|
||||||
if (
|
|
||||||
performerMode === GQL.BulkUpdateIdMode.Set &&
|
|
||||||
(!performerIds || performerIds.length === 0)
|
|
||||||
) {
|
|
||||||
// and all galleries have the same ids,
|
|
||||||
if (aggregatePerformerIds.length > 0) {
|
|
||||||
// then unset the performerIds, otherwise ignore
|
|
||||||
galleryInput.performer_ids = makeBulkUpdateIds(
|
|
||||||
performerIds || [],
|
|
||||||
performerMode
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// if performerIds non-empty, then we are setting them
|
|
||||||
galleryInput.performer_ids = makeBulkUpdateIds(
|
|
||||||
performerIds || [],
|
|
||||||
performerMode
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// if tagIds non-empty, then we are setting them
|
|
||||||
if (
|
|
||||||
tagMode === GQL.BulkUpdateIdMode.Set &&
|
|
||||||
(!tagIds || tagIds.length === 0)
|
|
||||||
) {
|
|
||||||
// and all galleries have the same ids,
|
|
||||||
if (aggregateTagIds.length > 0) {
|
|
||||||
// then unset the tagIds, otherwise ignore
|
|
||||||
galleryInput.tag_ids = makeBulkUpdateIds(tagIds || [], tagMode);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// if tagIds non-empty, then we are setting them
|
|
||||||
galleryInput.tag_ids = makeBulkUpdateIds(tagIds || [], tagMode);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (organized !== undefined) {
|
if (organized !== undefined) {
|
||||||
galleryInput.organized = organized;
|
galleryInput.organized = organized;
|
||||||
@@ -157,85 +110,6 @@ export const EditGalleriesDialog: React.FC<IListOperationProps> = (
|
|||||||
setIsUpdating(false);
|
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(() => {
|
useEffect(() => {
|
||||||
const state = props.selected;
|
const state = props.selected;
|
||||||
let updateRating: number | undefined;
|
let updateRating: number | undefined;
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ export const GalleryCard: React.FC<IProps> = (props) => {
|
|||||||
function maybeRenderOrganized() {
|
function maybeRenderOrganized() {
|
||||||
if (props.gallery.organized) {
|
if (props.gallery.organized) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="organized">
|
||||||
<Button className="minimal">
|
<Button className="minimal">
|
||||||
<Icon icon="box" />
|
<Icon icon="box" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -21,7 +21,9 @@ export const GalleryDetailPanel: React.FC<IGalleryDetailProps> = ({
|
|||||||
if (!gallery.details) return;
|
if (!gallery.details) return;
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<h6>Details</h6>
|
<h6>
|
||||||
|
<FormattedMessage id="details" />
|
||||||
|
</h6>
|
||||||
<p className="pre">{gallery.details}</p>
|
<p className="pre">{gallery.details}</p>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -34,7 +36,12 @@ export const GalleryDetailPanel: React.FC<IGalleryDetailProps> = ({
|
|||||||
));
|
));
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<h6>Tags</h6>
|
<h6>
|
||||||
|
<FormattedMessage
|
||||||
|
id="countables.tags"
|
||||||
|
values={{ count: gallery.tags.length }}
|
||||||
|
/>
|
||||||
|
</h6>
|
||||||
{tags}
|
{tags}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -53,7 +60,12 @@ export const GalleryDetailPanel: React.FC<IGalleryDetailProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<h6>Performers</h6>
|
<h6>
|
||||||
|
<FormattedMessage
|
||||||
|
id="countables.performers"
|
||||||
|
values={{ count: gallery.performers.length }}
|
||||||
|
/>
|
||||||
|
</h6>
|
||||||
<div className="row justify-content-center gallery-performers">
|
<div className="row justify-content-center gallery-performers">
|
||||||
{cards}
|
{cards}
|
||||||
</div>
|
</div>
|
||||||
@@ -83,18 +95,19 @@ export const GalleryDetailPanel: React.FC<IGalleryDetailProps> = ({
|
|||||||
) : undefined}
|
) : undefined}
|
||||||
{gallery.rating ? (
|
{gallery.rating ? (
|
||||||
<h6>
|
<h6>
|
||||||
Rating: <RatingStars value={gallery.rating} />
|
<FormattedMessage id="rating" />:{" "}
|
||||||
|
<RatingStars value={gallery.rating} />
|
||||||
</h6>
|
</h6>
|
||||||
) : (
|
) : (
|
||||||
""
|
""
|
||||||
)}
|
)}
|
||||||
<h6>
|
<h6>
|
||||||
<FormattedMessage id="created_at" />:{" "}
|
<FormattedMessage id="created_at" />:{" "}
|
||||||
{TextUtils.formatDate(intl, gallery.created_at)}{" "}
|
{TextUtils.formatDateTime(intl, gallery.created_at)}{" "}
|
||||||
</h6>
|
</h6>
|
||||||
<h6>
|
<h6>
|
||||||
<FormattedMessage id="updated_at" />:{" "}
|
<FormattedMessage id="updated_at" />:{" "}
|
||||||
{TextUtils.formatDate(intl, gallery.updated_at)}{" "}
|
{TextUtils.formatDateTime(intl, gallery.updated_at)}{" "}
|
||||||
</h6>
|
</h6>
|
||||||
</div>
|
</div>
|
||||||
{gallery.studio && (
|
{gallery.studio && (
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export const GalleryFileInfoPanel: React.FC<IGalleryFileInfoPanelProps> = (
|
|||||||
truncate
|
truncate
|
||||||
/>
|
/>
|
||||||
<URLField
|
<URLField
|
||||||
id="path"
|
id="media_info.downloaded_from"
|
||||||
url={props.gallery.url}
|
url={props.gallery.url}
|
||||||
value={props.gallery.url}
|
value={props.gallery.url}
|
||||||
truncate
|
truncate
|
||||||
|
|||||||
@@ -9,6 +9,14 @@ import { useToast } from "src/hooks";
|
|||||||
import { FormUtils } from "src/utils";
|
import { FormUtils } from "src/utils";
|
||||||
import MultiSet from "../Shared/MultiSet";
|
import MultiSet from "../Shared/MultiSet";
|
||||||
import { RatingStars } from "../Scenes/SceneDetails/RatingStars";
|
import { RatingStars } from "../Scenes/SceneDetails/RatingStars";
|
||||||
|
import {
|
||||||
|
getAggregateInputIDs,
|
||||||
|
getAggregateInputValue,
|
||||||
|
getAggregatePerformerIds,
|
||||||
|
getAggregateRating,
|
||||||
|
getAggregateStudioId,
|
||||||
|
getAggregateTagIds,
|
||||||
|
} from "src/utils/bulkUpdate";
|
||||||
|
|
||||||
interface IListOperationProps {
|
interface IListOperationProps {
|
||||||
selected: GQL.SlimImageDataFragment[];
|
selected: GQL.SlimImageDataFragment[];
|
||||||
@@ -42,22 +50,12 @@ export const EditImagesDialog: React.FC<IListOperationProps> = (
|
|||||||
|
|
||||||
const checkboxRef = React.createRef<HTMLInputElement>();
|
const checkboxRef = React.createRef<HTMLInputElement>();
|
||||||
|
|
||||||
function makeBulkUpdateIds(
|
|
||||||
ids: string[],
|
|
||||||
mode: GQL.BulkUpdateIdMode
|
|
||||||
): GQL.BulkUpdateIds {
|
|
||||||
return {
|
|
||||||
mode,
|
|
||||||
ids,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function getImageInput(): GQL.BulkImageUpdateInput {
|
function getImageInput(): GQL.BulkImageUpdateInput {
|
||||||
// need to determine what we are actually setting on each image
|
// need to determine what we are actually setting on each image
|
||||||
const aggregateRating = getRating(props.selected);
|
const aggregateRating = getAggregateRating(props.selected);
|
||||||
const aggregateStudioId = getStudioId(props.selected);
|
const aggregateStudioId = getAggregateStudioId(props.selected);
|
||||||
const aggregatePerformerIds = getPerformerIds(props.selected);
|
const aggregatePerformerIds = getAggregatePerformerIds(props.selected);
|
||||||
const aggregateTagIds = getTagIds(props.selected);
|
const aggregateTagIds = getAggregateTagIds(props.selected);
|
||||||
|
|
||||||
const imageInput: GQL.BulkImageUpdateInput = {
|
const imageInput: GQL.BulkImageUpdateInput = {
|
||||||
ids: props.selected.map((image) => {
|
ids: props.selected.map((image) => {
|
||||||
@@ -65,67 +63,15 @@ export const EditImagesDialog: React.FC<IListOperationProps> = (
|
|||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
// if rating is undefined
|
imageInput.rating = getAggregateInputValue(rating, aggregateRating);
|
||||||
if (rating === undefined) {
|
imageInput.studio_id = getAggregateInputValue(studioId, aggregateStudioId);
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// if studioId is undefined
|
imageInput.performer_ids = getAggregateInputIDs(
|
||||||
if (studioId === undefined) {
|
performerMode,
|
||||||
// and all images have the same studioId,
|
performerIds,
|
||||||
// then unset the studioId, otherwise ignoring studioId
|
aggregatePerformerIds
|
||||||
if (aggregateStudioId) {
|
);
|
||||||
// null studio_id to unset it
|
imageInput.tag_ids = getAggregateInputIDs(tagMode, tagIds, aggregateTagIds);
|
||||||
imageInput.studio_id = null;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// if studioId is set, then we are setting it
|
|
||||||
imageInput.studio_id = studioId;
|
|
||||||
}
|
|
||||||
|
|
||||||
// if performerIds are empty
|
|
||||||
if (
|
|
||||||
performerMode === GQL.BulkUpdateIdMode.Set &&
|
|
||||||
(!performerIds || performerIds.length === 0)
|
|
||||||
) {
|
|
||||||
// and all images have the same ids,
|
|
||||||
if (aggregatePerformerIds.length > 0) {
|
|
||||||
// then unset the performerIds, otherwise ignore
|
|
||||||
imageInput.performer_ids = makeBulkUpdateIds(
|
|
||||||
performerIds || [],
|
|
||||||
performerMode
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// if performerIds non-empty, then we are setting them
|
|
||||||
imageInput.performer_ids = makeBulkUpdateIds(
|
|
||||||
performerIds || [],
|
|
||||||
performerMode
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// if tagIds non-empty, then we are setting them
|
|
||||||
if (
|
|
||||||
tagMode === GQL.BulkUpdateIdMode.Set &&
|
|
||||||
(!tagIds || tagIds.length === 0)
|
|
||||||
) {
|
|
||||||
// and all images have the same ids,
|
|
||||||
if (aggregateTagIds.length > 0) {
|
|
||||||
// then unset the tagIds, otherwise ignore
|
|
||||||
imageInput.tag_ids = makeBulkUpdateIds(tagIds || [], tagMode);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// if tagIds non-empty, then we are setting them
|
|
||||||
imageInput.tag_ids = makeBulkUpdateIds(tagIds || [], tagMode);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (organized !== undefined) {
|
if (organized !== undefined) {
|
||||||
imageInput.organized = organized;
|
imageInput.organized = organized;
|
||||||
@@ -155,83 +101,6 @@ export const EditImagesDialog: React.FC<IListOperationProps> = (
|
|||||||
setIsUpdating(false);
|
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(() => {
|
useEffect(() => {
|
||||||
const state = props.selected;
|
const state = props.selected;
|
||||||
let updateRating: number | undefined;
|
let updateRating: number | undefined;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React from "react";
|
import React, { MouseEvent } from "react";
|
||||||
import { Button, ButtonGroup } from "react-bootstrap";
|
import { Button, ButtonGroup } from "react-bootstrap";
|
||||||
import cx from "classnames";
|
import cx from "classnames";
|
||||||
import * as GQL from "src/core/generated-graphql";
|
import * as GQL from "src/core/generated-graphql";
|
||||||
@@ -14,6 +14,7 @@ interface IImageCardProps {
|
|||||||
selected: boolean | undefined;
|
selected: boolean | undefined;
|
||||||
zoomIndex: number;
|
zoomIndex: number;
|
||||||
onSelectedChanged: (selected: boolean, shiftKey: boolean) => void;
|
onSelectedChanged: (selected: boolean, shiftKey: boolean) => void;
|
||||||
|
onPreview?: (ev: MouseEvent) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ImageCard: React.FC<IImageCardProps> = (
|
export const ImageCard: React.FC<IImageCardProps> = (
|
||||||
@@ -49,7 +50,7 @@ export const ImageCard: React.FC<IImageCardProps> = (
|
|||||||
function maybeRenderOCounter() {
|
function maybeRenderOCounter() {
|
||||||
if (props.image.o_counter) {
|
if (props.image.o_counter) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="o-count">
|
||||||
<Button className="minimal">
|
<Button className="minimal">
|
||||||
<span className="fa-icon">
|
<span className="fa-icon">
|
||||||
<SweatDrops />
|
<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() {
|
function maybeRenderOrganized() {
|
||||||
if (props.image.organized) {
|
if (props.image.organized) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="organized">
|
||||||
<Button className="minimal">
|
<Button className="minimal">
|
||||||
<Icon icon="box" />
|
<Icon icon="box" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -78,6 +100,7 @@ export const ImageCard: React.FC<IImageCardProps> = (
|
|||||||
props.image.tags.length > 0 ||
|
props.image.tags.length > 0 ||
|
||||||
props.image.performers.length > 0 ||
|
props.image.performers.length > 0 ||
|
||||||
props.image.o_counter ||
|
props.image.o_counter ||
|
||||||
|
props.image.galleries.length > 0 ||
|
||||||
props.image.organized
|
props.image.organized
|
||||||
) {
|
) {
|
||||||
return (
|
return (
|
||||||
@@ -87,6 +110,7 @@ export const ImageCard: React.FC<IImageCardProps> = (
|
|||||||
{maybeRenderTagPopoverButton()}
|
{maybeRenderTagPopoverButton()}
|
||||||
{maybeRenderPerformerPopoverButton()}
|
{maybeRenderPerformerPopoverButton()}
|
||||||
{maybeRenderOCounter()}
|
{maybeRenderOCounter()}
|
||||||
|
{maybeRenderGallery()}
|
||||||
{maybeRenderOrganized()}
|
{maybeRenderOrganized()}
|
||||||
</ButtonGroup>
|
</ButtonGroup>
|
||||||
</>
|
</>
|
||||||
@@ -119,6 +143,13 @@ export const ImageCard: React.FC<IImageCardProps> = (
|
|||||||
alt={props.image.title ?? ""}
|
alt={props.image.title ?? ""}
|
||||||
src={props.image.paths.thumbnail ?? ""}
|
src={props.image.paths.thumbnail ?? ""}
|
||||||
/>
|
/>
|
||||||
|
{props.onPreview ? (
|
||||||
|
<div className="preview-button">
|
||||||
|
<Button onClick={props.onPreview}>
|
||||||
|
<Icon icon="search" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : undefined}
|
||||||
</div>
|
</div>
|
||||||
<RatingBanner rating={props.image.rating} />
|
<RatingBanner rating={props.image.rating} />
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ export const Image: React.FC = () => {
|
|||||||
|
|
||||||
const { data, error, loading } = useFindImage(id);
|
const { data, error, loading } = useFindImage(id);
|
||||||
const image = data?.findImage;
|
const image = data?.findImage;
|
||||||
const [oLoading, setOLoading] = useState(false);
|
|
||||||
const [incrementO] = useImageIncrementO(image?.id ?? "0");
|
const [incrementO] = useImageIncrementO(image?.id ?? "0");
|
||||||
const [decrementO] = useImageDecrementO(image?.id ?? "0");
|
const [decrementO] = useImageDecrementO(image?.id ?? "0");
|
||||||
const [resetO] = useImageResetO(image?.id ?? "0");
|
const [resetO] = useImageResetO(image?.id ?? "0");
|
||||||
@@ -87,34 +86,25 @@ export const Image: React.FC = () => {
|
|||||||
|
|
||||||
const onIncrementClick = async () => {
|
const onIncrementClick = async () => {
|
||||||
try {
|
try {
|
||||||
setOLoading(true);
|
|
||||||
await incrementO();
|
await incrementO();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
Toast.error(e);
|
Toast.error(e);
|
||||||
} finally {
|
|
||||||
setOLoading(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onDecrementClick = async () => {
|
const onDecrementClick = async () => {
|
||||||
try {
|
try {
|
||||||
setOLoading(true);
|
|
||||||
await decrementO();
|
await decrementO();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
Toast.error(e);
|
Toast.error(e);
|
||||||
} finally {
|
|
||||||
setOLoading(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onResetClick = async () => {
|
const onResetClick = async () => {
|
||||||
try {
|
try {
|
||||||
setOLoading(true);
|
|
||||||
await resetO();
|
await resetO();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
Toast.error(e);
|
Toast.error(e);
|
||||||
} finally {
|
|
||||||
setOLoading(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -196,7 +186,6 @@ export const Image: React.FC = () => {
|
|||||||
</Nav.Item>
|
</Nav.Item>
|
||||||
<Nav.Item className="ml-auto">
|
<Nav.Item className="ml-auto">
|
||||||
<OCounterButton
|
<OCounterButton
|
||||||
loading={oLoading}
|
|
||||||
value={image.o_counter || 0}
|
value={image.o_counter || 0}
|
||||||
onIncrement={onIncrementClick}
|
onIncrement={onIncrementClick}
|
||||||
onDecrement={onDecrementClick}
|
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