Merge pull request #2369 from stashapp/develop

Merge develop to master for 0.13
This commit is contained in:
WithoutPants
2022-03-08 07:04:34 +11:00
committed by GitHub
450 changed files with 37727 additions and 6913 deletions

View File

@@ -13,7 +13,7 @@ concurrency:
cancel-in-progress: true
env:
COMPILER_IMAGE: stashapp/compiler:5
COMPILER_IMAGE: stashapp/compiler:6
jobs:
build:
@@ -91,12 +91,12 @@ jobs:
- name: Compile for all supported platforms
run: |
docker exec -t build /bin/bash -c "make cross-compile-windows"
docker exec -t build /bin/bash -c "make cross-compile-osx-intel"
docker exec -t build /bin/bash -c "make cross-compile-osx-applesilicon"
docker exec -t build /bin/bash -c "make cross-compile-macos-intel"
docker exec -t build /bin/bash -c "make cross-compile-macos-applesilicon"
docker exec -t build /bin/bash -c "make cross-compile-linux"
docker exec -t build /bin/bash -c "make cross-compile-linux-arm64v8"
docker exec -t build /bin/bash -c "make cross-compile-linux-arm32v7"
docker exec -t build /bin/bash -c "make cross-compile-pi"
docker exec -t build /bin/bash -c "make cross-compile-linux-arm32v6"
- name: Cleanup build container
run: docker rm -f -v build
@@ -121,8 +121,8 @@ jobs:
if: ${{ github.event_name == 'pull_request' && github.base_ref != 'refs/heads/develop' && github.base_ref != 'refs/heads/master'}}
uses: actions/upload-artifact@v2
with:
name: stash-osx
path: dist/stash-osx
name: stash-macos-intel
path: dist/stash-macos-intel
- name: Upload Linux binary
# only upload binaries for pull requests
@@ -145,13 +145,13 @@ jobs:
automatic_release_tag: latest_develop
title: "${{ env.STASH_VERSION }}: Latest development build"
files: |
dist/stash-osx
dist/stash-osx-applesilicon
dist/stash-macos-intel
dist/stash-macos-applesilicon
dist/stash-win.exe
dist/stash-linux
dist/stash-linux-arm64v8
dist/stash-linux-arm32v7
dist/stash-pi
dist/stash-linux-arm32v6
CHECKSUMS_SHA1
- name: Master release
@@ -161,13 +161,13 @@ jobs:
token: "${{ secrets.GITHUB_TOKEN }}"
allow_override: true
files: |
dist/stash-osx
dist/stash-osx-applesilicon
dist/stash-macos-intel
dist/stash-macos-applesilicon
dist/stash-win.exe
dist/stash-linux
dist/stash-linux-arm64v8
dist/stash-linux-arm32v7
dist/stash-pi
dist/stash-linux-arm32v6
CHECKSUMS_SHA1
gzip: false

View File

@@ -9,7 +9,7 @@ on:
pull_request:
env:
COMPILER_IMAGE: stashapp/compiler:5
COMPILER_IMAGE: stashapp/compiler:6
jobs:
golangci:

View File

@@ -1,12 +1,12 @@
IS_WIN =
IS_WIN_SHELL =
ifeq (${SHELL}, sh.exe)
IS_WIN = true
IS_WIN_SHELL = true
endif
ifeq (${SHELL}, cmd)
IS_WIN = true
IS_WIN_SHELL = true
endif
ifdef IS_WIN
ifdef IS_WIN_SHELL
SEPARATOR := &&
SET := set
else
@@ -14,6 +14,11 @@ else
SET := export
endif
IS_WIN_OS =
ifeq ($(OS),Windows_NT)
IS_WIN_OS = true
endif
# set LDFLAGS environment variable to any extra ldflags required
# set OUTPUT to generate a specific binary name
@@ -46,9 +51,13 @@ ifndef OFFICIAL_BUILD
endif
build: pre-build
ifdef IS_WIN_OS
PLATFORM_SPECIFIC_LDFLAGS := -H windowsgui
endif
build:
$(eval LDFLAGS := $(LDFLAGS) -X 'github.com/stashapp/stash/pkg/api.version=$(STASH_VERSION)' -X 'github.com/stashapp/stash/pkg/api.buildstamp=$(BUILD_DATE)' -X 'github.com/stashapp/stash/pkg/api.githash=$(GITHASH)')
$(eval LDFLAGS := $(LDFLAGS) -X 'github.com/stashapp/stash/pkg/api.officialBuild=$(OFFICIAL_BUILD)')
go build $(OUTPUT) -mod=vendor -v -tags "sqlite_omit_load_extension osusergo netgo" $(GO_BUILD_FLAGS) -ldflags "$(LDFLAGS) $(EXTRA_LDFLAGS)"
$(eval LDFLAGS := $(LDFLAGS) -X 'github.com/stashapp/stash/pkg/manager/config.officialBuild=$(OFFICIAL_BUILD)')
go build $(OUTPUT) -mod=vendor -v -tags "sqlite_omit_load_extension osusergo netgo" $(GO_BUILD_FLAGS) -ldflags "$(LDFLAGS) $(EXTRA_LDFLAGS) $(PLATFORM_SPECIFIC_LDFLAGS)"
# strips debug symbols from the release build
build-release: EXTRA_LDFLAGS := -s -w
@@ -65,23 +74,38 @@ cross-compile-windows: export GOARCH := amd64
cross-compile-windows: export CC := x86_64-w64-mingw32-gcc
cross-compile-windows: export CXX := x86_64-w64-mingw32-g++
cross-compile-windows: OUTPUT := -o dist/stash-win.exe
cross-compile-windows: PLATFORM_SPECIFIC_LDFLAGS := -H windowsgui
cross-compile-windows: build-release-static
cross-compile-osx-intel: export GOOS := darwin
cross-compile-osx-intel: export GOARCH := amd64
cross-compile-osx-intel: export CC := o64-clang
cross-compile-osx-intel: export CXX := o64-clang++
cross-compile-osx-intel: OUTPUT := -o dist/stash-osx
cross-compile-macos-intel: export GOOS := darwin
cross-compile-macos-intel: export GOARCH := amd64
cross-compile-macos-intel: export CC := o64-clang
cross-compile-macos-intel: export CXX := o64-clang++
cross-compile-macos-intel: OUTPUT := -o dist/stash-macos-intel
# can't use static build for OSX
cross-compile-osx-intel: build-release
cross-compile-macos-intel: build-release
cross-compile-osx-applesilicon: export GOOS := darwin
cross-compile-osx-applesilicon: export GOARCH := arm64
cross-compile-osx-applesilicon: export CC := oa64e-clang
cross-compile-osx-applesilicon: export CXX := oa64e-clang++
cross-compile-osx-applesilicon: OUTPUT := -o dist/stash-osx-applesilicon
cross-compile-macos-applesilicon: export GOOS := darwin
cross-compile-macos-applesilicon: export GOARCH := arm64
cross-compile-macos-applesilicon: export CC := oa64e-clang
cross-compile-macos-applesilicon: export CXX := oa64e-clang++
cross-compile-macos-applesilicon: OUTPUT := -o dist/stash-macos-applesilicon
# can't use static build for OSX
cross-compile-osx-applesilicon: build-release
cross-compile-macos-applesilicon: build-release
cross-compile-macos:
rm -rf dist/Stash.app dist/Stash-macos.zip
make cross-compile-macos-applesilicon
make cross-compile-macos-intel
# Combine into one universal binary
lipo -create -output dist/stash-macos-universal dist/stash-macos-intel dist/stash-macos-applesilicon
rm dist/stash-macos-intel dist/stash-macos-applesilicon
# Place into bundle and zip up
cp -R scripts/macos-bundle dist/Stash.app
mkdir dist/Stash.app/Contents/MacOS
mv dist/stash-macos-universal dist/Stash.app/Contents/MacOS/stash
cd dist && zip -r Stash-macos.zip Stash.app && cd ..
rm -rf dist/Stash.app
cross-compile-linux: export GOOS := linux
cross-compile-linux: export GOARCH := amd64
@@ -101,21 +125,20 @@ cross-compile-linux-arm32v7: export CC := arm-linux-gnueabihf-gcc
cross-compile-linux-arm32v7: OUTPUT := -o dist/stash-linux-arm32v7
cross-compile-linux-arm32v7: build-release-static
cross-compile-pi: export GOOS := linux
cross-compile-pi: export GOARCH := arm
cross-compile-pi: export GOARM := 6
cross-compile-pi: export CC := arm-linux-gnueabi-gcc
cross-compile-pi: OUTPUT := -o dist/stash-pi
cross-compile-pi: build-release-static
cross-compile-linux-arm32v6: export GOOS := linux
cross-compile-linux-arm32v6: export GOARCH := arm
cross-compile-linux-arm32v6: export GOARM := 6
cross-compile-linux-arm32v6: export CC := arm-linux-gnueabi-gcc
cross-compile-linux-arm32v6: OUTPUT := -o dist/stash-linux-arm32v6
cross-compile-linux-arm32v6: build-release-static
cross-compile-all:
make cross-compile-windows
make cross-compile-osx-intel
make cross-compile-osx-applesilicon
make cross-compile-macos
make cross-compile-linux
make cross-compile-linux-arm64v8
make cross-compile-linux-arm32v7
make cross-compile-pi
make cross-compile-linux-arm32v6
# Regenerates GraphQL files
generate: generate-backend generate-frontend

View File

@@ -2,7 +2,7 @@
https://stashapp.cc
[![Build](https://github.com/stashapp/stash/actions/workflows/build.yml/badge.svg?branch=develop&event=push)](https://github.com/stashapp/stash/actions/workflows/build.yml)
[![Docker pulls](https://img.shields.io/docker/pulls/stashapp/stash.svg)](https://hub.docker.com/r/stashapp/Stash 'DockerHub')
[![Docker pulls](https://img.shields.io/docker/pulls/stashapp/stash.svg)](https://hub.docker.com/r/stashapp/stash 'DockerHub')
[![Go Report Card](https://goreportcard.com/badge/github.com/stashapp/stash)](https://goreportcard.com/report/github.com/stashapp/stash)
[![Discord](https://img.shields.io/discord/559159668438728723.svg?logo=discord)](https://discord.gg/2TsNFKt)
@@ -22,30 +22,32 @@ For further information you can [read the in-app manual](ui/v2.5/src/docs/en).
<img src="docs/readme_assets/windows_logo.svg" width="100%" height="75"> Windows | <img src="docs/readme_assets/mac_logo.svg" width="100%" height="75"> MacOS| <img src="docs/readme_assets/linux_logo.svg" width="100%" height="75"> Linux | <img src="docs/readme_assets/docker_logo.svg" width="100%" height="75"> Docker
:---:|:---:|:---:|:---:
[Latest Release](https://github.com/stashapp/stash/releases/latest/download/stash-win.exe) <br /> <sup><sub>[Development Preview](https://github.com/stashapp/stash/releases/download/latest_develop/stash-win.exe)</sub></sup> | [Latest Release (Apple Silicon)](https://github.com/stashapp/stash/releases/latest/download/stash-osx-applesilicon) <br /> <sup><sub>[Development Preview (Apple Silicon)](https://github.com/stashapp/stash/releases/download/latest_develop/stash-osx-applesilicon)</sub></sup> <br>[Latest Release (Intel)](https://github.com/stashapp/stash/releases/latest/download/stash-osx) <br /> <sup><sub>[Development Preview (Intel)](https://github.com/stashapp/stash/releases/download/latest_develop/stash-osx)</sub></sup> | [Latest Release (amd64)](https://github.com/stashapp/stash/releases/latest/download/stash-linux) <br /> <sup><sub>[Development Preview (amd64)](https://github.com/stashapp/stash/releases/download/latest_develop/stash-linux)</sub></sup> <br /> [More Architectures...](https://github.com/stashapp/stash/releases/latest) | [Instructions](docker/production/README.md) <br /> <sup><sub> [Sample docker-compose.yml](docker/production/docker-compose.yml)</sub></sup>
## Getting Started
Run the executable (double click the exe on windows or run `./stash-osx` / `./stash-linux` from the terminal on macOS / Linux) to get started.
*Note for Windows users:* Running the app might present a security prompt since the binary isn't yet signed. Bypass this by clicking "more info" and then the "run anyway" button.
[Latest Release](https://github.com/stashapp/stash/releases/latest/download/stash-win.exe) <br /> <sup><sub>[Development Preview](https://github.com/stashapp/stash/releases/download/latest_develop/stash-win.exe)</sub></sup> | [Latest Release (Apple Silicon)](https://github.com/stashapp/stash/releases/latest/download/stash-macos-applesilicon) <br /> <sup><sub>[Development Preview (Apple Silicon)](https://github.com/stashapp/stash/releases/download/latest_develop/stash-macos-applesilicon)</sub></sup> <br />[Latest Release (Intel)](https://github.com/stashapp/stash/releases/latest/download/stash-macos-intel) <br /> <sup><sub>[Development Preview (Intel)](https://github.com/stashapp/stash/releases/download/latest_develop/stash-macos-intel)</sub></sup> | [Latest Release (amd64)](https://github.com/stashapp/stash/releases/latest/download/stash-linux) <br /> <sup><sub>[Development Preview (amd64)](https://github.com/stashapp/stash/releases/download/latest_develop/stash-linux)</sub></sup> <br /> [More Architectures...](https://github.com/stashapp/stash/releases/latest) | [Instructions](docker/production/README.md) <br /> <sup><sub> [Sample docker-compose.yml](docker/production/docker-compose.yml)</sub></sup>
## First Run
#### Windows Users: Security Prompt
Running the app might present a security prompt since the binary isn't yet signed. Bypass this by clicking "more info" and then the "run anyway" button.
#### FFMPEG
Stash requires ffmpeg. If you don't have it installed, Stash will download a copy for you. It is recommended that Linux users install `ffmpeg` from their distro's package manager.
# Usage
## Quickstart Guide
Download and run Stash. It will prompt you for some configuration options and a directory to index (you can also do this step afterward)
Stash is a web-based application. Once the application is running, the interface is available (by default) from http://localhost:9999.
**If you'd like to automatically retrieve and organize information about your entire library,** You will need to download some [scrapers](https://github.com/stashapp/stash/tree/develop/ui/v2.5/src/docs/en/Scraping.md). The Stash community has developed scrapers for many popular data sources which can be downloaded and installed from [this repository](https://github.com/stashapp/CommunityScrapers).
On first run, Stash will prompt you for some configuration options and media directories to index, called "Scanning" in Stash. After scanning, your media will be available for browsing, curating, editing, and tagging.
The simplest way to tag a large number of files is by using the [Tagger](https://github.com/stashapp/stash/blob/develop/ui/v2.5/src/docs/en/Tagger.md) which uses filename keywords to help identify the file and pull in scene and performer information from our stash-box database. Note that this data source is not comprehensive and you may need to use the scrapers to identify some of your media.
Stash can pull metadata (performers, tags, descriptions, studios, and more) directly from many sites through the use of [scrapers](https://github.com/stashapp/stash/tree/develop/ui/v2.5/src/docs/en/Scraping.md), which integrate directly into Stash.
Many community-maintained scrapers are available for download at the [Community Scrapers Collection](https://github.com/stashapp/CommunityScrapers). The community also maintains StashDB, a crowd-sourced repository of scene, studio, and performer information, that can automatically identify much of a typical media collection. Inquire in the Discord for details. Identifying an entire collection will typically require a mix of multiple sources.
<sub>StashDB is the canonical instance of our open source metadata API, [stash-box](https://github.com/stashapp/stash-box).</sub>
# Translation
[![Translate](https://translate.stashapp.cc/widgets/stash/-/stash-desktop-client/svg-badge.svg)](https://translate.stashapp.cc/engage/stash/)
🇧🇷 🇨🇳 🇬🇧 🇫🇮 🇫🇷 🇩🇪 🇮🇹 🇪🇸 🇸🇪 🇹🇼
🇧🇷 🇨🇳 🇳🇱 🇬🇧 🇫🇮 🇫🇷 🇩🇪 🇮🇹 🇯🇵 🇪🇸 🇸🇪 🇹🇼 🇹🇷
Stash is available in 10 languages (so far!) and it could be in your language too. If you want to help us translate Stash into your language, you can make an account at [translate.stashapp.cc](https://translate.stashapp.cc/projects/stash/stash-desktop-client/) to get started contributing new languages or improving existing ones. Thanks!
Stash is available in 13 languages (so far!) and it could be in your language too. If you want to help us translate Stash into your language, you can make an account at [translate.stashapp.cc](https://translate.stashapp.cc/projects/stash/stash-desktop-client/) to get started contributing new languages or improving existing ones. Thanks!
# Support (FAQ)

View File

@@ -2,7 +2,7 @@ FROM --platform=$BUILDPLATFORM alpine:latest AS binary
ARG TARGETPLATFORM
WORKDIR /
COPY stash-* /
RUN if [ "$TARGETPLATFORM" = "linux/arm/v6" ]; then BIN=stash-pi; \
RUN if [ "$TARGETPLATFORM" = "linux/arm/v6" ]; then BIN=stash-linux-arm32v6; \
elif [ "$TARGETPLATFORM" = "linux/arm/v7" ]; then BIN=stash-linux-arm32v7; \
elif [ "$TARGETPLATFORM" = "linux/arm64" ]; then BIN=stash-linux-arm64v8; \
elif [ "$TARGETPLATFORM" = "linux/amd64" ]; then BIN=stash-linux; \

View File

@@ -20,7 +20,7 @@ RUN apt-get update && \
gcc-arm-linux-gnueabi libc-dev-armel-cross linux-libc-dev-armel-cross \
gcc-arm-linux-gnueabihf libc-dev-armhf-cross \
gcc-aarch64-linux-gnu libc-dev-arm64-cross \
nodejs yarn --no-install-recommends || exit 1; \
nodejs yarn zip --no-install-recommends || exit 1; \
rm -rf /var/lib/apt/lists/*;
# Cross compile setup

View File

@@ -1,6 +1,6 @@
user=stashapp
repo=compiler
version=5
version=6
latest:
docker build -t ${user}/${repo}:latest .

View File

@@ -1,5 +1,3 @@
Modified from https://github.com/bep/dockerfiles/tree/master/ci-goreleaser
When the dockerfile is changed, the version number should be incremented in the Makefile and the new version tag should be pushed to docker hub. The `scripts/cross-compile.sh` script should also be updated to use the new version number tag, and `.travis.yml` needs to be updated to pull the correct image tag.
A MacOS univeral binary can be created using `lipo -create -output stash-osx-universal stash-osx stash-osx-applesilicon`, available in the image.
When the dockerfile is changed, the version number should be incremented in the Makefile and the new version tag should be pushed to docker hub. The `scripts/cross-compile.sh` script should also be updated to use the new version number tag, and the github workflow files need to be updated to pull the correct image tag.

14
go.mod
View File

@@ -38,8 +38,8 @@ require (
golang.org/x/crypto v0.0.0-20210817164053-32db794688a5
golang.org/x/image v0.0.0-20210220032944-ac19c3e999fb
golang.org/x/net v0.0.0-20211123203042-d83791d6bcd9
golang.org/x/sys v0.0.0-20210823070655-63515b42dcdf
golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b // indirect
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e
golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b
golang.org/x/text v0.3.7
golang.org/x/tools v0.1.5 // indirect
gopkg.in/sourcemap.v1 v1.0.5 // indirect
@@ -47,6 +47,11 @@ require (
)
require (
github.com/apenwarr/fixconsole v0.0.0-20191012055117-5a9f6489cc29
github.com/go-chi/httplog v0.2.1
github.com/go-toast/toast v0.0.0-20190211030409-01e6764cf0a4
github.com/kermieisinthehouse/gosx-notifier v0.1.1
github.com/kermieisinthehouse/systray v1.2.4
github.com/lucasb-eyer/go-colorful v1.2.0
github.com/vearutop/statigz v1.1.6
github.com/vektah/gqlparser/v2 v2.0.1
@@ -55,10 +60,12 @@ require (
require (
github.com/agnivade/levenshtein v1.1.0 // indirect
github.com/antchfx/xpath v1.2.0 // indirect
github.com/apenwarr/w32 v0.0.0-20190407065021-aa00fece76ab // indirect
github.com/chromedp/sysutil v1.0.0 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/fsnotify/fsnotify v1.5.1 // indirect
github.com/go-chi/chi/v5 v5.0.0 // indirect
github.com/gobwas/httphead v0.1.0 // indirect
github.com/gobwas/pool v0.2.1 // indirect
github.com/gobwas/ws v1.1.0-rc.5 // indirect
@@ -77,10 +84,11 @@ require (
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.1 // indirect
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect
github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d // indirect
github.com/pelletier/go-toml v1.9.4 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rs/zerolog v1.18.0 // indirect
github.com/rs/zerolog v1.18.1-0.20200514152719-663cbb4c8469 // indirect
github.com/russross/blackfriday/v2 v2.0.1 // indirect
github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect
github.com/spf13/cast v1.4.1 // indirect

26
go.sum
View File

@@ -88,6 +88,10 @@ github.com/antchfx/xpath v1.2.0/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwq
github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
github.com/apache/arrow/go/arrow v0.0.0-20200601151325-b2287a20f230/go.mod h1:QNYViu/X0HXDHw7m3KXzWSVXIbfUvJqBFe6Gj8/pYA0=
github.com/apache/arrow/go/arrow v0.0.0-20210521153258-78c88a9f517b/go.mod h1:R4hW3Ug0s+n4CUsWHKOj00Pu01ZqU4x/hSF5kXUcXKQ=
github.com/apenwarr/fixconsole v0.0.0-20191012055117-5a9f6489cc29 h1:muXWUcay7DDy1/hEQWrYlBy+g0EuwT70sBHg65SeUc4=
github.com/apenwarr/fixconsole v0.0.0-20191012055117-5a9f6489cc29/go.mod h1:JYWahgHer+Z2xbsgHPtaDYVWzeHDminu+YIBWkxpCAY=
github.com/apenwarr/w32 v0.0.0-20190407065021-aa00fece76ab h1:CMGzRRCjnD50RjUFSArBLuCxiDvdp7b8YPAcikBEQ+k=
github.com/apenwarr/w32 v0.0.0-20190407065021-aa00fece76ab/go.mod h1:nfFtvHn2Hgs9G1u0/J6LHQv//EksNC+7G8vXmd1VTJ8=
github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q=
github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE=
github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o=
@@ -214,6 +218,10 @@ github.com/glycerine/goconvey v0.0.0-20180728074245-46e3a41ad493/go.mod h1:Ogl1T
github.com/go-chi/chi v3.3.2+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ=
github.com/go-chi/chi v4.0.2+incompatible h1:maB6vn6FqCxrpz4FqWdh4+lwpyZIQS7YEAUcHlgXVRs=
github.com/go-chi/chi v4.0.2+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ=
github.com/go-chi/chi/v5 v5.0.0 h1:DBPx88FjZJH3FsICfDAfIfnb7XxKIYVGG6lOPlhENAg=
github.com/go-chi/chi/v5 v5.0.0/go.mod h1:BBug9lr0cqtdAhsu6R4AAdvufI0/XBzAQSsUqJpoZOs=
github.com/go-chi/httplog v0.2.1 h1:KgCtIUkYNlfIsUPzE3utxd1KDKOvCrnAKaqdo0rmrh0=
github.com/go-chi/httplog v0.2.1/go.mod h1:JyHOFO9twSfGoTin/RoP25Lx2a9Btq10ug+sgxe0+bo=
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
@@ -224,6 +232,8 @@ github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG
github.com/go-sql-driver/mysql v1.5.0 h1:ozyZYNQW3x3HtqT1jira07DN2PArx2v7/mN66gGcHOs=
github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/go-toast/toast v0.0.0-20190211030409-01e6764cf0a4 h1:qZNfIGkIANxGv/OqtnntR4DfOY2+BgwR60cAcu/i3SE=
github.com/go-toast/toast v0.0.0-20190211030409-01e6764cf0a4/go.mod h1:kW3HQ4UdaAyrUCSSDR4xUzBKW6O2iA4uHhk7AtyYp10=
github.com/gobuffalo/attrs v0.0.0-20190224210810-a9411de4debd/go.mod h1:4duuawTqi2wkkpB4ePgWMaai6/Kc6WEz83bhFwpHzj0=
github.com/gobuffalo/depgen v0.0.0-20190329151759-d478694a28d3/go.mod h1:3STtPUQYuzV0gBVOY3vy6CfMm/ljR4pABfrTeHNLHUY=
github.com/gobuffalo/depgen v0.1.0/go.mod h1:+ifsuy7fhi15RWncXQQKjWS9JPkdah5sZvtHc2RXGlg=
@@ -484,6 +494,12 @@ github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0/go.mod h1:1NbS8ALr
github.com/karrick/godirwalk v1.8.0/go.mod h1:H5KPZjojv4lE+QYImBI8xVtrBRgYrIVsaRPx4tDPEn4=
github.com/karrick/godirwalk v1.10.3/go.mod h1:RoGL9dQei4vP9ilrpETWE8CLOZ1kiN0LhBygSwrAsHA=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
github.com/kermieisinthehouse/gosx-notifier v0.1.1 h1:lVXyKsa1c1RUkckp3KayloNLoI//fUwVYye3RPSPtEw=
github.com/kermieisinthehouse/gosx-notifier v0.1.1/go.mod h1:xyWT07azFtUOcHl96qMVvKhvKzsMcS7rKTHQyv8WTho=
github.com/kermieisinthehouse/systray v1.2.3 h1:tawLahcam/Ccs/F2n6EOQo8qJnSTD2hLzOYqTGsUsbA=
github.com/kermieisinthehouse/systray v1.2.3/go.mod h1:axh6C/jNuSyC0QGtidZJURc9h+h41HNoMySoLVrhVR4=
github.com/kermieisinthehouse/systray v1.2.4 h1:pdH5vnl+KKjRrVCRU4g/2W1/0HVzuuJ6WXHlPPHYY6s=
github.com/kermieisinthehouse/systray v1.2.4/go.mod h1:axh6C/jNuSyC0QGtidZJURc9h+h41HNoMySoLVrhVR4=
github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
@@ -576,6 +592,8 @@ github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d h1:VhgPp6v9qf9Agr/56bj7Y/xa04UccTW04VP0Qed4vnQ=
github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d/go.mod h1:YUTz3bUH2ZwIWBy3CJBeOBEugqcmXREj14T+iG/4k4U=
github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.12.0/go.mod h1:oUhWkIvk5aDxtKvDDuw8gItl8pKl42LzjC9KZE0HfGg=
@@ -633,8 +651,9 @@ github.com/rs/cors v1.6.0/go.mod h1:gFx+x8UowdsKA9AchylcLynDq+nNFfI8FkUZdN/jGCU=
github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ=
github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU=
github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc=
github.com/rs/zerolog v1.18.0 h1:CbAm3kP2Tptby1i9sYy2MGRg0uxIN9cyDb59Ys7W8z8=
github.com/rs/zerolog v1.18.0/go.mod h1:9nvC1axdVrAHcu/s9taAVfBuIdTZLVQmKQyvrUjF5+I=
github.com/rs/zerolog v1.18.1-0.20200514152719-663cbb4c8469 h1:DuXsEWHUTO5lsxxzKM4KUKGDIOi7nawNDs6d+AiulEA=
github.com/rs/zerolog v1.18.1-0.20200514152719-663cbb4c8469/go.mod h1:IzD0RJ65iWH0w97OQQebJEvTZYvsCUm9WVLWBQrJRjo=
github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q=
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
@@ -661,6 +680,7 @@ github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6Mwd
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE=
github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c/go.mod h1:XDJAKZRPZ1CvBcN2aX5YOUTYGHki24fSF0Iv48Ibg0s=
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
@@ -917,6 +937,7 @@ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5h
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190405154228-4b34438f7a67/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190415145633-3fd5a3612ccd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190419153524-e8e3143a4f4a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -984,8 +1005,9 @@ golang.org/x/sys v0.0.0-20210616045830-e2b7044e8c71/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210823070655-63515b42dcdf h1:2ucpDCmfkl8Bd/FsLtiD653Wf96cW37s+iGx93zsu4k=
golang.org/x/sys v0.0.0-20210823070655-63515b42dcdf/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e h1:fLOSk5Q00efkSvAm+4xcoXD+RRmLmmulPn5I3Y9F2EM=
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b h1:9zKuko04nR4gjZ4+DNjHqRlAJqbJETHwiNKDqTfOjfE=
golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=

View File

@@ -25,7 +25,6 @@ fragment ConfigGeneralData on ConfigGeneralResult {
username
password
maxSessionAge
trustedProxies
logFile
logOut
logLevel
@@ -52,8 +51,10 @@ fragment ConfigInterfaceData on ConfigInterfaceResult {
soundOnPreview
wallShowTitle
wallPlayback
showScrubber
maximumLoopDuration
noBrowser
notificationsEnabled
autostartVideo
autostartVideoOnPlaySelected
continuePlaylistDefault

View File

@@ -22,6 +22,12 @@ mutation MovieUpdate($input: MovieUpdateInput!) {
}
}
mutation BulkMovieUpdate($input: BulkMovieUpdateInput!) {
bulkMovieUpdate(input: $input) {
...MovieData
}
}
mutation MovieDestroy($id: ID!) {
movieDestroy(input: { id: $id })
}

View File

@@ -5,3 +5,11 @@ mutation SubmitStashBoxFingerprints($input: StashBoxFingerprintSubmissionInput!)
mutation StashBoxBatchPerformerTag($input: StashBoxBatchPerformerTagInput!) {
stashBoxBatchPerformerTag(input: $input)
}
mutation SubmitStashBoxSceneDraft($input: StashBoxDraftSubmissionInput!) {
submitStashBoxSceneDraft(input: $input)
}
mutation SubmitStashBoxPerformerDraft($input: StashBoxDraftSubmissionInput!) {
submitStashBoxPerformerDraft(input: $input)
}

View File

@@ -11,3 +11,10 @@ query Directory($path: String) {
directories
}
}
query ValidateStashBox($input: StashBoxInput!) {
validateStashBoxCredentials(input: $input) {
valid
status
}
}

View File

@@ -136,6 +136,7 @@ type Query {
"Desired collation locale. Determines the order of the directory result. eg. 'en-US', 'pt-BR', ..."
locale: String = "en"
): Directory!
validateStashBoxCredentials(input: StashBoxInput!): StashBoxValidationResult!
# System status
systemStatus: SystemStatus!
@@ -223,6 +224,7 @@ type Mutation {
movieUpdate(input: MovieUpdateInput!): Movie
movieDestroy(input: MovieDestroyInput!): Boolean!
moviesDestroy(ids: [ID!]!): Boolean!
bulkMovieUpdate(input: BulkMovieUpdateInput!): [Movie!]
tagCreate(input: TagCreateInput!): Tag
tagUpdate(input: TagUpdateInput!): Tag
@@ -281,6 +283,11 @@ type Mutation {
"""Submit fingerprints to stash-box instance"""
submitStashBoxFingerprints(input: StashBoxFingerprintSubmissionInput!): Boolean!
"""Submit scene as draft to stash-box instance"""
submitStashBoxSceneDraft(input: StashBoxDraftSubmissionInput!): ID
"""Submit performer as draft to stash-box instance"""
submitStashBoxPerformerDraft(input: StashBoxDraftSubmissionInput!): ID
"""Backup the database. Optionally returns a link to download the database file"""
backupDatabase(input: BackupDatabaseInput!): String

View File

@@ -76,7 +76,7 @@ input ConfigGeneralInput {
"""Maximum session cookie age"""
maxSessionAge: Int
"""Comma separated list of proxies to allow traffic from"""
trustedProxies: [String!]
trustedProxies: [String!] @deprecated(reason: "no longer supported")
"""Name of the log file"""
logFile: String
"""Whether to also output to stderr"""
@@ -157,7 +157,7 @@ type ConfigGeneralResult {
"""Maximum session cookie age"""
maxSessionAge: Int!
"""Comma separated list of proxies to allow traffic from"""
trustedProxies: [String!]!
trustedProxies: [String!] @deprecated(reason: "no longer supported")
"""Name of the log file"""
logFile: String
"""Whether to also output to stderr"""
@@ -208,6 +208,9 @@ input ConfigInterfaceInput {
"""Wall playback type"""
wallPlayback: String
"""Show scene scrubber by default"""
showScrubber: Boolean
"""Maximum duration (in seconds) in which a scene video will loop in the scene player"""
maximumLoopDuration: Int
"""If true, video will autostart on load in the scene player"""
@@ -239,6 +242,8 @@ input ConfigInterfaceInput {
funscriptOffset: Int
"""True if we should not auto-open a browser window on startup"""
noBrowser: Boolean
"""True if we should send notifications to the desktop"""
notificationsEnabled: Boolean
}
type ConfigDisableDropdownCreate {
@@ -259,10 +264,15 @@ type ConfigInterfaceResult {
"""Wall playback type"""
wallPlayback: String
"""Show scene scrubber by default"""
showScrubber: Boolean
"""Maximum duration (in seconds) in which a scene video will loop in the scene player"""
maximumLoopDuration: Int
""""True if we should not auto-open a browser window on startup"""
"""True if we should not auto-open a browser window on startup"""
noBrowser: Boolean
"""True if we should send desktop notifications"""
notificationsEnabled: Boolean
"""If true, video will autostart on load in the scene player"""
autostartVideo: Boolean
"""If true, video will autostart when loading from play random or play selected"""
@@ -391,3 +401,8 @@ type StashConfig {
input GenerateAPIKeyInput {
clear: Boolean
}
type StashBoxValidationResult {
valid: Boolean!
status: String!
}

View File

@@ -33,6 +33,12 @@ input ResolutionCriterionInput {
modifier: CriterionModifier!
}
input PHashDuplicationCriterionInput {
duplicated: Boolean
"""Currently unimplemented"""
distance: Int
}
input PerformerFilterType {
AND: PerformerFilterType
OR: PerformerFilterType
@@ -130,6 +136,8 @@ input SceneFilterType {
organized: Boolean
"""Filter by o-counter"""
o_counter: IntCriterionInput
"""Filter Scenes that have an exact phash match available"""
duplicated: PHashDuplicationCriterionInput
"""Filter by resolution"""
resolution: ResolutionCriterionInput
"""Filter by duration (in seconds)"""
@@ -148,6 +156,10 @@ input SceneFilterType {
tag_count: IntCriterionInput
"""Filter to only include scenes with performers with these tags"""
performer_tags: HierarchicalMultiCriterionInput
"""Filter scenes that have performers that have been favorited"""
performer_favorite: Boolean
"""Filter scenes by performer age at time of scene"""
performer_age: IntCriterionInput
"""Filter to only include scenes with these performers"""
performers: MultiCriterionInput
"""Filter by performer count"""
@@ -243,6 +255,10 @@ input GalleryFilterType {
performers: MultiCriterionInput
"""Filter by performer count"""
performer_count: IntCriterionInput
"""Filter galleries that have performers that have been favorited"""
performer_favorite: Boolean
"""Filter galleries by performer age at time of gallery"""
performer_age: IntCriterionInput
"""Filter by number of images in this gallery"""
image_count: IntCriterionInput
"""Filter by url"""
@@ -324,6 +340,8 @@ input ImageFilterType {
performers: MultiCriterionInput
"""Filter by performer count"""
performer_count: IntCriterionInput
"""Filter images that have performers that have been favorited"""
performer_favorite: Boolean
"""Filter to only include images with these galleries"""
galleries: MultiCriterionInput
}

View File

@@ -54,6 +54,14 @@ input MovieUpdateInput {
back_image: String
}
input BulkMovieUpdateInput {
clientMutationId: String
ids: [ID!]
rating: Int
studio_id: ID
director: String
}
input MovieDestroyInput {
id: ID!
}

View File

@@ -24,3 +24,8 @@ input StashBoxFingerprintSubmissionInput {
scene_ids: [String!]!
stash_box_index: Int!
}
input StashBoxDraftSubmissionInput {
id: String!
stash_box_index: Int!
}

View File

@@ -156,3 +156,21 @@ query FindSceneByID($id: ID!) {
mutation SubmitFingerprint($input: FingerprintSubmission!) {
submitFingerprint(input: $input)
}
query Me {
me {
name
}
}
mutation SubmitSceneDraft($input: SceneDraftInput!) {
submitSceneDraft(input: $input) {
id
}
}
mutation SubmitPerformerDraft($input: PerformerDraftInput!) {
submitPerformerDraft(input: $input) {
id
}
}

21
main.go
View File

@@ -3,13 +3,14 @@ package main
import (
"embed"
"fmt"
"os"
"os/signal"
"runtime/pprof"
"syscall"
"github.com/apenwarr/fixconsole"
"github.com/stashapp/stash/pkg/api"
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/manager"
_ "github.com/golang-migrate/migrate/v4/database/sqlite3"
@@ -22,18 +23,24 @@ var uiBox embed.FS
//go:embed ui/login
var loginUIBox embed.FS
func init() {
// On Windows, attach to parent shell
err := fixconsole.FixConsoleIfNeeded()
if err != nil {
fmt.Printf("FixConsoleOutput: %v\n", err)
}
}
func main() {
manager.Initialize()
api.Start(uiBox, loginUIBox)
// stop any profiling at exit
defer pprof.StopCPUProfile()
blockForever()
err := manager.GetInstance().Shutdown()
if err != nil {
logger.Errorf("Error when closing: %s", err)
}
// stop any profiling at exit
pprof.StopCPUProfile()
manager.GetInstance().Shutdown(0)
}
func blockForever() {

View File

@@ -57,15 +57,10 @@ func authenticateHandler() func(http.Handler) http.Handler {
if err := session.CheckAllowPublicWithoutAuth(c, r); err != nil {
var externalAccess session.ExternalAccessError
var untrustedProxy session.UntrustedProxyError
switch {
case errors.As(err, &externalAccess):
securityActivateTripwireAccessedFromInternetWithoutAuth(c, externalAccess, w)
return
case errors.As(err, &untrustedProxy):
logger.Warnf("Rejected request from untrusted proxy: %v", net.IP(untrustedProxy))
w.WriteHeader(http.StatusForbidden)
return
default:
logger.Errorf("Error checking external access security: %v", err)
w.WriteHeader(http.StatusInternalServerError)
@@ -135,9 +130,4 @@ func securityActivateTripwireAccessedFromInternetWithoutAuth(c *config.Instance,
if err != nil {
logger.Error(err)
}
err = manager.GetInstance().Shutdown()
if err != nil {
logger.Error(err)
}
}

View File

@@ -33,7 +33,7 @@ var stashReleases = func() map[string]string {
"darwin/arm64": "stash-osx-applesilicon",
"linux/amd64": "stash-linux",
"windows/amd64": "stash-win.exe",
"linux/arm": "stash-pi",
"linux/arm": "stash-linux-arm32v6",
"linux/arm64": "stash-linux-arm64v8",
"linux/armv7": "stash-linux-arm32v7",
}

28
pkg/api/favicon.go Normal file
View 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
}

View File

@@ -19,6 +19,10 @@ var matcher = language.NewMatcher([]language.Tag{
language.MustParse("sv-SE"),
language.MustParse("zh-CN"),
language.MustParse("zh-TW"),
language.MustParse("hr-HR"),
language.MustParse("nl-NL"),
language.MustParse("ru-RU"),
language.MustParse("tr-TR"),
})
// newCollator parses a locale into a collator

View File

@@ -197,10 +197,6 @@ func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input models.Co
c.Set(config.MaxSessionAge, *input.MaxSessionAge)
}
if input.TrustedProxies != nil {
c.Set(config.TrustedProxies, input.TrustedProxies)
}
if input.LogFile != nil {
c.Set(config.LogFile, input.LogFile)
}
@@ -298,6 +294,10 @@ func (r *mutationResolver) ConfigureInterface(ctx context.Context, input models.
setBool(config.NoBrowser, input.NoBrowser)
setBool(config.NotificationsEnabled, input.NotificationsEnabled)
setBool(config.ShowScrubber, input.ShowScrubber)
if input.WallPlayback != nil {
c.Set(config.WallPlayback, *input.WallPlayback)
}

View File

@@ -3,6 +3,7 @@ package api
import (
"context"
"database/sql"
"fmt"
"strconv"
"time"
@@ -220,6 +221,71 @@ func (r *mutationResolver) MovieUpdate(ctx context.Context, input models.MovieUp
return r.getMovie(ctx, movie.ID)
}
func (r *mutationResolver) BulkMovieUpdate(ctx context.Context, input models.BulkMovieUpdateInput) ([]*models.Movie, error) {
movieIDs, err := utils.StringSliceToIntSlice(input.Ids)
if err != nil {
return nil, err
}
updatedTime := time.Now()
translator := changesetTranslator{
inputMap: getUpdateInputMap(ctx),
}
updatedMovie := models.MoviePartial{
UpdatedAt: &models.SQLiteTimestamp{Timestamp: updatedTime},
}
updatedMovie.Rating = translator.nullInt64(input.Rating, "rating")
updatedMovie.StudioID = translator.nullInt64FromString(input.StudioID, "studio_id")
updatedMovie.Director = translator.nullString(input.Director, "director")
ret := []*models.Movie{}
if err := r.withTxn(ctx, func(repo models.Repository) error {
qb := repo.Movie()
for _, movieID := range movieIDs {
updatedMovie.ID = movieID
existing, err := qb.Find(movieID)
if err != nil {
return err
}
if existing == nil {
return fmt.Errorf("movie with id %d not found", movieID)
}
movie, err := qb.Update(updatedMovie)
if err != nil {
return err
}
ret = append(ret, movie)
}
return nil
}); err != nil {
return nil, err
}
var newRet []*models.Movie
for _, movie := range ret {
r.hookExecutor.ExecutePostHooks(ctx, movie.ID, plugin.MovieUpdatePost, input, translator.getFields())
movie, err = r.getMovie(ctx, movie.ID)
if err != nil {
return nil, err
}
newRet = append(newRet, movie)
}
return newRet, nil
}
func (r *mutationResolver) MovieDestroy(ctx context.Context, input models.MovieDestroyInput) (bool, error) {
id, err := strconv.Atoi(input.ID)
if err != nil {

View File

@@ -27,3 +27,62 @@ func (r *mutationResolver) StashBoxBatchPerformerTag(ctx context.Context, input
jobID := manager.GetInstance().StashBoxBatchPerformerTag(ctx, input)
return strconv.Itoa(jobID), nil
}
func (r *mutationResolver) SubmitStashBoxSceneDraft(ctx context.Context, input models.StashBoxDraftSubmissionInput) (*string, error) {
boxes := config.GetInstance().GetStashBoxes()
if input.StashBoxIndex < 0 || input.StashBoxIndex >= len(boxes) {
return nil, fmt.Errorf("invalid stash_box_index %d", input.StashBoxIndex)
}
client := stashbox.NewClient(*boxes[input.StashBoxIndex], r.txnManager)
id, err := strconv.Atoi(input.ID)
if err != nil {
return nil, err
}
var res *string
err = r.withReadTxn(ctx, func(repo models.ReaderRepository) error {
qb := repo.Scene()
scene, err := qb.Find(id)
if err != nil {
return err
}
filepath := manager.GetInstance().Paths.Scene.GetScreenshotPath(scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm()))
res, err = client.SubmitSceneDraft(ctx, id, boxes[input.StashBoxIndex].Endpoint, filepath)
return err
})
return res, err
}
func (r *mutationResolver) SubmitStashBoxPerformerDraft(ctx context.Context, input models.StashBoxDraftSubmissionInput) (*string, error) {
boxes := config.GetInstance().GetStashBoxes()
if input.StashBoxIndex < 0 || input.StashBoxIndex >= len(boxes) {
return nil, fmt.Errorf("invalid stash_box_index %d", input.StashBoxIndex)
}
client := stashbox.NewClient(*boxes[input.StashBoxIndex], r.txnManager)
id, err := strconv.Atoi(input.ID)
if err != nil {
return nil, err
}
var res *string
err = r.withReadTxn(ctx, func(repo models.ReaderRepository) error {
qb := repo.Performer()
performer, err := qb.Find(id)
if err != nil {
return err
}
res, err = client.SubmitPerformerDraft(ctx, performer, boxes[input.StashBoxIndex].Endpoint)
return err
})
return res, err
}

View File

@@ -2,9 +2,12 @@ package api
import (
"context"
"fmt"
"strings"
"github.com/stashapp/stash/pkg/manager/config"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/scraper/stashbox"
"github.com/stashapp/stash/pkg/utils"
"golang.org/x/text/collate"
)
@@ -83,7 +86,6 @@ func makeConfigGeneralResult() *models.ConfigGeneralResult {
Username: config.GetUsername(),
Password: config.GetPasswordHash(),
MaxSessionAge: config.GetMaxSessionAge(),
TrustedProxies: config.GetTrustedProxies(),
LogFile: &logFile,
LogOut: config.GetLogOut(),
LogLevel: config.GetLogLevel(),
@@ -107,8 +109,10 @@ func makeConfigInterfaceResult() *models.ConfigInterfaceResult {
menuItems := config.GetMenuItems()
soundOnPreview := config.GetSoundOnPreview()
wallShowTitle := config.GetWallShowTitle()
showScrubber := config.GetShowScrubber()
wallPlayback := config.GetWallPlayback()
noBrowser := config.GetNoBrowser()
notificationsEnabled := config.GetNotificationsEnabled()
maximumLoopDuration := config.GetMaximumLoopDuration()
autostartVideo := config.GetAutostartVideo()
autostartVideoOnPlaySelected := config.GetAutostartVideoOnPlaySelected()
@@ -129,8 +133,10 @@ func makeConfigInterfaceResult() *models.ConfigInterfaceResult {
SoundOnPreview: &soundOnPreview,
WallShowTitle: &wallShowTitle,
WallPlayback: &wallPlayback,
ShowScrubber: &showScrubber,
MaximumLoopDuration: &maximumLoopDuration,
NoBrowser: &noBrowser,
NotificationsEnabled: &notificationsEnabled,
AutostartVideo: &autostartVideo,
ShowStudioAsText: &showStudioAsText,
AutostartVideoOnPlaySelected: &autostartVideoOnPlaySelected,
@@ -188,3 +194,38 @@ func makeConfigDefaultsResult() *models.ConfigDefaultSettingsResult {
DeleteGenerated: &deleteGeneratedDefault,
}
}
func (r *queryResolver) ValidateStashBoxCredentials(ctx context.Context, input models.StashBoxInput) (*models.StashBoxValidationResult, error) {
client := stashbox.NewClient(models.StashBox{Endpoint: input.Endpoint, APIKey: input.APIKey}, r.txnManager)
user, err := client.GetUser(ctx)
valid := user != nil && user.Me != nil
var status string
if valid {
status = fmt.Sprintf("Successfully authenticated as %s", user.Me.Name)
} else {
switch {
case strings.Contains(strings.ToLower(err.Error()), "doctype"):
// Index file returned rather than graphql
status = "Invalid endpoint"
case strings.Contains(err.Error(), "request failed"):
status = "No response from server"
case strings.HasPrefix(err.Error(), "invalid character") ||
strings.HasPrefix(err.Error(), "illegal base64 data") ||
err.Error() == "unexpected end of JSON input" ||
err.Error() == "token contains an invalid number of segments":
status = "Malformed API key."
case err.Error() == "" || err.Error() == "signature is invalid":
status = "Invalid or expired API key."
default:
status = fmt.Sprintf("Unknown error: %s", err)
}
}
result := models.StashBoxValidationResult{
Valid: valid,
Status: status,
}
return &result, nil
}

View File

@@ -23,8 +23,10 @@ import (
"github.com/go-chi/chi"
"github.com/go-chi/chi/middleware"
"github.com/gorilla/websocket"
"github.com/pkg/browser"
"github.com/go-chi/httplog"
"github.com/rs/cors"
"github.com/stashapp/stash/pkg/desktop"
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/manager"
"github.com/stashapp/stash/pkg/manager/config"
@@ -36,7 +38,6 @@ import (
var version string
var buildstamp string
var githash string
var officialBuild string
func Start(uiBox embed.FS, loginUIBox embed.FS) {
initialiseImages()
@@ -52,7 +53,10 @@ func Start(uiBox embed.FS, loginUIBox embed.FS) {
c := config.GetInstance()
if c.GetLogAccess() {
r.Use(middleware.Logger)
httpLogger := httplog.NewLogger("Stash", httplog.Options{
Concise: true,
})
r.Use(httplog.RequestLogger(httpLogger))
}
r.Use(SecurityHeadersMiddleware)
r.Use(middleware.DefaultCompress)
@@ -184,6 +188,7 @@ func Start(uiBox embed.FS, loginUIBox embed.FS) {
}
customUILocation := c.GetCustomUILocation()
static := statigz.FileServer(uiBox)
// Serve the web app
r.HandleFunc("/*", func(w http.ResponseWriter, r *http.Request) {
@@ -222,7 +227,7 @@ func Start(uiBox embed.FS, loginUIBox embed.FS) {
}
r.URL.Path = uiRootDir + r.URL.Path
statigz.FileServer(uiBox).ServeHTTP(w, r)
static.ServeHTTP(w, r)
}
})
@@ -245,25 +250,16 @@ func Start(uiBox embed.FS, loginUIBox embed.FS) {
TLSConfig: tlsConfig,
}
printVersion()
go printLatestVersion(context.TODO())
logger.Infof("stash is listening on " + address)
if tlsConfig != nil {
displayAddress = "https://" + displayAddress + "/"
} else {
displayAddress = "http://" + displayAddress + "/"
}
go func() {
printVersion()
printLatestVersion(context.TODO())
logger.Infof("stash is listening on " + address)
if tlsConfig != nil {
displayAddress = "https://" + displayAddress + "/"
} else {
displayAddress = "http://" + displayAddress + "/"
}
// This can be done before actually starting the server, as modern browsers will
// automatically reload the page if a local port is closed at page load and then opened.
if !c.GetNoBrowser() && manager.GetInstance().IsDesktop() {
err = browser.OpenURL(displayAddress)
if err != nil {
logger.Error("Could not open browser: " + err.Error())
}
}
if tlsConfig != nil {
logger.Infof("stash is running at " + displayAddress)
logger.Error(server.ListenAndServeTLS("", ""))
@@ -271,12 +267,14 @@ func Start(uiBox embed.FS, loginUIBox embed.FS) {
logger.Infof("stash is running at " + displayAddress)
logger.Error(server.ListenAndServe())
}
manager.GetInstance().Shutdown(0)
}()
desktop.Start(manager.GetInstance(), &FaviconProvider{uiBox: uiBox})
}
func printVersion() {
versionString := githash
if IsOfficialBuild() {
if config.IsOfficialBuild() {
versionString += " - Official Build"
} else {
versionString += " - Unofficial Build"
@@ -287,10 +285,6 @@ func printVersion() {
fmt.Printf("stash version: %s - %s\n", versionString, buildstamp)
}
func IsOfficialBuild() bool {
return officialBuild == "true"
}
func GetVersion() (string, string, string) {
return version, githash, buildstamp
}
@@ -364,7 +358,6 @@ func SecurityHeadersMiddleware(next http.Handler) http.Handler {
w.Header().Set("Referrer-Policy", "same-origin")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("X-XSS-Protection", "1")
w.Header().Set("Content-Security-Policy", cspDirectives)
@@ -385,13 +378,7 @@ func BaseURLMiddleware(next http.Handler) http.Handler {
}
prefix := getProxyPrefix(r.Header)
port := ""
forwardedPort := r.Header.Get("X-Forwarded-Port")
if forwardedPort != "" && forwardedPort != "80" && forwardedPort != "8080" && forwardedPort != "443" && !strings.Contains(r.Host, ":") {
port = ":" + forwardedPort
}
baseURL := scheme + "://" + r.Host + port + prefix
baseURL := scheme + "://" + r.Host + prefix
externalHost := config.GetInstance().GetExternalHost()
if externalHost != "" {

170
pkg/desktop/desktop.go Normal file
View 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
}

View 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)
}

View 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) {
}

View 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)
}

Binary file not shown.

View 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.
}

View 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)
}
}
}()
}

View File

@@ -13,6 +13,7 @@ import (
"runtime"
"strings"
"github.com/stashapp/stash/pkg/desktop"
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/utils"
)
@@ -202,7 +203,9 @@ func pathBinaryHasCorrectFlags() bool {
if err != nil {
return false
}
bytes, _ := exec.Command(ffmpegPath).CombinedOutput()
cmd := exec.Command(ffmpegPath)
desktop.HideExecShell(cmd)
bytes, _ := cmd.CombinedOutput()
output := string(bytes)
hasOpus := strings.Contains(output, "--enable-libopus")
hasVpx := strings.Contains(output, "--enable-libvpx")

View File

@@ -9,6 +9,7 @@ import (
"sync"
"time"
"github.com/stashapp/stash/pkg/desktop"
"github.com/stashapp/stash/pkg/logger"
)
@@ -90,6 +91,7 @@ func (e *Encoder) runTranscode(probeResult VideoFile, args []string) (string, er
logger.Error("FFMPEG stdout not available: " + err.Error())
}
desktop.HideExecShell(cmd)
if err = cmd.Start(); err != nil {
return "", err
}
@@ -141,6 +143,7 @@ func (e *Encoder) run(sourcePath string, args []string, stdin io.Reader) (string
cmd.Stderr = &stderr
cmd.Stdin = stdin
desktop.HideExecShell(cmd)
if err := cmd.Start(); err != nil {
return "", err
}

View File

@@ -8,6 +8,7 @@ import (
type SpriteScreenshotOptions struct {
Time float64
Frame int
Width int
}
@@ -36,3 +37,31 @@ func (e *Encoder) SpriteScreenshot(probeResult VideoFile, options SpriteScreensh
return img, err
}
// SpriteScreenshotSlow uses the select filter to get a single frame from a videofile instead of seeking
// It is very slow and should only be used for files with very small duration in secs / frame count
func (e *Encoder) SpriteScreenshotSlow(probeResult VideoFile, options SpriteScreenshotOptions) (image.Image, error) {
args := []string{
"-v", "error",
"-i", probeResult.Path,
"-vsync", "0", // do not create/drop frames
"-vframes", "1",
"-vf", fmt.Sprintf("select=eq(n\\,%d),scale=%v:-1", options.Frame, options.Width), // keep only frame number options.Frame
"-c:v", "bmp",
"-f", "rawvideo",
"-",
}
data, err := e.run(probeResult.Path, args, nil)
if err != nil {
return nil, err
}
reader := strings.NewReader(data)
img, _, err := image.Decode(reader)
if err != nil {
return nil, err
}
return img, err
}

View File

@@ -11,6 +11,7 @@ import (
"strings"
"time"
"github.com/stashapp/stash/pkg/desktop"
"github.com/stashapp/stash/pkg/logger"
)
@@ -218,6 +219,7 @@ type VideoFile struct {
Height int
FrameRate float64
Rotation int64
FrameCount int64
AudioCodec string
}
@@ -228,7 +230,9 @@ type FFProbe string
// Execute exec command and bind result to struct.
func (f *FFProbe) NewVideoFile(videoPath string, stripExt bool) (*VideoFile, error) {
args := []string{"-v", "quiet", "-print_format", "json", "-show_format", "-show_streams", "-show_error", videoPath}
out, err := exec.Command(string(*f), args...).Output()
cmd := exec.Command(string(*f), args...)
desktop.HideExecShell(cmd)
out, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("FFProbe encountered an error with <%s>.\nError JSON:\n%s\nError: %s", videoPath, string(out), err.Error())
@@ -242,6 +246,24 @@ func (f *FFProbe) NewVideoFile(videoPath string, stripExt bool) (*VideoFile, err
return parse(videoPath, probeJSON, stripExt)
}
// GetReadFrameCount counts the actual frames of the video file
func (f *FFProbe) GetReadFrameCount(vf *VideoFile) (int64, error) {
args := []string{"-v", "quiet", "-print_format", "json", "-count_frames", "-show_format", "-show_streams", "-show_error", vf.Path}
out, err := exec.Command(string(*f), args...).Output()
if err != nil {
return 0, fmt.Errorf("FFProbe encountered an error with <%s>.\nError JSON:\n%s\nError: %s", vf.Path, string(out), err.Error())
}
probeJSON := &FFProbeJSON{}
if err := json.Unmarshal(out, probeJSON); err != nil {
return 0, fmt.Errorf("error unmarshalling video data for <%s>: %s", vf.Path, err.Error())
}
fc, err := parse(vf.Path, probeJSON, false)
return fc.FrameCount, err
}
func parse(filePath string, probeJSON *FFProbeJSON, stripExt bool) (*VideoFile, error) {
if probeJSON == nil {
return nil, fmt.Errorf("failed to get ffprobe json for <%s>", filePath)
@@ -263,8 +285,8 @@ func parse(filePath string, probeJSON *FFProbeJSON, stripExt bool) (*VideoFile,
}
result.Comment = probeJSON.Format.Tags.Comment
result.Bitrate, _ = strconv.ParseInt(probeJSON.Format.BitRate, 10, 64)
result.Container = probeJSON.Format.FormatName
duration, _ := strconv.ParseFloat(probeJSON.Format.Duration, 64)
result.Duration = math.Round(duration*100) / 100
@@ -288,6 +310,15 @@ func parse(filePath string, probeJSON *FFProbeJSON, stripExt bool) (*VideoFile,
if videoStream != nil {
result.VideoStream = videoStream
result.VideoCodec = videoStream.CodecName
result.FrameCount, _ = strconv.ParseInt(videoStream.NbFrames, 10, 64)
if videoStream.NbReadFrames != "" { // if ffprobe counted the frames use that instead
fc, _ := strconv.ParseInt(videoStream.NbReadFrames, 10, 64)
if fc > 0 {
result.FrameCount, _ = strconv.ParseInt(videoStream.NbReadFrames, 10, 64)
} else {
logger.Debugf("[ffprobe] <%s> invalid Read Frames count", videoStream.NbReadFrames)
}
}
result.VideoBitrate, _ = strconv.ParseInt(videoStream.BitRate, 10, 64)
var framerate float64
if strings.Contains(videoStream.AvgFrameRate, "/") {

View File

@@ -8,6 +8,7 @@ import (
"strconv"
"strings"
"github.com/stashapp/stash/pkg/desktop"
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/models"
)
@@ -220,6 +221,7 @@ func (e *Encoder) stream(probeResult VideoFile, options TranscodeStreamOptions)
return nil, err
}
desktop.HideExecShell(cmd)
if err = cmd.Start(); err != nil {
return nil, err
}

View File

@@ -70,6 +70,7 @@ type FFProbeStream struct {
Level int `json:"level,omitempty"`
NalLengthSize string `json:"nal_length_size,omitempty"`
NbFrames string `json:"nb_frames"`
NbReadFrames string `json:"nb_read_frames"`
PixFmt string `json:"pix_fmt,omitempty"`
Profile string `json:"profile"`
RFrameRate string `json:"r_frame_rate"`

View File

@@ -4,6 +4,7 @@ import (
"fmt"
"io"
"io/fs"
"os"
"strconv"
"time"
@@ -117,6 +118,19 @@ func (o Scanner) generateHashes(f *models.File, file SourceFile, regenerate bool
if o.CalculateOSHash && (regenerate || f.OSHash == "") {
logger.Infof("Calculating oshash for %s ...", f.Path)
size := file.FileInfo().Size()
// #2196 for symlinks
// get the size of the actual file, not the symlink
if file.FileInfo().Mode()&os.ModeSymlink == os.ModeSymlink {
fi, err := os.Stat(f.Path)
if err != nil {
return false, err
}
logger.Debugf("File <%s> is symlink. Size changed from <%d> to <%d>", f.Path, size, fi.Size())
size = fi.Size()
}
src, err = file.Open()
if err != nil {
return false, err
@@ -130,7 +144,7 @@ func (o Scanner) generateHashes(f *models.File, file SourceFile, regenerate bool
// regenerate hash
var oshash string
oshash, err = o.Hasher.OSHash(seekSrc, file.FileInfo().Size())
oshash, err = o.Hasher.OSHash(seekSrc, size)
if err != nil {
return false, fmt.Errorf("error generating oshash for %s: %w", file.Path(), err)
}

View File

@@ -40,7 +40,7 @@ func (t *SceneIdentifier) Identify(ctx context.Context, txnManager models.Transa
}
if result == nil {
logger.Infof("Unable to identify %s", scene.Path)
logger.Debugf("Unable to identify %s", scene.Path)
return nil
}
@@ -176,7 +176,7 @@ func (t *SceneIdentifier) modifyScene(ctx context.Context, txnManager models.Tra
// don't update anything if nothing was set
if updater.IsEmpty() {
logger.Infof("Nothing to set for %s", s.Path)
logger.Debugf("Nothing to set for %s", s.Path)
return nil
}

View File

@@ -6,6 +6,7 @@ import (
"os/exec"
"strings"
"github.com/stashapp/stash/pkg/desktop"
"github.com/stashapp/stash/pkg/logger"
)
@@ -32,6 +33,7 @@ func (e *vipsEncoder) run(args []string, stdin *bytes.Buffer) (string, error) {
cmd.Stderr = &stderr
cmd.Stdin = stdin
desktop.HideExecShell(cmd)
if err := cmd.Start(); err != nil {
return "", err
}

View File

@@ -2,9 +2,13 @@ package job
import (
"context"
"fmt"
"strconv"
"strings"
"sync"
"time"
"github.com/stashapp/stash/pkg/desktop"
"github.com/stashapp/stash/pkg/utils"
)
@@ -204,9 +208,14 @@ func (m *Manager) onJobFinish(job *Job) {
} else {
job.Status = StatusFinished
}
t := time.Now()
job.EndTime = &t
cleanDesc := strings.TrimRight(job.Description, ".")
timeElapsed := job.EndTime.Sub(*job.StartTime)
hours := fmt.Sprintf("%+02s", strconv.FormatFloat(timeElapsed.Hours(), 'f', 0, 64))
minutes := fmt.Sprintf("%+02s", strconv.FormatFloat(timeElapsed.Minutes(), 'f', 0, 64))
seconds := fmt.Sprintf("%+02s", strconv.FormatFloat(timeElapsed.Seconds(), 'f', 0, 64))
desktop.SendNotification("Task Finished", "Task \""+cleanDesc+"\" is finished in "+hours+":"+minutes+":"+seconds+".")
}
func (m *Manager) removeJob(job *Job) {

View File

@@ -32,6 +32,7 @@ func Init(logFile string, logOut bool, logLevel string) {
customFormatter.TimestampFormat = "2006-01-02 15:04:05"
customFormatter.ForceColors = true
customFormatter.FullTimestamp = true
logger.SetOutput(os.Stderr)
logger.SetFormatter(customFormatter)
// #1837 - trigger the console to use color-mode since it won't be

View File

@@ -21,6 +21,8 @@ import (
"github.com/stashapp/stash/pkg/utils"
)
var officialBuild string
const (
Stash = "stash"
Cache = "cache"
@@ -133,6 +135,9 @@ const (
ShowStudioAsText = "show_studio_as_text"
CSSEnabled = "cssEnabled"
ShowScrubber = "show_scrubber"
showScrubberDefault = true
WallPlayback = "wall_playback"
defaultWallPlayback = "video"
@@ -147,7 +152,6 @@ const (
FunscriptOffset = "funscript_offset"
// Security
TrustedProxies = "trusted_proxies"
dangerousAllowPublicWithoutAuth = "dangerous_allow_public_without_auth"
dangerousAllowPublicWithoutAuthDefault = "false"
SecurityTripwireAccessedFromPublicInternet = "security_tripwire_accessed_from_public_internet"
@@ -179,8 +183,12 @@ const (
deleteGeneratedDefaultDefault = true
// Desktop Integration Options
NoBrowser = "noBrowser"
NoBrowserDefault = false
NoBrowser = "noBrowser"
NoBrowserDefault = false
NotificationsEnabled = "notifications_enabled"
NotificationsEnabledDefault = true
ShowOneTimeMovedNotification = "show_one_time_moved_notification"
ShowOneTimeMovedNotificationDefault = false
// File upload options
MaxUploadSize = "max_upload_size"
@@ -212,6 +220,10 @@ func (s *StashBoxError) Error() string {
return "Stash-box: " + s.msg
}
func IsOfficialBuild() bool {
return officialBuild == "true"
}
type Instance struct {
// main instance - backed by config file
main *viper.Viper
@@ -222,8 +234,9 @@ type Instance struct {
cpuProfilePath string
isNewSystem bool
certFile string
keyFile string
// configUpdates chan int
certFile string
keyFile string
sync.RWMutex
// deadlock.RWMutex // for deadlock testing/issues
}
@@ -271,7 +284,25 @@ func (i *Instance) GetNoBrowser() bool {
return i.getBool(NoBrowser)
}
func (i *Instance) GetNotificationsEnabled() bool {
return i.getBool(NotificationsEnabled)
}
// func (i *Instance) GetConfigUpdatesChannel() chan int {
// return i.configUpdates
// }
// GetShowOneTimeMovedNotification shows whether a small notification to inform the user that Stash
// will no longer show a terminal window, and instead will be available in the tray, should be shown.
// It is true when an existing system is started after upgrading, and set to false forever after it is shown.
func (i *Instance) GetShowOneTimeMovedNotification() bool {
return i.getBool(ShowOneTimeMovedNotification)
}
func (i *Instance) Set(key string, value interface{}) {
// if key == MenuItems {
// i.configUpdates <- 0
// }
i.Lock()
defer i.Unlock()
i.main.Set(key, value)
@@ -367,6 +398,18 @@ func (i *Instance) getBool(key string) bool {
return i.viper(key).GetBool(key)
}
func (i *Instance) getBoolDefault(key string, def bool) bool {
i.RLock()
defer i.RUnlock()
ret := def
v := i.viper(key)
if v.IsSet(key) {
ret = v.GetBool(key)
}
return ret
}
func (i *Instance) getInt(key string) int {
i.RLock()
defer i.RUnlock()
@@ -531,16 +574,7 @@ func (i *Instance) GetScraperCDPPath() string {
// GetScraperCertCheck returns true if the scraper should check for insecure
// certificates when fetching an image or a page.
func (i *Instance) GetScraperCertCheck() bool {
ret := true
i.RLock()
defer i.RUnlock()
v := i.viper(ScraperCertCheck)
if v.IsSet(ScraperCertCheck) {
ret = v.GetBool(ScraperCertCheck)
}
return ret
return i.getBoolDefault(ScraperCertCheck, true)
}
func (i *Instance) GetScraperExcludeTagPatterns() []string {
@@ -820,6 +854,10 @@ func (i *Instance) GetWallPlayback() string {
return ret
}
func (i *Instance) GetShowScrubber() bool {
return i.getBoolDefault(ShowScrubber, showScrubberDefault)
}
func (i *Instance) GetMaximumLoopDuration() int {
return i.getInt(MaximumLoopDuration)
}
@@ -829,16 +867,7 @@ func (i *Instance) GetAutostartVideo() bool {
}
func (i *Instance) GetAutostartVideoOnPlaySelected() bool {
i.Lock()
defer i.Unlock()
ret := autostartVideoOnPlaySelectedDefault
v := i.viper(AutostartVideoOnPlaySelected)
if v.IsSet(AutostartVideoOnPlaySelected) {
ret = v.GetBool(AutostartVideoOnPlaySelected)
}
return ret
return i.getBoolDefault(AutostartVideoOnPlaySelected, autostartVideoOnPlaySelectedDefault)
}
func (i *Instance) GetContinuePlaylistDefault() bool {
@@ -926,16 +955,7 @@ func (i *Instance) GetDeleteFileDefault() bool {
}
func (i *Instance) GetDeleteGeneratedDefault() bool {
i.RLock()
defer i.RUnlock()
ret := deleteGeneratedDefaultDefault
v := i.viper(DeleteGeneratedDefault)
if v.IsSet(DeleteGeneratedDefault) {
ret = v.GetBool(DeleteGeneratedDefault)
}
return ret
return i.getBoolDefault(DeleteGeneratedDefault, deleteGeneratedDefaultDefault)
}
// GetDefaultIdentifySettings returns the default Identify task settings.
@@ -1014,12 +1034,6 @@ func (i *Instance) GetDefaultGenerateSettings() *models.GenerateMetadataOptions
return nil
}
// GetTrustedProxies returns a comma separated list of ip addresses that should allow proxying.
// When empty, allow from any private network
func (i *Instance) GetTrustedProxies() []string {
return i.getStringSlice(TrustedProxies)
}
// GetDangerousAllowPublicWithoutAuth determines if the security feature is enabled.
// See https://github.com/stashapp/stash/wiki/Authentication-Required-When-Accessing-Stash-From-the-Internet
func (i *Instance) GetDangerousAllowPublicWithoutAuth() bool {
@@ -1066,17 +1080,7 @@ func (i *Instance) GetLogFile() string {
// in addition to writing to a log file. Logging will be output to the
// terminal if file logging is disabled. Defaults to true.
func (i *Instance) GetLogOut() bool {
i.RLock()
defer i.RUnlock()
ret := defaultLogOut
v := i.viper(LogOut)
if v.IsSet(LogOut) {
ret = v.GetBool(LogOut)
}
return ret
return i.getBoolDefault(LogOut, defaultLogOut)
}
// GetLogLevel returns the lowest log level to write to the log.
@@ -1093,16 +1097,7 @@ func (i *Instance) GetLogLevel() string {
// GetLogAccess returns true if http requests should be logged to the terminal.
// HTTP requests are not logged to the log file. Defaults to true.
func (i *Instance) GetLogAccess() bool {
i.RLock()
defer i.RUnlock()
ret := defaultLogAccess
v := i.viper(LogAccess)
if v.IsSet(LogAccess) {
ret = v.GetBool(LogAccess)
}
return ret
return i.getBoolDefault(LogAccess, defaultLogAccess)
}
// Max allowed graphql upload size in megabytes
@@ -1191,6 +1186,8 @@ func (i *Instance) setDefaultValues(write bool) error {
i.main.SetDefault(Generated, i.main.GetString(Metadata))
i.main.SetDefault(NoBrowser, NoBrowserDefault)
i.main.SetDefault(NotificationsEnabled, NotificationsEnabledDefault)
i.main.SetDefault(ShowOneTimeMovedNotification, ShowOneTimeMovedNotificationDefault)
// Set default scrapers and plugins paths
i.main.SetDefault(ScrapersPath, defaultScrapersPath)
@@ -1217,6 +1214,12 @@ func (i *Instance) setExistingSystemDefaults() error {
i.main.Set(NoBrowser, true)
}
// Existing systems as of the introduction of the taskbar should inform users.
if !i.main.InConfig(ShowOneTimeMovedNotification) {
configDirtied = true
i.main.Set(ShowOneTimeMovedNotification, true)
}
if configDirtied {
return i.main.WriteConfig()
}
@@ -1254,4 +1257,5 @@ func (i *Instance) setInitialConfig(write bool) error {
func (i *Instance) FinalizeSetup() {
i.isNewSystem = false
// i.configUpdates <- 0
}

View File

@@ -99,7 +99,6 @@ func TestConcurrentConfigAccess(t *testing.T) {
i.Set(DefaultIdentifySettings, i.GetDefaultIdentifySettings())
i.Set(DeleteGeneratedDefault, i.GetDeleteGeneratedDefault())
i.Set(DeleteFileDefault, i.GetDeleteFileDefault())
i.Set(TrustedProxies, i.GetTrustedProxies())
i.Set(dangerousAllowPublicWithoutAuth, i.GetDangerousAllowPublicWithoutAuth())
i.Set(SecurityTripwireAccessedFromPublicInternet, i.GetSecurityTripwireAccessedFromPublicInternet())
i.Set(DisableDropdownCreatePerformer, i.GetDisableDropdownCreate().Performer)

View File

@@ -45,6 +45,7 @@ func Initialize() (*Instance, error) {
_ = GetInstance()
instance.overrides = overrides
instance.cpuProfilePath = flags.cpuProfilePath
// instance.configUpdates = make(chan int)
if err = initConfig(instance, flags); err != nil {
return

View File

@@ -38,7 +38,7 @@ func excludeFiles(files []string, patterns []string) ([]string, int) {
func matchFileRegex(file string, fileRegexps []*regexp.Regexp) bool {
for _, regPattern := range fileRegexps {
if regPattern.MatchString(strings.ToLower(file)) {
if regPattern.MatchString(file) {
return true
}
}
@@ -60,7 +60,10 @@ func generateRegexps(patterns []string) []*regexp.Regexp {
var fileRegexps []*regexp.Regexp
for _, pattern := range patterns {
reg, err := regexp.Compile(strings.ToLower(pattern))
if !strings.HasPrefix(pattern, "(?i)") {
pattern = "(?i)" + pattern
}
reg, err := regexp.Compile(pattern)
if err != nil {
logger.Errorf("Exclude :%v", err)
} else {
@@ -78,7 +81,7 @@ func generateRegexps(patterns []string) []*regexp.Regexp {
func matchFileSimple(file string, regExps []*regexp.Regexp) bool {
for _, regPattern := range regExps {
if regPattern.MatchString(strings.ToLower(file)) {
if regPattern.MatchString(file) {
return true
}
}

View File

@@ -26,7 +26,9 @@ var excludeTestFilenames = []string{
"\\\\network\\videos\\filename windows network.mp4",
"\\\\network\\share\\windows network wanted.mp4",
"\\\\network\\share\\windows network wanted sample.mp4",
"\\\\network\\private\\windows.network.skip.mp4"}
"\\\\network\\private\\windows.network.skip.mp4",
"/stash/videos/a5.mp4",
"/stash/videos/mIxEdCaSe.mp4"}
var excludeTests = []struct {
testPattern []string
@@ -42,6 +44,10 @@ var excludeTests = []struct {
{[]string{"^\\\\\\\\network"}, 4}, // windows net share
{[]string{"\\\\private\\\\"}, 1}, // windows net share
{[]string{"\\\\private\\\\", "sample\\.mp4"}, 3}, // windows net share
{[]string{"\\D\\d\\.mp4"}, 1}, // validates that \D doesn't get converted to lowercase \d
{[]string{"mixedcase\\.mp4"}, 1}, // validates we can match the mixed case file
{[]string{"MIXEDCASE\\.mp4"}, 1}, // validates we can match the mixed case file
{[]string{"(?i)MIXEDCASE\\.mp4"}, 1}, // validates we can match the mixed case file without adding another (?i) to it
}
func TestExcludeFiles(t *testing.T) {

View File

@@ -9,6 +9,7 @@ import (
"strconv"
"strings"
"github.com/stashapp/stash/pkg/desktop"
"github.com/stashapp/stash/pkg/ffmpeg"
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/utils"
@@ -72,6 +73,7 @@ func (g *GeneratorInfo) calculateFrameRate(videoStream *ffmpeg.FFProbeStream) er
}
command := exec.Command(string(instance.FFMPEG), args...)
desktop.HideExecShell(command)
var stdErrBuffer bytes.Buffer
command.Stderr = &stdErrBuffer // Frames go to stderr rather than stdout
if err := command.Run(); err == nil {
@@ -112,6 +114,12 @@ func (g *GeneratorInfo) configure() error {
return err
}
// #2250 - ensure ChunkCount is valid
if g.ChunkCount < 1 {
logger.Warnf("[generator] Segment count (%d) must be > 0. Using 1 instead.", g.ChunkCount)
g.ChunkCount = 1
}
g.NthFrame = g.NumberOfFrames / g.ChunkCount
return nil

View File

@@ -25,6 +25,7 @@ type SpriteGenerator struct {
VTTOutputPath string
Rows int
Columns int
SlowSeek bool // use alternate seek function, very slow!
Overwrite bool
}
@@ -34,17 +35,33 @@ func NewSpriteGenerator(videoFile ffmpeg.VideoFile, videoChecksum string, imageO
if !exists {
return nil, err
}
slowSeek := false
chunkCount := rows * cols
// FFMPEG bombs out if we try to request 89 snapshots from a 2 second video
if videoFile.Duration < 3 {
return nil, errors.New("video too short to create sprite")
// For files with small duration / low frame count try to seek using frame number intead of seconds
if videoFile.Duration < 5 || (0 < videoFile.FrameCount && videoFile.FrameCount <= int64(chunkCount)) { // some files can have FrameCount == 0, only use SlowSeek if duration < 5
if videoFile.Duration <= 0 {
s := fmt.Sprintf("video %s: duration(%.3f)/frame count(%d) invalid, skipping sprite creation", videoFile.Path, videoFile.Duration, videoFile.FrameCount)
return nil, errors.New(s)
}
logger.Warnf("[generator] video %s too short (%.3fs, %d frames), using frame seeking", videoFile.Path, videoFile.Duration, videoFile.FrameCount)
slowSeek = true
// do an actual frame count of the file ( number of frames = read frames)
ffprobe := GetInstance().FFProbe
fc, err := ffprobe.GetReadFrameCount(&videoFile)
if err == nil {
if fc != videoFile.FrameCount {
logger.Warnf("[generator] updating framecount (%d) for %s with read frames count (%d)", videoFile.FrameCount, videoFile.Path, fc)
videoFile.FrameCount = fc
}
}
}
generator, err := newGeneratorInfo(videoFile)
if err != nil {
return nil, err
}
generator.ChunkCount = rows * cols
generator.ChunkCount = chunkCount
if err := generator.configure(); err != nil {
return nil, err
}
@@ -55,6 +72,7 @@ func NewSpriteGenerator(videoFile ffmpeg.VideoFile, videoChecksum string, imageO
ImageOutputPath: imageOutputPath,
VTTOutputPath: vttOutputPath,
Rows: rows,
SlowSeek: slowSeek,
Columns: cols,
}, nil
}
@@ -75,23 +93,51 @@ func (g *SpriteGenerator) generateSpriteImage(encoder *ffmpeg.Encoder) error {
if !g.Overwrite && g.imageExists() {
return nil
}
logger.Infof("[generator] generating sprite image for %s", g.Info.VideoFile.Path)
// Create `this.chunkCount` thumbnails in the tmp directory
stepSize := g.Info.VideoFile.Duration / float64(g.Info.ChunkCount)
var images []image.Image
for i := 0; i < g.Info.ChunkCount; i++ {
time := float64(i) * stepSize
options := ffmpeg.SpriteScreenshotOptions{
Time: time,
Width: 160,
if !g.SlowSeek {
logger.Infof("[generator] generating sprite image for %s", g.Info.VideoFile.Path)
// generate `ChunkCount` thumbnails
stepSize := g.Info.VideoFile.Duration / float64(g.Info.ChunkCount)
for i := 0; i < g.Info.ChunkCount; i++ {
time := float64(i) * stepSize
options := ffmpeg.SpriteScreenshotOptions{
Time: time,
Width: 160,
}
img, err := encoder.SpriteScreenshot(g.Info.VideoFile, options)
if err != nil {
return err
}
images = append(images, img)
}
img, err := encoder.SpriteScreenshot(g.Info.VideoFile, options)
if err != nil {
return err
} else {
logger.Infof("[generator] generating sprite image for %s (%d frames)", g.Info.VideoFile.Path, g.Info.VideoFile.FrameCount)
stepFrame := float64(g.Info.VideoFile.FrameCount-1) / float64(g.Info.ChunkCount)
for i := 0; i < g.Info.ChunkCount; i++ {
// generate exactly `ChunkCount` thumbnails, using duplicate frames if needed
frame := math.Round(float64(i) * stepFrame)
if frame >= math.MaxInt || frame <= math.MinInt {
return errors.New("invalid frame number conversion")
}
options := ffmpeg.SpriteScreenshotOptions{
Frame: int(frame),
Width: 160,
}
img, err := encoder.SpriteScreenshotSlow(g.Info.VideoFile, options)
if err != nil {
return err
}
images = append(images, img)
}
images = append(images, img)
}
if len(images) == 0 {
@@ -132,7 +178,15 @@ func (g *SpriteGenerator) generateSpriteVTT(encoder *ffmpeg.Encoder) error {
width := image.Width / g.Columns
height := image.Height / g.Rows
stepSize := float64(g.Info.NthFrame) / g.Info.FrameRate
var stepSize float64
if !g.SlowSeek {
stepSize = float64(g.Info.NthFrame) / g.Info.FrameRate
} else {
// for files with a low framecount (<ChunkCount) g.Info.NthFrame can be zero
// so recalculate from scratch
stepSize = float64(g.Info.VideoFile.FrameCount-1) / float64(g.Info.ChunkCount)
stepSize /= g.Info.FrameRate
}
vttLines := []string{"WEBVTT", ""}
for index := 0; index < g.Info.ChunkCount; index++ {

View File

@@ -2,6 +2,8 @@ package manager
import (
"archive/zip"
"github.com/stashapp/stash/pkg/file"
"github.com/stashapp/stash/pkg/manager/config"
"strings"
"github.com/stashapp/stash/pkg/logger"
@@ -14,20 +16,26 @@ func walkGalleryZip(path string, walkFunc func(file *zip.File) error) error {
}
defer readCloser.Close()
for _, file := range readCloser.File {
if file.FileInfo().IsDir() {
excludeImgRegex := generateRegexps(config.GetInstance().GetImageExcludes())
for _, f := range readCloser.File {
if f.FileInfo().IsDir() {
continue
}
if strings.Contains(file.Name, "__MACOSX") {
if strings.Contains(f.Name, "__MACOSX") {
continue
}
if !isImage(file.Name) {
if !isImage(f.Name) {
continue
}
err := walkFunc(file)
if matchFileRegex(file.ZipFile(path, f).Path(), excludeImgRegex) {
continue
}
err := walkFunc(f)
if err != nil {
return err
}

View File

@@ -4,12 +4,9 @@ import (
"context"
"errors"
"fmt"
"io/ioutil"
"os"
"path/filepath"
"runtime"
"runtime/pprof"
"strings"
"sync"
"time"
@@ -407,34 +404,6 @@ func (s *singleton) Migrate(ctx context.Context, input models.MigrateInput) erro
return nil
}
func (s *singleton) IsDesktop() bool {
// check if running under root
if os.Getuid() == 0 {
return false
}
// check if started by init, e.g. stash is a *nix systemd service / MacOS launchd service
if os.Getppid() == 1 {
return false
}
if IsServerDockerized() {
return false
}
return true
}
func IsServerDockerized() bool {
if runtime.GOOS == "linux" {
_, dockerEnvErr := os.Stat("/.dockerenv")
cgroups, _ := ioutil.ReadFile("/proc/self/cgroup")
if os.IsExist(dockerEnvErr) || strings.Contains(string(cgroups), "docker") {
return true
}
}
return false
}
func (s *singleton) GetSystemStatus() *models.SystemStatus {
status := models.SystemStatusEnumOk
dbSchema := int(database.Version())
@@ -458,8 +427,15 @@ func (s *singleton) GetSystemStatus() *models.SystemStatus {
}
// Shutdown gracefully stops the manager
func (s *singleton) Shutdown() error {
func (s *singleton) Shutdown(code int) {
// TODO: Each part of the manager needs to gracefully stop at some point
// for now, we just close the database.
return database.Close()
err := database.Close()
if err != nil {
logger.Errorf("Error closing database: %s", err)
if code == 0 {
os.Exit(1)
}
}
os.Exit(code)
}

View File

@@ -135,6 +135,8 @@ func (t *StashBoxPerformerTagTask) stashBoxPerformerTag(ctx context.Context) {
if excluded["name"] && performer.Name != nil {
value := sql.NullString{String: *performer.Name, Valid: true}
partial.Name = &value
checksum := utils.MD5FromString(*performer.Name)
partial.Checksum = &checksum
}
if performer.Piercings != nil && !excluded["piercings"] {
value := getNullString(performer.Piercings)
@@ -145,7 +147,7 @@ func (t *StashBoxPerformerTagTask) stashBoxPerformerTag(ctx context.Context) {
partial.Tattoos = &value
}
if performer.Twitter != nil && !excluded["twitter"] {
value := getNullString(performer.Tattoos)
value := getNullString(performer.Twitter)
partial.Twitter = &value
}
if performer.URL != nil && !excluded["url"] {
@@ -261,7 +263,7 @@ func getDate(val *string) models.SQLiteDate {
if val == nil {
return models.SQLiteDate{Valid: false}
} else {
return models.SQLiteDate{String: *val, Valid: false}
return models.SQLiteDate{String: *val, Valid: true}
}
}

View File

@@ -5,6 +5,7 @@ import (
"path/filepath"
"regexp"
"strings"
"unicode"
"github.com/stashapp/stash/pkg/gallery"
"github.com/stashapp/stash/pkg/image"
@@ -12,7 +13,12 @@ import (
"github.com/stashapp/stash/pkg/scene"
)
const separatorChars = `.\-_ `
const (
separatorChars = `.\-_ `
reNotLetterWordUnicode = `[^\p{L}\w\d]`
reNotLetterWord = `[^\w\d]`
)
func getPathQueryRegex(name string) string {
// escape specific regex characters
@@ -22,6 +28,13 @@ func getPathQueryRegex(name string) string {
const separator = `[` + separatorChars + `]`
ret := strings.ReplaceAll(name, " ", separator+"*")
// \p{L} is specifically omitted here because of the performance hit when
// including it. It does mean that paths where the name is bounded by
// unicode letters will be returned. However, the results should be tested
// by nameMatchesPath which does include \p{L}. The improvement in query
// performance should be outweigh the performance hit of testing any extra
// results.
ret = `(?:^|_|[^\w\d])` + ret + `(?:$|_|[^\w\d])`
return ret
}
@@ -36,7 +49,7 @@ func getPathWords(path string) []string {
}
// handle path separators
const separator = `(?:_|[^\w\d])+`
const separator = `(?:_|[^\p{L}\w\d])+`
re := regexp.MustCompile(separator)
retStr = re.ReplaceAllString(retStr, " ")
@@ -52,29 +65,31 @@ func getPathWords(path string) []string {
// we post-match afterwards, so we can afford to be a little loose
// with the query
// just use the first two characters
ret = append(ret, w[0:2])
// #2293 - need to convert to unicode runes for the substring, otherwise
// the resulting string is corrupted.
ret = append(ret, string([]rune(w)[0:2]))
}
}
return ret
}
// https://stackoverflow.com/a/53069799
func allASCII(s string) bool {
for i := 0; i < len(s); i++ {
if s[i] > unicode.MaxASCII {
return false
}
}
return true
}
// nameMatchesPath returns the index in the path for the right-most match.
// Returns -1 if not found.
func nameMatchesPath(name, path string) int {
// escape specific regex characters
name = regexp.QuoteMeta(name)
name = strings.ToLower(name)
path = strings.ToLower(path)
// handle path separators
const separator = `[` + separatorChars + `]`
reStr := strings.ReplaceAll(name, " ", separator+"*")
reStr = `(?:^|_|[^\w\d])` + reStr + `(?:$|_|[^\w\d])`
re := regexp.MustCompile(reStr)
// #2363 - optimisation: only use unicode character regexp if path contains
// unicode characters
re := nameToRegexp(name, !allASCII(path))
found := re.FindAllStringIndex(path, -1)
if found == nil {
@@ -84,6 +99,39 @@ func nameMatchesPath(name, path string) int {
return found[len(found)-1][0]
}
// nameToRegexp compiles a regexp pattern to match paths from the given name.
// Set useUnicode to true if this regexp is to be used on any strings with unicode characters.
func nameToRegexp(name string, useUnicode bool) *regexp.Regexp {
// escape specific regex characters
name = regexp.QuoteMeta(name)
name = strings.ToLower(name)
// handle path separators
const separator = `[` + separatorChars + `]`
// performance optimisation: only use \p{L} is useUnicode is true
notWord := reNotLetterWord
if useUnicode {
notWord = reNotLetterWordUnicode
}
reStr := strings.ReplaceAll(name, " ", separator+"*")
reStr = `(?:^|_|` + notWord + `)` + reStr + `(?:$|_|` + notWord + `)`
re := regexp.MustCompile(reStr)
return re
}
func regexpMatchesPath(r *regexp.Regexp, path string) int {
path = strings.ToLower(path)
found := r.FindAllStringIndex(path, -1)
if found == nil {
return -1
}
return found[len(found)-1][0]
}
func PathToPerformers(path string, performerReader models.PerformerReader) ([]*models.Performer, error) {
words := getPathWords(path)
performers, err := performerReader.QueryForAutoTag(words)
@@ -199,8 +247,13 @@ func PathToScenes(name string, paths []string, sceneReader models.SceneReader) (
}
var ret []*models.Scene
// paths may have unicode characters
const useUnicode = true
r := nameToRegexp(name, useUnicode)
for _, p := range scenes {
if nameMatchesPath(name, p.Path) != -1 {
if regexpMatchesPath(r, p.Path) != -1 {
ret = append(ret, p)
}
}
@@ -231,8 +284,13 @@ func PathToImages(name string, paths []string, imageReader models.ImageReader) (
}
var ret []*models.Image
// paths may have unicode characters
const useUnicode = true
r := nameToRegexp(name, useUnicode)
for _, p := range images {
if nameMatchesPath(name, p.Path) != -1 {
if regexpMatchesPath(r, p.Path) != -1 {
ret = append(ret, p)
}
}
@@ -263,8 +321,13 @@ func PathToGalleries(name string, paths []string, galleryReader models.GalleryRe
}
var ret []*models.Gallery
// paths may have unicode characters
const useUnicode = true
r := nameToRegexp(name, useUnicode)
for _, p := range gallerys {
if nameMatchesPath(name, p.Path.String) != -1 {
if regexpMatchesPath(r, p.Path.String) != -1 {
ret = append(ret, p)
}
}

View File

@@ -4,71 +4,90 @@ import "testing"
func Test_nameMatchesPath(t *testing.T) {
const name = "first last"
const unicodeName = "伏字"
tests := []struct {
name string
path string
want int
testName string
name string
path string
want int
}{
{
"exact",
name,
name,
0,
},
{
"partial",
name,
"first",
-1,
},
{
"separator",
name,
"first.last",
0,
},
{
"separator",
name,
"first-last",
0,
},
{
"separator",
name,
"first_last",
0,
},
{
"separators",
name,
"first.-_ last",
0,
},
{
"within string",
name,
"before_first last/after",
6,
},
{
"not within string",
name,
"beforefirst last/after",
-1,
},
{
"not within string",
name,
"before/first lastafter",
-1,
},
{
"not within string",
name,
"first last1",
-1,
},
{
"not within string",
name,
"1first last",
-1,
},
{
"unicode",
unicodeName,
unicodeName,
0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := nameMatchesPath(name, tt.path); got != tt.want {
t.Run(tt.testName, func(t *testing.T) {
if got := nameMatchesPath(tt.name, tt.path); got != tt.want {
t.Errorf("nameMatchesPath() = %v, want %v", got, tt.want)
}
})

View File

@@ -8,6 +8,7 @@ import (
"os/exec"
"sync"
"github.com/stashapp/stash/pkg/desktop"
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/plugin/common"
)
@@ -87,6 +88,7 @@ func (t *rawPluginTask) Start() error {
t.waitGroup.Add(1)
t.done = make(chan bool, 1)
desktop.HideExecShell(cmd)
if err = cmd.Start(); err != nil {
return fmt.Errorf("error running plugin: %v", err)
}

View File

@@ -135,6 +135,14 @@ func Destroy(scene *models.Scene, repo models.Repository, fileDeleter *FileDelet
if err := fileDeleter.Files([]string{scene.Path}); err != nil {
return err
}
funscriptPath := utils.GetFunscriptPath(scene.Path)
funscriptExists, _ := utils.FileExists(funscriptPath)
if funscriptExists {
if err := fileDeleter.Files([]string{funscriptPath}); err != nil {
return err
}
}
}
if deleteGenerated {

View File

@@ -840,7 +840,7 @@ func (s mappedScraper) processScene(ctx context.Context, q mappedQuery, r mapped
for _, p := range performerTagResults {
tag := &models.ScrapedTag{}
p.apply(tag)
ret.Tags = append(ret.Tags, tag)
performer.Tags = append(performer.Tags, tag)
}
ret.Performers = append(ret.Performers, performer)

View File

@@ -10,6 +10,7 @@ import (
"path/filepath"
"strings"
"github.com/stashapp/stash/pkg/desktop"
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/models"
)
@@ -66,6 +67,7 @@ func (s *scriptScraper) runScraperScript(inString string, out interface{}) error
logger.Error("Scraper stdout not available: " + err.Error())
}
desktop.HideExecShell(cmd)
if err = cmd.Start(); err != nil {
logger.Error("Error running scraper script: " + err.Error())
return errors.New("error running scraper script")

View File

@@ -31,6 +31,8 @@ type Query struct {
FindScenesByFingerprints []*Scene "json:\"findScenesByFingerprints\" graphql:\"findScenesByFingerprints\""
FindScenesByFullFingerprints []*Scene "json:\"findScenesByFullFingerprints\" graphql:\"findScenesByFullFingerprints\""
QueryScenes QueryScenesResultType "json:\"queryScenes\" graphql:\"queryScenes\""
FindSite *Site "json:\"findSite\" graphql:\"findSite\""
QuerySites QuerySitesResultType "json:\"querySites\" graphql:\"querySites\""
FindEdit *Edit "json:\"findEdit\" graphql:\"findEdit\""
QueryEdits QueryEditsResultType "json:\"queryEdits\" graphql:\"queryEdits\""
FindUser *User "json:\"findUser\" graphql:\"findUser\""
@@ -38,48 +40,57 @@ type Query struct {
Me *User "json:\"me\" graphql:\"me\""
SearchPerformer []*Performer "json:\"searchPerformer\" graphql:\"searchPerformer\""
SearchScene []*Scene "json:\"searchScene\" graphql:\"searchScene\""
FindDraft *Draft "json:\"findDraft\" graphql:\"findDraft\""
FindDrafts []*Draft "json:\"findDrafts\" graphql:\"findDrafts\""
Version Version "json:\"version\" graphql:\"version\""
GetConfig StashBoxConfig "json:\"getConfig\" graphql:\"getConfig\""
}
type Mutation struct {
SceneCreate *Scene "json:\"sceneCreate\" graphql:\"sceneCreate\""
SceneUpdate *Scene "json:\"sceneUpdate\" graphql:\"sceneUpdate\""
SceneDestroy bool "json:\"sceneDestroy\" graphql:\"sceneDestroy\""
PerformerCreate *Performer "json:\"performerCreate\" graphql:\"performerCreate\""
PerformerUpdate *Performer "json:\"performerUpdate\" graphql:\"performerUpdate\""
PerformerDestroy bool "json:\"performerDestroy\" graphql:\"performerDestroy\""
StudioCreate *Studio "json:\"studioCreate\" graphql:\"studioCreate\""
StudioUpdate *Studio "json:\"studioUpdate\" graphql:\"studioUpdate\""
StudioDestroy bool "json:\"studioDestroy\" graphql:\"studioDestroy\""
TagCreate *Tag "json:\"tagCreate\" graphql:\"tagCreate\""
TagUpdate *Tag "json:\"tagUpdate\" graphql:\"tagUpdate\""
TagDestroy bool "json:\"tagDestroy\" graphql:\"tagDestroy\""
UserCreate *User "json:\"userCreate\" graphql:\"userCreate\""
UserUpdate *User "json:\"userUpdate\" graphql:\"userUpdate\""
UserDestroy bool "json:\"userDestroy\" graphql:\"userDestroy\""
ImageCreate *Image "json:\"imageCreate\" graphql:\"imageCreate\""
ImageDestroy bool "json:\"imageDestroy\" graphql:\"imageDestroy\""
NewUser *string "json:\"newUser\" graphql:\"newUser\""
ActivateNewUser *User "json:\"activateNewUser\" graphql:\"activateNewUser\""
GenerateInviteCode string "json:\"generateInviteCode\" graphql:\"generateInviteCode\""
RescindInviteCode bool "json:\"rescindInviteCode\" graphql:\"rescindInviteCode\""
GrantInvite int "json:\"grantInvite\" graphql:\"grantInvite\""
RevokeInvite int "json:\"revokeInvite\" graphql:\"revokeInvite\""
TagCategoryCreate *TagCategory "json:\"tagCategoryCreate\" graphql:\"tagCategoryCreate\""
TagCategoryUpdate *TagCategory "json:\"tagCategoryUpdate\" graphql:\"tagCategoryUpdate\""
TagCategoryDestroy bool "json:\"tagCategoryDestroy\" graphql:\"tagCategoryDestroy\""
RegenerateAPIKey string "json:\"regenerateAPIKey\" graphql:\"regenerateAPIKey\""
ResetPassword bool "json:\"resetPassword\" graphql:\"resetPassword\""
ChangePassword bool "json:\"changePassword\" graphql:\"changePassword\""
SceneEdit Edit "json:\"sceneEdit\" graphql:\"sceneEdit\""
PerformerEdit Edit "json:\"performerEdit\" graphql:\"performerEdit\""
StudioEdit Edit "json:\"studioEdit\" graphql:\"studioEdit\""
TagEdit Edit "json:\"tagEdit\" graphql:\"tagEdit\""
EditVote Edit "json:\"editVote\" graphql:\"editVote\""
EditComment Edit "json:\"editComment\" graphql:\"editComment\""
ApplyEdit Edit "json:\"applyEdit\" graphql:\"applyEdit\""
CancelEdit Edit "json:\"cancelEdit\" graphql:\"cancelEdit\""
SubmitFingerprint bool "json:\"submitFingerprint\" graphql:\"submitFingerprint\""
SceneCreate *Scene "json:\"sceneCreate\" graphql:\"sceneCreate\""
SceneUpdate *Scene "json:\"sceneUpdate\" graphql:\"sceneUpdate\""
SceneDestroy bool "json:\"sceneDestroy\" graphql:\"sceneDestroy\""
PerformerCreate *Performer "json:\"performerCreate\" graphql:\"performerCreate\""
PerformerUpdate *Performer "json:\"performerUpdate\" graphql:\"performerUpdate\""
PerformerDestroy bool "json:\"performerDestroy\" graphql:\"performerDestroy\""
StudioCreate *Studio "json:\"studioCreate\" graphql:\"studioCreate\""
StudioUpdate *Studio "json:\"studioUpdate\" graphql:\"studioUpdate\""
StudioDestroy bool "json:\"studioDestroy\" graphql:\"studioDestroy\""
TagCreate *Tag "json:\"tagCreate\" graphql:\"tagCreate\""
TagUpdate *Tag "json:\"tagUpdate\" graphql:\"tagUpdate\""
TagDestroy bool "json:\"tagDestroy\" graphql:\"tagDestroy\""
UserCreate *User "json:\"userCreate\" graphql:\"userCreate\""
UserUpdate *User "json:\"userUpdate\" graphql:\"userUpdate\""
UserDestroy bool "json:\"userDestroy\" graphql:\"userDestroy\""
ImageCreate *Image "json:\"imageCreate\" graphql:\"imageCreate\""
ImageDestroy bool "json:\"imageDestroy\" graphql:\"imageDestroy\""
NewUser *string "json:\"newUser\" graphql:\"newUser\""
ActivateNewUser *User "json:\"activateNewUser\" graphql:\"activateNewUser\""
GenerateInviteCode *string "json:\"generateInviteCode\" graphql:\"generateInviteCode\""
RescindInviteCode bool "json:\"rescindInviteCode\" graphql:\"rescindInviteCode\""
GrantInvite int "json:\"grantInvite\" graphql:\"grantInvite\""
RevokeInvite int "json:\"revokeInvite\" graphql:\"revokeInvite\""
TagCategoryCreate *TagCategory "json:\"tagCategoryCreate\" graphql:\"tagCategoryCreate\""
TagCategoryUpdate *TagCategory "json:\"tagCategoryUpdate\" graphql:\"tagCategoryUpdate\""
TagCategoryDestroy bool "json:\"tagCategoryDestroy\" graphql:\"tagCategoryDestroy\""
SiteCreate *Site "json:\"siteCreate\" graphql:\"siteCreate\""
SiteUpdate *Site "json:\"siteUpdate\" graphql:\"siteUpdate\""
SiteDestroy bool "json:\"siteDestroy\" graphql:\"siteDestroy\""
RegenerateAPIKey string "json:\"regenerateAPIKey\" graphql:\"regenerateAPIKey\""
ResetPassword bool "json:\"resetPassword\" graphql:\"resetPassword\""
ChangePassword bool "json:\"changePassword\" graphql:\"changePassword\""
SceneEdit Edit "json:\"sceneEdit\" graphql:\"sceneEdit\""
PerformerEdit Edit "json:\"performerEdit\" graphql:\"performerEdit\""
StudioEdit Edit "json:\"studioEdit\" graphql:\"studioEdit\""
TagEdit Edit "json:\"tagEdit\" graphql:\"tagEdit\""
EditVote Edit "json:\"editVote\" graphql:\"editVote\""
EditComment Edit "json:\"editComment\" graphql:\"editComment\""
ApplyEdit Edit "json:\"applyEdit\" graphql:\"applyEdit\""
CancelEdit Edit "json:\"cancelEdit\" graphql:\"cancelEdit\""
SubmitFingerprint bool "json:\"submitFingerprint\" graphql:\"submitFingerprint\""
SubmitSceneDraft DraftSubmissionStatus "json:\"submitSceneDraft\" graphql:\"submitSceneDraft\""
SubmitPerformerDraft DraftSubmissionStatus "json:\"submitPerformerDraft\" graphql:\"submitPerformerDraft\""
DestroyDraft bool "json:\"destroyDraft\" graphql:\"destroyDraft\""
}
type URLFragment struct {
URL string "json:\"url\" graphql:\"url\""
@@ -180,60 +191,43 @@ type FindSceneByID struct {
type SubmitFingerprintPayload struct {
SubmitFingerprint bool "json:\"submitFingerprint\" graphql:\"submitFingerprint\""
}
type Me struct {
Me *struct {
Name string "json:\"name\" graphql:\"name\""
} "json:\"me\" graphql:\"me\""
}
type SubmitSceneDraftPayload struct {
SubmitSceneDraft struct {
ID *string "json:\"id\" graphql:\"id\""
} "json:\"submitSceneDraft\" graphql:\"submitSceneDraft\""
}
type SubmitPerformerDraftPayload struct {
SubmitPerformerDraft struct {
ID *string "json:\"id\" graphql:\"id\""
} "json:\"submitPerformerDraft\" graphql:\"submitPerformerDraft\""
}
const FindSceneByFingerprintQuery = `query FindSceneByFingerprint ($fingerprint: FingerprintQueryInput!) {
findSceneByFingerprint(fingerprint: $fingerprint) {
... SceneFragment
}
}
fragment FuzzyDateFragment on FuzzyDate {
date
accuracy
fragment BodyModificationFragment on BodyModification {
location
description
}
fragment SceneFragment on Scene {
id
title
details
fragment FingerprintFragment on Fingerprint {
algorithm
hash
duration
date
urls {
... URLFragment
}
images {
... ImageFragment
}
studio {
... StudioFragment
}
tags {
... TagFragment
}
performers {
... PerformerAppearanceFragment
}
fingerprints {
... FingerprintFragment
}
}
fragment URLFragment on URL {
url
type
}
fragment StudioFragment on Studio {
fragment TagFragment on Tag {
name
id
urls {
... URLFragment
}
images {
... ImageFragment
}
}
fragment PerformerAppearanceFragment on PerformerAppearance {
as
performer {
... PerformerFragment
}
}
fragment PerformerFragment on Performer {
id
@@ -269,15 +263,9 @@ fragment PerformerFragment on Performer {
... BodyModificationFragment
}
}
fragment ImageFragment on Image {
id
url
width
height
}
fragment TagFragment on Tag {
name
id
fragment FuzzyDateFragment on FuzzyDate {
date
accuracy
}
fragment MeasurementsFragment on Measurements {
band_size
@@ -285,14 +273,52 @@ fragment MeasurementsFragment on Measurements {
waist
hip
}
fragment BodyModificationFragment on BodyModification {
location
description
}
fragment FingerprintFragment on Fingerprint {
algorithm
hash
fragment SceneFragment on Scene {
id
title
details
duration
date
urls {
... URLFragment
}
images {
... ImageFragment
}
studio {
... StudioFragment
}
tags {
... TagFragment
}
performers {
... PerformerAppearanceFragment
}
fingerprints {
... FingerprintFragment
}
}
fragment ImageFragment on Image {
id
url
width
height
}
fragment StudioFragment on Studio {
name
id
urls {
... URLFragment
}
images {
... ImageFragment
}
}
fragment PerformerAppearanceFragment on PerformerAppearance {
as
performer {
... PerformerFragment
}
}
`
@@ -314,12 +340,6 @@ const FindScenesByFullFingerprintsQuery = `query FindScenesByFullFingerprints ($
... SceneFragment
}
}
fragment ImageFragment on Image {
id
url
width
height
}
fragment StudioFragment on Studio {
name
id
@@ -330,10 +350,6 @@ fragment StudioFragment on Studio {
... ImageFragment
}
}
fragment TagFragment on Tag {
name
id
}
fragment PerformerFragment on Performer {
id
name
@@ -378,6 +394,35 @@ fragment MeasurementsFragment on Measurements {
waist
hip
}
fragment BodyModificationFragment on BodyModification {
location
description
}
fragment ImageFragment on Image {
id
url
width
height
}
fragment URLFragment on URL {
url
type
}
fragment TagFragment on Tag {
name
id
}
fragment PerformerAppearanceFragment on PerformerAppearance {
as
performer {
... PerformerFragment
}
}
fragment FingerprintFragment on Fingerprint {
algorithm
hash
duration
}
fragment SceneFragment on Scene {
id
title
@@ -403,25 +448,6 @@ fragment SceneFragment on Scene {
... FingerprintFragment
}
}
fragment PerformerAppearanceFragment on PerformerAppearance {
as
performer {
... PerformerFragment
}
}
fragment BodyModificationFragment on BodyModification {
location
description
}
fragment FingerprintFragment on Fingerprint {
algorithm
hash
duration
}
fragment URLFragment on URL {
url
type
}
`
func (c *Client) FindScenesByFullFingerprints(ctx context.Context, fingerprints []*FingerprintQueryInput, httpRequestOptions ...client.HTTPRequestOption) (*FindScenesByFullFingerprints, error) {
@@ -442,6 +468,11 @@ const SearchSceneQuery = `query SearchScene ($term: String!) {
... SceneFragment
}
}
fragment FingerprintFragment on Fingerprint {
algorithm
hash
duration
}
fragment URLFragment on URL {
url
type
@@ -456,14 +487,21 @@ fragment TagFragment on Tag {
name
id
}
fragment FuzzyDateFragment on FuzzyDate {
date
accuracy
fragment PerformerAppearanceFragment on PerformerAppearance {
as
performer {
... PerformerFragment
}
}
fragment FingerprintFragment on Fingerprint {
algorithm
hash
duration
fragment MeasurementsFragment on Measurements {
band_size
cup_size
waist
hip
}
fragment BodyModificationFragment on BodyModification {
location
description
}
fragment SceneFragment on Scene {
id
@@ -500,12 +538,6 @@ fragment StudioFragment on Studio {
... ImageFragment
}
}
fragment PerformerAppearanceFragment on PerformerAppearance {
as
performer {
... PerformerFragment
}
}
fragment PerformerFragment on Performer {
id
name
@@ -540,15 +572,9 @@ fragment PerformerFragment on Performer {
... BodyModificationFragment
}
}
fragment MeasurementsFragment on Measurements {
band_size
cup_size
waist
hip
}
fragment BodyModificationFragment on BodyModification {
location
description
fragment FuzzyDateFragment on FuzzyDate {
date
accuracy
}
`
@@ -570,30 +596,6 @@ const SearchPerformerQuery = `query SearchPerformer ($term: String!) {
... PerformerFragment
}
}
fragment URLFragment on URL {
url
type
}
fragment ImageFragment on Image {
id
url
width
height
}
fragment FuzzyDateFragment on FuzzyDate {
date
accuracy
}
fragment MeasurementsFragment on Measurements {
band_size
cup_size
waist
hip
}
fragment BodyModificationFragment on BodyModification {
location
description
}
fragment PerformerFragment on Performer {
id
name
@@ -628,6 +630,30 @@ fragment PerformerFragment on Performer {
... BodyModificationFragment
}
}
fragment URLFragment on URL {
url
type
}
fragment ImageFragment on Image {
id
url
width
height
}
fragment FuzzyDateFragment on FuzzyDate {
date
accuracy
}
fragment MeasurementsFragment on Measurements {
band_size
cup_size
waist
hip
}
fragment BodyModificationFragment on BodyModification {
location
description
}
`
func (c *Client) SearchPerformer(ctx context.Context, term string, httpRequestOptions ...client.HTTPRequestOption) (*SearchPerformer, error) {
@@ -648,40 +674,6 @@ const FindPerformerByIDQuery = `query FindPerformerByID ($id: ID!) {
... PerformerFragment
}
}
fragment PerformerFragment on Performer {
id
name
disambiguation
aliases
gender
merged_ids
urls {
... URLFragment
}
images {
... ImageFragment
}
birthdate {
... FuzzyDateFragment
}
ethnicity
country
eye_color
hair_color
height
measurements {
... MeasurementsFragment
}
breast_type
career_start_year
career_end_year
tattoos {
... BodyModificationFragment
}
piercings {
... BodyModificationFragment
}
}
fragment URLFragment on URL {
url
type
@@ -706,46 +698,6 @@ fragment BodyModificationFragment on BodyModification {
location
description
}
`
func (c *Client) FindPerformerByID(ctx context.Context, id string, httpRequestOptions ...client.HTTPRequestOption) (*FindPerformerByID, error) {
vars := map[string]interface{}{
"id": id,
}
var res FindPerformerByID
if err := c.Client.Post(ctx, FindPerformerByIDQuery, &res, vars, httpRequestOptions...); err != nil {
return nil, err
}
return &res, nil
}
const FindSceneByIDQuery = `query FindSceneByID ($id: ID!) {
findScene(id: $id) {
... SceneFragment
}
}
fragment ImageFragment on Image {
id
url
width
height
}
fragment StudioFragment on Studio {
name
id
urls {
... URLFragment
}
images {
... ImageFragment
}
}
fragment TagFragment on Tag {
name
id
}
fragment PerformerFragment on Performer {
id
name
@@ -780,6 +732,34 @@ fragment PerformerFragment on Performer {
... BodyModificationFragment
}
}
`
func (c *Client) FindPerformerByID(ctx context.Context, id string, httpRequestOptions ...client.HTTPRequestOption) (*FindPerformerByID, error) {
vars := map[string]interface{}{
"id": id,
}
var res FindPerformerByID
if err := c.Client.Post(ctx, FindPerformerByIDQuery, &res, vars, httpRequestOptions...); err != nil {
return nil, err
}
return &res, nil
}
const FindSceneByIDQuery = `query FindSceneByID ($id: ID!) {
findScene(id: $id) {
... SceneFragment
}
}
fragment TagFragment on Tag {
name
id
}
fragment FuzzyDateFragment on FuzzyDate {
date
accuracy
}
fragment MeasurementsFragment on Measurements {
band_size
cup_size
@@ -820,9 +800,21 @@ fragment URLFragment on URL {
url
type
}
fragment BodyModificationFragment on BodyModification {
location
description
fragment ImageFragment on Image {
id
url
width
height
}
fragment StudioFragment on Studio {
name
id
urls {
... URLFragment
}
images {
... ImageFragment
}
}
fragment PerformerAppearanceFragment on PerformerAppearance {
as
@@ -830,9 +822,43 @@ fragment PerformerAppearanceFragment on PerformerAppearance {
... PerformerFragment
}
}
fragment FuzzyDateFragment on FuzzyDate {
date
accuracy
fragment PerformerFragment on Performer {
id
name
disambiguation
aliases
gender
merged_ids
urls {
... URLFragment
}
images {
... ImageFragment
}
birthdate {
... FuzzyDateFragment
}
ethnicity
country
eye_color
hair_color
height
measurements {
... MeasurementsFragment
}
breast_type
career_start_year
career_end_year
tattoos {
... BodyModificationFragment
}
piercings {
... BodyModificationFragment
}
}
fragment BodyModificationFragment on BodyModification {
location
description
}
`
@@ -866,3 +892,61 @@ func (c *Client) SubmitFingerprint(ctx context.Context, input FingerprintSubmiss
return &res, nil
}
const MeQuery = `query Me {
me {
name
}
}
`
func (c *Client) Me(ctx context.Context, httpRequestOptions ...client.HTTPRequestOption) (*Me, error) {
vars := map[string]interface{}{}
var res Me
if err := c.Client.Post(ctx, MeQuery, &res, vars, httpRequestOptions...); err != nil {
return nil, err
}
return &res, nil
}
const SubmitSceneDraftQuery = `mutation SubmitSceneDraft ($input: SceneDraftInput!) {
submitSceneDraft(input: $input) {
id
}
}
`
func (c *Client) SubmitSceneDraft(ctx context.Context, input SceneDraftInput, httpRequestOptions ...client.HTTPRequestOption) (*SubmitSceneDraftPayload, error) {
vars := map[string]interface{}{
"input": input,
}
var res SubmitSceneDraftPayload
if err := c.Client.Post(ctx, SubmitSceneDraftQuery, &res, vars, httpRequestOptions...); err != nil {
return nil, err
}
return &res, nil
}
const SubmitPerformerDraftQuery = `mutation SubmitPerformerDraft ($input: PerformerDraftInput!) {
submitPerformerDraft(input: $input) {
id
}
}
`
func (c *Client) SubmitPerformerDraft(ctx context.Context, input PerformerDraftInput, httpRequestOptions ...client.HTTPRequestOption) (*SubmitPerformerDraftPayload, error) {
vars := map[string]interface{}{
"input": input,
}
var res SubmitPerformerDraftPayload
if err := c.Client.Post(ctx, SubmitPerformerDraftQuery, &res, vars, httpRequestOptions...); err != nil {
return nil, err
}
return &res, nil
}

View File

@@ -11,6 +11,10 @@ import (
"github.com/99designs/gqlgen/graphql"
)
type DraftData interface {
IsDraftData()
}
type EditDetails interface {
IsEditDetails()
}
@@ -19,6 +23,18 @@ type EditTarget interface {
IsEditTarget()
}
type SceneDraftPerformer interface {
IsSceneDraftPerformer()
}
type SceneDraftStudio interface {
IsSceneDraftStudio()
}
type SceneDraftTag interface {
IsSceneDraftTag()
}
type ActivateNewUserInput struct {
Name string `json:"name"`
Email string `json:"email"`
@@ -60,6 +76,37 @@ type DateCriterionInput struct {
Modifier CriterionModifier `json:"modifier"`
}
type Draft struct {
ID string `json:"id"`
Created time.Time `json:"created"`
Expires time.Time `json:"expires"`
Data DraftData `json:"data"`
}
type DraftEntity struct {
Name string `json:"name"`
ID *string `json:"id"`
}
func (DraftEntity) IsSceneDraftPerformer() {}
func (DraftEntity) IsSceneDraftStudio() {}
func (DraftEntity) IsSceneDraftTag() {}
type DraftEntityInput struct {
Name string `json:"name"`
ID *string `json:"id"`
}
type DraftFingerprint struct {
Hash string `json:"hash"`
Algorithm FingerprintAlgorithm `json:"algorithm"`
Duration int `json:"duration"`
}
type DraftSubmissionStatus struct {
ID *string `json:"id"`
}
type Edit struct {
ID string `json:"id"`
User *User `json:"user"`
@@ -75,13 +122,15 @@ type Edit struct {
// Entity specific options
Options *PerformerEditOptions `json:"options"`
Comments []*EditComment `json:"comments"`
Votes []*VoteComment `json:"votes"`
Votes []*EditVote `json:"votes"`
// = Accepted - Rejected
VoteCount int `json:"vote_count"`
Status VoteStatusEnum `json:"status"`
Applied bool `json:"applied"`
Created time.Time `json:"created"`
Updated time.Time `json:"updated"`
VoteCount int `json:"vote_count"`
// Is the edit considered destructive.
Destructive bool `json:"destructive"`
Status VoteStatusEnum `json:"status"`
Applied bool `json:"applied"`
Created time.Time `json:"created"`
Updated time.Time `json:"updated"`
}
type EditComment struct {
@@ -123,10 +172,15 @@ type EditInput struct {
Comment *string `json:"comment"`
}
type EditVote struct {
User *User `json:"user"`
Date time.Time `json:"date"`
Vote VoteTypeEnum `json:"vote"`
}
type EditVoteInput struct {
ID string `json:"id"`
Comment *string `json:"comment"`
Type VoteTypeEnum `json:"type"`
ID string `json:"id"`
Vote VoteTypeEnum `json:"vote"`
}
type EyeColorCriterionInput struct {
@@ -135,24 +189,30 @@ type EyeColorCriterionInput struct {
}
type Fingerprint struct {
Hash string `json:"hash"`
Algorithm FingerprintAlgorithm `json:"algorithm"`
Duration int `json:"duration"`
Submissions int `json:"submissions"`
Created time.Time `json:"created"`
Updated time.Time `json:"updated"`
Hash string `json:"hash"`
Algorithm FingerprintAlgorithm `json:"algorithm"`
Duration int `json:"duration"`
Submissions int `json:"submissions"`
Created time.Time `json:"created"`
Updated time.Time `json:"updated"`
UserSubmitted bool `json:"user_submitted"`
}
type FingerprintEditInput struct {
Hash string `json:"hash"`
Algorithm FingerprintAlgorithm `json:"algorithm"`
Duration int `json:"duration"`
Submissions int `json:"submissions"`
Created time.Time `json:"created"`
Updated time.Time `json:"updated"`
UserIds []string `json:"user_ids"`
Hash string `json:"hash"`
Algorithm FingerprintAlgorithm `json:"algorithm"`
Duration int `json:"duration"`
Created time.Time `json:"created"`
// @deprecated(reason: "unused")
Submissions *int `json:"submissions"`
// @deprecated(reason: "unused")
Updated *time.Time `json:"updated"`
}
type FingerprintInput struct {
// assumes current user if omitted. Ignored for non-modify Users
UserIds []string `json:"user_ids"`
Hash string `json:"hash"`
Algorithm FingerprintAlgorithm `json:"algorithm"`
Duration int `json:"duration"`
@@ -166,6 +226,7 @@ type FingerprintQueryInput struct {
type FingerprintSubmission struct {
SceneID string `json:"scene_id"`
Fingerprint *FingerprintInput `json:"fingerprint"`
Unmatch *bool `json:"unmatch"`
}
type FuzzyDate struct {
@@ -238,6 +299,11 @@ type MultiIDCriterionInput struct {
Modifier CriterionModifier `json:"modifier"`
}
type MultiStringCriterionInput struct {
Value []string `json:"value"`
Modifier CriterionModifier `json:"modifier"`
}
type NewUserInput struct {
Email string `json:"email"`
InviteKey *string `json:"invite_key"`
@@ -272,7 +338,8 @@ type Performer struct {
Studios []*PerformerStudio `json:"studios"`
}
func (Performer) IsEditTarget() {}
func (Performer) IsEditTarget() {}
func (Performer) IsSceneDraftPerformer() {}
type PerformerAppearance struct {
Performer *Performer `json:"performer"`
@@ -305,12 +372,55 @@ type PerformerCreateInput struct {
Tattoos []*BodyModificationInput `json:"tattoos"`
Piercings []*BodyModificationInput `json:"piercings"`
ImageIds []string `json:"image_ids"`
DraftID *string `json:"draft_id"`
}
type PerformerDestroyInput struct {
ID string `json:"id"`
}
type PerformerDraft struct {
Name string `json:"name"`
Aliases *string `json:"aliases"`
Gender *string `json:"gender"`
Birthdate *string `json:"birthdate"`
Urls []string `json:"urls"`
Ethnicity *string `json:"ethnicity"`
Country *string `json:"country"`
EyeColor *string `json:"eye_color"`
HairColor *string `json:"hair_color"`
Height *string `json:"height"`
Measurements *string `json:"measurements"`
BreastType *string `json:"breast_type"`
Tattoos *string `json:"tattoos"`
Piercings *string `json:"piercings"`
CareerStartYear *int `json:"career_start_year"`
CareerEndYear *int `json:"career_end_year"`
Image *Image `json:"image"`
}
func (PerformerDraft) IsDraftData() {}
type PerformerDraftInput struct {
Name string `json:"name"`
Aliases *string `json:"aliases"`
Gender *string `json:"gender"`
Birthdate *string `json:"birthdate"`
Urls []string `json:"urls"`
Ethnicity *string `json:"ethnicity"`
Country *string `json:"country"`
EyeColor *string `json:"eye_color"`
HairColor *string `json:"hair_color"`
Height *string `json:"height"`
Measurements *string `json:"measurements"`
BreastType *string `json:"breast_type"`
Tattoos *string `json:"tattoos"`
Piercings *string `json:"piercings"`
CareerStartYear *int `json:"career_start_year"`
CareerEndYear *int `json:"career_end_year"`
Image *graphql.Upload `json:"image"`
}
type PerformerEdit struct {
Name *string `json:"name"`
Disambiguation *string `json:"disambiguation"`
@@ -340,6 +450,7 @@ type PerformerEdit struct {
RemovedPiercings []*BodyModification `json:"removed_piercings"`
AddedImages []*Image `json:"added_images"`
RemovedImages []*Image `json:"removed_images"`
DraftID *string `json:"draft_id"`
}
func (PerformerEdit) IsEditDetails() {}
@@ -363,6 +474,7 @@ type PerformerEditDetailsInput struct {
Tattoos []*BodyModificationInput `json:"tattoos"`
Piercings []*BodyModificationInput `json:"piercings"`
ImageIds []string `json:"image_ids"`
DraftID *string `json:"draft_id"`
}
type PerformerEditInput struct {
@@ -459,6 +571,11 @@ type QueryScenesResultType struct {
Scenes []*Scene `json:"scenes"`
}
type QuerySitesResultType struct {
Count int `json:"count"`
Sites []*Site `json:"sites"`
}
type QuerySpec struct {
Page *int `json:"page"`
PerPage *int `json:"per_page"`
@@ -514,6 +631,7 @@ type Scene struct {
Duration *int `json:"duration"`
Director *string `json:"director"`
Deleted bool `json:"deleted"`
Edits []*Edit `json:"edits"`
}
func (Scene) IsEditTarget() {}
@@ -536,13 +654,39 @@ type SceneDestroyInput struct {
ID string `json:"id"`
}
type SceneDraft struct {
Title *string `json:"title"`
Details *string `json:"details"`
URL *URL `json:"url"`
Date *string `json:"date"`
Studio SceneDraftStudio `json:"studio"`
Performers []SceneDraftPerformer `json:"performers"`
Tags []SceneDraftTag `json:"tags"`
Image *Image `json:"image"`
Fingerprints []*DraftFingerprint `json:"fingerprints"`
}
func (SceneDraft) IsDraftData() {}
type SceneDraftInput struct {
Title *string `json:"title"`
Details *string `json:"details"`
URL *string `json:"url"`
Date *string `json:"date"`
Studio *DraftEntityInput `json:"studio"`
Performers []*DraftEntityInput `json:"performers"`
Tags []*DraftEntityInput `json:"tags"`
Image *graphql.Upload `json:"image"`
Fingerprints []*FingerprintInput `json:"fingerprints"`
}
type SceneEdit struct {
Title *string `json:"title"`
Details *string `json:"details"`
AddedUrls []*URL `json:"added_urls"`
RemovedUrls []*URL `json:"removed_urls"`
Date *string `json:"date"`
StudioID *string `json:"studio_id"`
Studio *Studio `json:"studio"`
// Added or modified performer appearance entries
AddedPerformers []*PerformerAppearance `json:"added_performers"`
RemovedPerformers []*PerformerAppearance `json:"removed_performers"`
@@ -554,6 +698,7 @@ type SceneEdit struct {
RemovedFingerprints []*Fingerprint `json:"removed_fingerprints"`
Duration *int `json:"duration"`
Director *string `json:"director"`
DraftID *string `json:"draft_id"`
}
func (SceneEdit) IsEditDetails() {}
@@ -567,9 +712,10 @@ type SceneEditDetailsInput struct {
Performers []*PerformerAppearanceInput `json:"performers"`
TagIds []string `json:"tag_ids"`
ImageIds []string `json:"image_ids"`
Fingerprints []*FingerprintEditInput `json:"fingerprints"`
Duration *int `json:"duration"`
Director *string `json:"director"`
Fingerprints []*FingerprintInput `json:"fingerprints"`
DraftID *string `json:"draft_id"`
}
type SceneEditInput struct {
@@ -599,7 +745,7 @@ type SceneFilterType struct {
// Filter to include scenes with performer appearing as alias
Alias *StringCriterionInput `json:"alias"`
// Filter to only include scenes with these fingerprints
Fingerprints *MultiIDCriterionInput `json:"fingerprints"`
Fingerprints *MultiStringCriterionInput `json:"fingerprints"`
}
type SceneUpdateInput struct {
@@ -617,6 +763,50 @@ type SceneUpdateInput struct {
Director *string `json:"director"`
}
type Site struct {
ID string `json:"id"`
Name string `json:"name"`
Description *string `json:"description"`
URL *string `json:"url"`
Regex *string `json:"regex"`
ValidTypes []ValidSiteTypeEnum `json:"valid_types"`
Icon string `json:"icon"`
Created time.Time `json:"created"`
Updated time.Time `json:"updated"`
}
type SiteCreateInput struct {
Name string `json:"name"`
Description *string `json:"description"`
URL *string `json:"url"`
Regex *string `json:"regex"`
ValidTypes []ValidSiteTypeEnum `json:"valid_types"`
}
type SiteDestroyInput struct {
ID string `json:"id"`
}
type SiteUpdateInput struct {
ID string `json:"id"`
Name string `json:"name"`
Description *string `json:"description"`
URL *string `json:"url"`
Regex *string `json:"regex"`
ValidTypes []ValidSiteTypeEnum `json:"valid_types"`
}
type StashBoxConfig struct {
HostURL string `json:"host_url"`
RequireInvite bool `json:"require_invite"`
RequireActivation bool `json:"require_activation"`
VotePromotionThreshold *int `json:"vote_promotion_threshold"`
VoteApplicationThreshold int `json:"vote_application_threshold"`
VotingPeriod int `json:"voting_period"`
MinDestructiveVotingPeriod int `json:"min_destructive_voting_period"`
VoteCronInterval string `json:"vote_cron_interval"`
}
type StringCriterionInput struct {
Value string `json:"value"`
Modifier CriterionModifier `json:"modifier"`
@@ -632,14 +822,14 @@ type Studio struct {
Deleted bool `json:"deleted"`
}
func (Studio) IsEditTarget() {}
func (Studio) IsEditTarget() {}
func (Studio) IsSceneDraftStudio() {}
type StudioCreateInput struct {
Name string `json:"name"`
Urls []*URLInput `json:"urls"`
ParentID *string `json:"parent_id"`
ChildStudioIds []string `json:"child_studio_ids"`
ImageIds []string `json:"image_ids"`
Name string `json:"name"`
Urls []*URLInput `json:"urls"`
ParentID *string `json:"parent_id"`
ImageIds []string `json:"image_ids"`
}
type StudioDestroyInput struct {
@@ -649,23 +839,20 @@ type StudioDestroyInput struct {
type StudioEdit struct {
Name *string `json:"name"`
// Added and modified URLs
AddedUrls []*URL `json:"added_urls"`
RemovedUrls []*URL `json:"removed_urls"`
Parent *Studio `json:"parent"`
AddedChildStudios []*Studio `json:"added_child_studios"`
RemovedChildStudios []*Studio `json:"removed_child_studios"`
AddedImages []*Image `json:"added_images"`
RemovedImages []*Image `json:"removed_images"`
AddedUrls []*URL `json:"added_urls"`
RemovedUrls []*URL `json:"removed_urls"`
Parent *Studio `json:"parent"`
AddedImages []*Image `json:"added_images"`
RemovedImages []*Image `json:"removed_images"`
}
func (StudioEdit) IsEditDetails() {}
type StudioEditDetailsInput struct {
Name *string `json:"name"`
Urls []*URLInput `json:"urls"`
ParentID *string `json:"parent_id"`
ChildStudioIds []string `json:"child_studio_ids"`
ImageIds []string `json:"image_ids"`
Name *string `json:"name"`
Urls []*URLInput `json:"urls"`
ParentID *string `json:"parent_id"`
ImageIds []string `json:"image_ids"`
}
type StudioEditInput struct {
@@ -686,12 +873,11 @@ type StudioFilterType struct {
}
type StudioUpdateInput struct {
ID string `json:"id"`
Name *string `json:"name"`
Urls []*URLInput `json:"urls"`
ParentID *string `json:"parent_id"`
ChildStudioIds []string `json:"child_studio_ids"`
ImageIds []string `json:"image_ids"`
ID string `json:"id"`
Name *string `json:"name"`
Urls []*URLInput `json:"urls"`
ParentID *string `json:"parent_id"`
ImageIds []string `json:"image_ids"`
}
type Tag struct {
@@ -704,7 +890,8 @@ type Tag struct {
Category *TagCategory `json:"category"`
}
func (Tag) IsEditTarget() {}
func (Tag) IsEditTarget() {}
func (Tag) IsSceneDraftTag() {}
type TagCategory struct {
ID string `json:"id"`
@@ -742,11 +929,11 @@ type TagDestroyInput struct {
}
type TagEdit struct {
Name *string `json:"name"`
Description *string `json:"description"`
AddedAliases []string `json:"added_aliases"`
RemovedAliases []string `json:"removed_aliases"`
CategoryID *string `json:"category_id"`
Name *string `json:"name"`
Description *string `json:"description"`
AddedAliases []string `json:"added_aliases"`
RemovedAliases []string `json:"removed_aliases"`
Category *TagCategory `json:"category"`
}
func (TagEdit) IsEditDetails() {}
@@ -786,11 +973,12 @@ type TagUpdateInput struct {
type URL struct {
URL string `json:"url"`
Type string `json:"type"`
Site *Site `json:"site"`
}
type URLInput struct {
URL string `json:"url"`
Type string `json:"type"`
URL string `json:"url"`
SiteID string `json:"site_id"`
}
type User struct {
@@ -801,12 +989,11 @@ type User struct {
// Should not be visible to other users
Email *string `json:"email"`
// Should not be visible to other users
APIKey *string `json:"api_key"`
SuccessfulEdits int `json:"successful_edits"`
UnsuccessfulEdits int `json:"unsuccessful_edits"`
SuccessfulVotes int `json:"successful_votes"`
// Votes on unsuccessful edits
UnsuccessfulVotes int `json:"unsuccessful_votes"`
APIKey *string `json:"api_key"`
// Vote counts by type
VoteCount *UserVoteCount `json:"vote_count"`
// Edit counts by status
EditCount *UserEditCount `json:"edit_count"`
// Calls to the API from this user over a configurable time period
APICalls int `json:"api_calls"`
InvitedBy *User `json:"invited_by"`
@@ -834,6 +1021,16 @@ type UserDestroyInput struct {
ID string `json:"id"`
}
type UserEditCount struct {
Accepted int `json:"accepted"`
Rejected int `json:"rejected"`
Pending int `json:"pending"`
ImmediateAccepted int `json:"immediate_accepted"`
ImmediateRejected int `json:"immediate_rejected"`
Failed int `json:"failed"`
Canceled int `json:"canceled"`
}
type UserFilterType struct {
// Filter to search user name - assumes like query unless quoted
Name *string `json:"name"`
@@ -866,19 +1063,21 @@ type UserUpdateInput struct {
Email *string `json:"email"`
}
type UserVoteCount struct {
Abstain int `json:"abstain"`
Accept int `json:"accept"`
Reject int `json:"reject"`
ImmediateAccept int `json:"immediate_accept"`
ImmediateReject int `json:"immediate_reject"`
}
type Version struct {
Hash string `json:"hash"`
BuildTime string `json:"build_time"`
BuildType string `json:"build_type"`
Version string `json:"version"`
}
type VoteComment struct {
User *User `json:"user"`
Date *string `json:"date"`
Comment *string `json:"comment"`
Type *VoteTypeEnum `json:"type"`
}
type BreastTypeEnum string
const (
@@ -1435,6 +1634,7 @@ const (
RoleEnumInvite RoleEnum = "INVITE"
// May grant and rescind invite tokens and resind invite keys
RoleEnumManageInvites RoleEnum = "MANAGE_INVITES"
RoleEnumBot RoleEnum = "BOT"
)
var AllRoleEnum = []RoleEnum{
@@ -1445,11 +1645,12 @@ var AllRoleEnum = []RoleEnum{
RoleEnumAdmin,
RoleEnumInvite,
RoleEnumManageInvites,
RoleEnumBot,
}
func (e RoleEnum) IsValid() bool {
switch e {
case RoleEnumRead, RoleEnumVote, RoleEnumEdit, RoleEnumModify, RoleEnumAdmin, RoleEnumInvite, RoleEnumManageInvites:
case RoleEnumRead, RoleEnumVote, RoleEnumEdit, RoleEnumModify, RoleEnumAdmin, RoleEnumInvite, RoleEnumManageInvites, RoleEnumBot:
return true
}
return false
@@ -1605,6 +1806,49 @@ func (e TargetTypeEnum) MarshalGQL(w io.Writer) {
fmt.Fprint(w, strconv.Quote(e.String()))
}
type ValidSiteTypeEnum string
const (
ValidSiteTypeEnumPerformer ValidSiteTypeEnum = "PERFORMER"
ValidSiteTypeEnumScene ValidSiteTypeEnum = "SCENE"
ValidSiteTypeEnumStudio ValidSiteTypeEnum = "STUDIO"
)
var AllValidSiteTypeEnum = []ValidSiteTypeEnum{
ValidSiteTypeEnumPerformer,
ValidSiteTypeEnumScene,
ValidSiteTypeEnumStudio,
}
func (e ValidSiteTypeEnum) IsValid() bool {
switch e {
case ValidSiteTypeEnumPerformer, ValidSiteTypeEnumScene, ValidSiteTypeEnumStudio:
return true
}
return false
}
func (e ValidSiteTypeEnum) String() string {
return string(e)
}
func (e *ValidSiteTypeEnum) UnmarshalGQL(v interface{}) error {
str, ok := v.(string)
if !ok {
return fmt.Errorf("enums must be strings")
}
*e = ValidSiteTypeEnum(str)
if !e.IsValid() {
return fmt.Errorf("%s is not a valid ValidSiteTypeEnum", str)
}
return nil
}
func (e ValidSiteTypeEnum) MarshalGQL(w io.Writer) {
fmt.Fprint(w, strconv.Quote(e.String()))
}
type VoteStatusEnum string
const (
@@ -1613,6 +1857,8 @@ const (
VoteStatusEnumPending VoteStatusEnum = "PENDING"
VoteStatusEnumImmediateAccepted VoteStatusEnum = "IMMEDIATE_ACCEPTED"
VoteStatusEnumImmediateRejected VoteStatusEnum = "IMMEDIATE_REJECTED"
VoteStatusEnumFailed VoteStatusEnum = "FAILED"
VoteStatusEnumCanceled VoteStatusEnum = "CANCELED"
)
var AllVoteStatusEnum = []VoteStatusEnum{
@@ -1621,11 +1867,13 @@ var AllVoteStatusEnum = []VoteStatusEnum{
VoteStatusEnumPending,
VoteStatusEnumImmediateAccepted,
VoteStatusEnumImmediateRejected,
VoteStatusEnumFailed,
VoteStatusEnumCanceled,
}
func (e VoteStatusEnum) IsValid() bool {
switch e {
case VoteStatusEnumAccepted, VoteStatusEnumRejected, VoteStatusEnumPending, VoteStatusEnumImmediateAccepted, VoteStatusEnumImmediateRejected:
case VoteStatusEnumAccepted, VoteStatusEnumRejected, VoteStatusEnumPending, VoteStatusEnumImmediateAccepted, VoteStatusEnumImmediateRejected, VoteStatusEnumFailed, VoteStatusEnumCanceled:
return true
}
return false
@@ -1655,7 +1903,7 @@ func (e VoteStatusEnum) MarshalGQL(w io.Writer) {
type VoteTypeEnum string
const (
VoteTypeEnumComment VoteTypeEnum = "COMMENT"
VoteTypeEnumAbstain VoteTypeEnum = "ABSTAIN"
VoteTypeEnumAccept VoteTypeEnum = "ACCEPT"
VoteTypeEnumReject VoteTypeEnum = "REJECT"
// Immediately accepts the edit - bypassing the vote
@@ -1665,7 +1913,7 @@ const (
)
var AllVoteTypeEnum = []VoteTypeEnum{
VoteTypeEnumComment,
VoteTypeEnumAbstain,
VoteTypeEnumAccept,
VoteTypeEnumReject,
VoteTypeEnumImmediateAccept,
@@ -1674,7 +1922,7 @@ var AllVoteTypeEnum = []VoteTypeEnum{
func (e VoteTypeEnum) IsValid() bool {
switch e {
case VoteTypeEnumComment, VoteTypeEnumAccept, VoteTypeEnumReject, VoteTypeEnumImmediateAccept, VoteTypeEnumImmediateReject:
case VoteTypeEnumAbstain, VoteTypeEnumAccept, VoteTypeEnumReject, VoteTypeEnumImmediateAccept, VoteTypeEnumImmediateReject:
return true
}
return false

View File

@@ -1,14 +1,20 @@
package stashbox
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"os"
"strconv"
"strings"
"github.com/Yamashou/gqlgenc/client"
"github.com/Yamashou/gqlgenc/graphqljson"
"github.com/corona10/goimagehash"
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/match"
@@ -66,6 +72,18 @@ func (c Client) QueryStashBoxScene(ctx context.Context, queryStr string) ([]*mod
return ret, nil
}
func phashMatches(hash, other int64) bool {
// HACK - stash-box match distance is configurable. This needs to be fixed on
// the stash-box end.
const stashBoxDistance = 4
imageHash := goimagehash.NewImageHash(uint64(hash), goimagehash.PHash)
otherHash := goimagehash.NewImageHash(uint64(other), goimagehash.PHash)
distance, _ := imageHash.Distance(otherHash)
return distance <= stashBoxDistance
}
// FindStashBoxScenesByFingerprints queries stash-box for scenes using every
// scene's MD5/OSHASH checksum, or PHash, and returns results in the same order
// as the input slice.
@@ -78,6 +96,7 @@ func (c Client) FindStashBoxScenesByFingerprints(ctx context.Context, sceneIDs [
var fingerprints []*graphql.FingerprintQueryInput
// map fingerprints to their scene index
fpToScene := make(map[string][]int)
phashToScene := make(map[int64][]int)
if err := c.txnManager.WithReadTxn(ctx, func(r models.ReaderRepository) error {
qb := r.Scene()
@@ -115,6 +134,7 @@ func (c Client) FindStashBoxScenesByFingerprints(ctx context.Context, sceneIDs [
Algorithm: graphql.FingerprintAlgorithmPhash,
})
fpToScene[phashStr] = append(fpToScene[phashStr], index)
phashToScene[scene.Phash.Int64] = append(phashToScene[scene.Phash.Int64], index)
}
}
@@ -132,8 +152,8 @@ func (c Client) FindStashBoxScenesByFingerprints(ctx context.Context, sceneIDs [
ret := make([][]*models.ScrapedScene, len(sceneIDs))
for _, s := range allScenes {
var addedTo []int
for _, fp := range s.Fingerprints {
sceneIndexes := fpToScene[fp.Hash]
addScene := func(sceneIndexes []int) {
for _, index := range sceneIndexes {
if !utils.IntInclude(addedTo, index) {
addedTo = append(addedTo, index)
@@ -141,6 +161,24 @@ func (c Client) FindStashBoxScenesByFingerprints(ctx context.Context, sceneIDs [
}
}
}
for _, fp := range s.Fingerprints {
addScene(fpToScene[fp.Hash])
// HACK - we really need stash-box to return specific hash-to-result sets
if fp.Algorithm == graphql.FingerprintAlgorithmPhash.String() {
hash, err := utils.StringToPhash(fp.Hash)
if err != nil {
continue
}
for phash, sceneIndexes := range phashToScene {
if phashMatches(hash, phash) {
addScene(sceneIndexes)
}
}
}
}
}
return ret, nil
@@ -468,7 +506,7 @@ func findURL(urls []*graphql.URLFragment, urlType string) *string {
func enumToStringPtr(e fmt.Stringer, titleCase bool) *string {
if e != nil {
ret := e.String()
ret := strings.ReplaceAll(e.String(), "_", " ")
if titleCase {
ret = strings.Title(strings.ToLower(ret))
}
@@ -478,6 +516,28 @@ func enumToStringPtr(e fmt.Stringer, titleCase bool) *string {
return nil
}
func translateGender(gender *graphql.GenderEnum) *string {
var res models.GenderEnum
switch *gender {
case graphql.GenderEnumMale:
res = models.GenderEnumMale
case graphql.GenderEnumFemale:
res = models.GenderEnumFemale
case graphql.GenderEnumIntersex:
res = models.GenderEnumIntersex
case graphql.GenderEnumTransgenderFemale:
res = models.GenderEnumTransgenderFemale
case graphql.GenderEnumTransgenderMale:
res = models.GenderEnumTransgenderMale
}
if res != "" {
strVal := res.String()
return &strVal
}
return nil
}
func formatMeasurements(m graphql.MeasurementsFragment) *string {
if m.BandSize != nil && m.CupSize != nil && m.Hip != nil && m.Waist != nil {
ret := fmt.Sprintf("%d%s-%d-%d", *m.BandSize, *m.CupSize, *m.Waist, *m.Hip)
@@ -587,7 +647,7 @@ func performerFragmentToScrapedScenePerformer(p graphql.PerformerFragment) *mode
}
if p.Gender != nil {
sp.Gender = enumToStringPtr(p.Gender, false)
sp.Gender = translateGender(p.Gender)
}
if p.Ethnicity != nil {
@@ -731,3 +791,274 @@ func (c Client) FindStashBoxPerformerByName(ctx context.Context, name string) (*
return ret, nil
}
func (c Client) GetUser(ctx context.Context) (*graphql.Me, error) {
return c.client.Me(ctx)
}
func (c Client) SubmitSceneDraft(ctx context.Context, sceneID int, endpoint string, imagePath string) (*string, error) {
draft := graphql.SceneDraftInput{}
var image *os.File
if err := c.txnManager.WithReadTxn(ctx, func(r models.ReaderRepository) error {
qb := r.Scene()
pqb := r.Performer()
sqb := r.Studio()
scene, err := qb.Find(sceneID)
if err != nil {
return err
}
if scene.Title.Valid {
draft.Title = &scene.Title.String
}
if scene.Details.Valid {
draft.Details = &scene.Details.String
}
if len(strings.TrimSpace(scene.URL.String)) > 0 {
url := strings.TrimSpace(scene.URL.String)
draft.URL = &url
}
if scene.Date.Valid {
draft.Date = &scene.Date.String
}
if scene.StudioID.Valid {
studio, err := sqb.Find(int(scene.StudioID.Int64))
if err != nil {
return err
}
studioDraft := graphql.DraftEntityInput{
Name: studio.Name.String,
}
stashIDs, err := sqb.GetStashIDs(studio.ID)
if err != nil {
return err
}
for _, stashID := range stashIDs {
if stashID.Endpoint == endpoint {
studioDraft.ID = &stashID.StashID
break
}
}
draft.Studio = &studioDraft
}
fingerprints := []*graphql.FingerprintInput{}
if scene.OSHash.Valid && scene.Duration.Valid {
fingerprint := graphql.FingerprintInput{
Hash: scene.OSHash.String,
Algorithm: graphql.FingerprintAlgorithmOshash,
Duration: int(scene.Duration.Float64),
}
fingerprints = append(fingerprints, &fingerprint)
}
if scene.Checksum.Valid && scene.Duration.Valid {
fingerprint := graphql.FingerprintInput{
Hash: scene.Checksum.String,
Algorithm: graphql.FingerprintAlgorithmMd5,
Duration: int(scene.Duration.Float64),
}
fingerprints = append(fingerprints, &fingerprint)
}
if scene.Phash.Valid && scene.Duration.Valid {
fingerprint := graphql.FingerprintInput{
Hash: utils.PhashToString(scene.Phash.Int64),
Algorithm: graphql.FingerprintAlgorithmPhash,
Duration: int(scene.Duration.Float64),
}
fingerprints = append(fingerprints, &fingerprint)
}
draft.Fingerprints = fingerprints
scenePerformers, err := pqb.FindBySceneID(sceneID)
if err != nil {
return err
}
performers := []*graphql.DraftEntityInput{}
for _, p := range scenePerformers {
performerDraft := graphql.DraftEntityInput{
Name: p.Name.String,
}
stashIDs, err := pqb.GetStashIDs(p.ID)
if err != nil {
return err
}
for _, stashID := range stashIDs {
if stashID.Endpoint == endpoint {
performerDraft.ID = &stashID.StashID
break
}
}
performers = append(performers, &performerDraft)
}
draft.Performers = performers
var tags []*graphql.DraftEntityInput
sceneTags, err := r.Tag().FindBySceneID(scene.ID)
if err != nil {
return err
}
for _, tag := range sceneTags {
tags = append(tags, &graphql.DraftEntityInput{Name: tag.Name})
}
draft.Tags = tags
exists, _ := utils.FileExists(imagePath)
if exists {
file, err := os.Open(imagePath)
if err == nil {
image = file
}
}
return nil
}); err != nil {
return nil, err
}
var id *string
var ret graphql.SubmitSceneDraftPayload
err := c.submitDraft(ctx, graphql.SubmitSceneDraftQuery, draft, image, &ret)
id = ret.SubmitSceneDraft.ID
return id, err
}
func (c Client) SubmitPerformerDraft(ctx context.Context, performer *models.Performer, endpoint string) (*string, error) {
draft := graphql.PerformerDraftInput{}
var image io.Reader
if err := c.txnManager.WithReadTxn(ctx, func(r models.ReaderRepository) error {
pqb := r.Performer()
img, _ := pqb.GetImage(performer.ID)
if img != nil {
image = bytes.NewReader(img)
}
if performer.Name.Valid {
draft.Name = performer.Name.String
}
if performer.Birthdate.Valid {
draft.Birthdate = &performer.Birthdate.String
}
if performer.Country.Valid {
draft.Country = &performer.Country.String
}
if performer.Ethnicity.Valid {
draft.Ethnicity = &performer.Ethnicity.String
}
if performer.EyeColor.Valid {
draft.EyeColor = &performer.EyeColor.String
}
if performer.FakeTits.Valid {
draft.BreastType = &performer.FakeTits.String
}
if performer.Gender.Valid {
draft.Gender = &performer.Gender.String
}
if performer.HairColor.Valid {
draft.HairColor = &performer.HairColor.String
}
if performer.Height.Valid {
draft.Height = &performer.Height.String
}
if performer.Measurements.Valid {
draft.Measurements = &performer.Measurements.String
}
if performer.Piercings.Valid {
draft.Piercings = &performer.Piercings.String
}
if performer.Tattoos.Valid {
draft.Tattoos = &performer.Tattoos.String
}
if performer.Aliases.Valid {
draft.Aliases = &performer.Aliases.String
}
var urls []string
if len(strings.TrimSpace(performer.Twitter.String)) > 0 {
urls = append(urls, "https://twitter.com/"+strings.TrimSpace(performer.Twitter.String))
}
if len(strings.TrimSpace(performer.Instagram.String)) > 0 {
urls = append(urls, "https://instagram.com/"+strings.TrimSpace(performer.Instagram.String))
}
if len(strings.TrimSpace(performer.URL.String)) > 0 {
urls = append(urls, strings.TrimSpace(performer.URL.String))
}
if len(urls) > 0 {
draft.Urls = urls
}
return nil
}); err != nil {
return nil, err
}
var id *string
var ret graphql.SubmitPerformerDraftPayload
err := c.submitDraft(ctx, graphql.SubmitPerformerDraftQuery, draft, image, &ret)
id = ret.SubmitPerformerDraft.ID
return id, err
}
func (c *Client) submitDraft(ctx context.Context, query string, input interface{}, image io.Reader, ret interface{}) error {
vars := map[string]interface{}{
"input": input,
}
r := &client.Request{
Query: query,
Variables: vars,
OperationName: "",
}
requestBody, err := json.Marshal(r)
if err != nil {
return fmt.Errorf("encode: %w", err)
}
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
if err := writer.WriteField("operations", string(requestBody)); err != nil {
return err
}
if image != nil {
if err := writer.WriteField("map", "{ \"0\": [\"variables.input.image\"] }"); err != nil {
return err
}
part, _ := writer.CreateFormFile("0", "draft")
if _, err := io.Copy(part, image); err != nil {
return err
}
} else if err := writer.WriteField("map", "{}"); err != nil {
return err
}
writer.Close()
req, _ := http.NewRequestWithContext(ctx, "POST", c.box.Endpoint, body)
req.Header.Add("Content-Type", writer.FormDataContentType())
req.Header.Set("ApiKey", c.box.APIKey)
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if err := graphqljson.Unmarshal(resp.Body, ret); err != nil {
return err
}
return err
}

View File

@@ -5,6 +5,7 @@ import (
"context"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os"
@@ -87,7 +88,7 @@ func loadURL(ctx context.Context, loadURL string, client *http.Client, scraperCo
// func urlFromCDP uses chrome cdp and DOM to load and process the url
// if remote is set as true in the scraperConfig it will try to use localhost:9222
// else it will look for google-chrome in path
func urlFromCDP(ctx context.Context, url string, driverOptions scraperDriverOptions, globalConfig GlobalConfig) (io.Reader, error) {
func urlFromCDP(ctx context.Context, urlCDP string, driverOptions scraperDriverOptions, globalConfig GlobalConfig) (io.Reader, error) {
if !driverOptions.UseCDP {
return nil, fmt.Errorf("url shouldn't be fetched through CDP")
@@ -107,6 +108,33 @@ func urlFromCDP(ctx context.Context, url string, driverOptions scraperDriverOpti
if isCDPPathHTTP(globalConfig) || isCDPPathWS(globalConfig) {
remote := cdpPath
// -------------------------------------------------------------------
// #1023
// when chromium is listening over RDP it only accepts requests
// with host headers that are either IPs or `localhost`
cdpURL, err := url.Parse(remote)
if err != nil {
return nil, fmt.Errorf("failed to parse CDP Path: %v", err)
}
hostname := cdpURL.Hostname()
if hostname != "localhost" {
if net.ParseIP(hostname) == nil { // not an IP
addr, err := net.LookupIP(hostname)
if err != nil || len(addr) == 0 { // can not resolve to IP
return nil, fmt.Errorf("CDP: hostname <%s> can not be resolved", hostname)
}
if len(addr[0]) == 0 { // nil IP
return nil, fmt.Errorf("CDP: hostname <%s> resolved to nil", hostname)
}
// addr is a valid IP
// replace the host part of the cdpURL with the IP
cdpURL.Host = strings.Replace(cdpURL.Host, hostname, addr[0].String(), 1)
// use that for remote
remote = cdpURL.String()
}
}
// --------------------------------------------------------------------
// if CDPPath is http(s) then we need to get the websocket URL
if isCDPPathHTTP(globalConfig) {
var err error
@@ -150,7 +178,7 @@ func urlFromCDP(ctx context.Context, url string, driverOptions scraperDriverOpti
setCDPCookies(driverOptions),
printCDPCookies(driverOptions, "Cookies found"),
network.SetExtraHTTPHeaders(network.Headers(headers)),
chromedp.Navigate(url),
chromedp.Navigate(urlCDP),
chromedp.Sleep(sleepDuration),
setCDPClicks(driverOptions),
chromedp.OuterHTML("html", &res, chromedp.ByQuery),

View File

@@ -16,12 +16,6 @@ func (e ExternalAccessError) Error() string {
return fmt.Sprintf("stash accessed from external IP %s", net.IP(e).String())
}
type UntrustedProxyError net.IP
func (e UntrustedProxyError) Error() string {
return fmt.Sprintf("untrusted proxy %s", net.IP(e).String())
}
func CheckAllowPublicWithoutAuth(c *config.Instance, r *http.Request) error {
if !c.HasCredentials() && !c.GetDangerousAllowPublicWithoutAuth() && !c.IsNewSystem() {
requestIPString, _, err := net.SplitHostPort(r.RemoteAddr)
@@ -42,43 +36,21 @@ func CheckAllowPublicWithoutAuth(c *config.Instance, r *http.Request) error {
if r.Header.Get("X-FORWARDED-FOR") != "" {
// Request was proxied
trustedProxies := c.GetTrustedProxies()
proxyChain := strings.Split(r.Header.Get("X-FORWARDED-FOR"), ", ")
if len(trustedProxies) == 0 {
// validate proxies against local network only
if !isLocalIP(requestIP) {
return ExternalAccessError(requestIP)
} else {
// Safe to validate X-Forwarded-For
for i := range proxyChain {
ip := net.ParseIP(proxyChain[i])
if !isLocalIP(ip) {
return ExternalAccessError(ip)
}
}
}
// validate proxies against local network only
if !isLocalIP(requestIP) {
return ExternalAccessError(requestIP)
} else {
// validate proxies against trusted proxies list
if isIPTrustedProxy(requestIP, trustedProxies) {
// Safe to validate X-Forwarded-For
// validate backwards, as only the last one is not attacker-controlled
for i := len(proxyChain) - 1; i >= 0; i-- {
ip := net.ParseIP(proxyChain[i])
if i == 0 {
// last entry is originating device, check if from the public internet
if !isLocalIP(ip) {
return ExternalAccessError(ip)
}
} else if !isIPTrustedProxy(ip, trustedProxies) {
return UntrustedProxyError(ip)
}
// Safe to validate X-Forwarded-For
for i := range proxyChain {
ip := net.ParseIP(proxyChain[i])
if !isLocalIP(ip) {
return ExternalAccessError(ip)
}
} else {
// Proxy not on safe proxy list
return UntrustedProxyError(requestIP)
}
}
} else if !isLocalIP(requestIP) { // request was not proxied
return ExternalAccessError(requestIP)
}
@@ -104,18 +76,6 @@ func isLocalIP(requestIP net.IP) bool {
return requestIP.IsPrivate() || requestIP.IsLoopback() || requestIP.IsLinkLocalUnicast() || cgNatAddrSpace.Contains(requestIP)
}
func isIPTrustedProxy(ip net.IP, trustedProxies []string) bool {
if len(trustedProxies) == 0 {
return isLocalIP(ip)
}
for _, v := range trustedProxies {
if ip.Equal(net.ParseIP(v)) {
return true
}
}
return false
}
func LogExternalAccessError(err ExternalAccessError) {
logger.Errorf("Stash has been accessed from the internet (public IP %s), without authentication. \n"+
"This is extremely dangerous! The whole world can see your stash page and browse your files! \n"+

View File

@@ -66,7 +66,7 @@ func TestCheckAllowPublicWithoutAuth(t *testing.T) {
}
{
// X-FORWARDED-FOR without trusted proxy
// X-FORWARDED-FOR
testCases := []struct {
proxyChain string
err error
@@ -91,39 +91,6 @@ func TestCheckAllowPublicWithoutAuth(t *testing.T) {
}
}
{
// X-FORWARDED-FOR with trusted proxy
var trustedProxies = []string{"8.8.8.8", "4.4.4.4"}
c.Set(config.TrustedProxies, trustedProxies)
testCases := []struct {
address string
proxyChain string
err error
}{
{"192.168.1.1:8080", "192.168.1.1, 192.168.1.2, 100.64.0.1, 127.0.0.1", &UntrustedProxyError{}},
{"8.8.8.8:8080", "192.168.1.2, 127.0.0.1", &UntrustedProxyError{}},
{"8.8.8.8:8080", "193.168.1.1, 4.4.4.4", &ExternalAccessError{}},
{"8.8.8.8:8080", "4.4.4.4", &ExternalAccessError{}},
{"8.8.8.8:8080", "192.168.1.1, 4.4.4.4a", &UntrustedProxyError{}},
{"8.8.8.8:8080", "192.168.1.1a, 4.4.4.4", &ExternalAccessError{}},
{"8.8.8.8:8080", "192.168.1.1, 4.4.4.4", nil},
{"8.8.8.8:8080", "192.168.1.1", nil},
}
header := make(http.Header)
for i, tc := range testCases {
header.Set("X-FORWARDED-FOR", tc.proxyChain)
r := &http.Request{
RemoteAddr: tc.address,
Header: header,
}
doTest(i, r, tc.err)
}
}
{
// test invalid request IPs
invalidIPs := []string{"192.168.1.a:9999", "192.168.1.1"}
@@ -134,11 +101,6 @@ func TestCheckAllowPublicWithoutAuth(t *testing.T) {
}
err := CheckAllowPublicWithoutAuth(c, r)
if errors.As(err, &UntrustedProxyError{}) || errors.As(err, &ExternalAccessError{}) {
t.Errorf("[%s]: unexpected error: %v", remoteAddr, err)
continue
}
if err == nil {
t.Errorf("[%s]: expected error", remoteAddr)
continue

View File

@@ -391,13 +391,7 @@ func stringCriterionHandler(c *models.StringCriterionInput, column string) crite
case models.CriterionModifierNotNull:
f.addWhere("(" + column + " IS NOT NULL AND TRIM(" + column + ") != '')")
default:
clause, count := getSimpleCriterionClause(modifier, "?")
if count == 1 {
f.addWhere(column+" "+clause, c.Value)
} else {
f.addWhere(column + " " + clause)
}
panic("unsupported string filter modifier")
}
}
}

View File

@@ -220,6 +220,8 @@ func (qb *galleryQueryBuilder) makeFilter(galleryFilter *models.GalleryFilterTyp
query.handleCriterion(galleryPerformerTagsCriterionHandler(qb, galleryFilter.PerformerTags))
query.handleCriterion(galleryAverageResolutionCriterionHandler(qb, galleryFilter.AverageResolution))
query.handleCriterion(galleryImageCountCriterionHandler(qb, galleryFilter.ImageCount))
query.handleCriterion(galleryPerformerFavoriteCriterionHandler(galleryFilter.PerformerFavorite))
query.handleCriterion(galleryPerformerAgeCriterionHandler(galleryFilter.PerformerAge))
return query
}
@@ -421,6 +423,43 @@ INNER JOIN (` + valuesClause + `) t ON t.column2 = pt.tag_id
}
}
func galleryPerformerFavoriteCriterionHandler(performerfavorite *bool) criterionHandlerFunc {
return func(f *filterBuilder) {
if performerfavorite != nil {
f.addLeftJoin("performers_galleries", "", "galleries.id = performers_galleries.gallery_id")
if *performerfavorite {
// contains at least one favorite
f.addLeftJoin("performers", "", "performers.id = performers_galleries.performer_id")
f.addWhere("performers.favorite = 1")
} else {
// contains zero favorites
f.addLeftJoin(`(SELECT performers_galleries.gallery_id as id FROM performers_galleries
JOIN performers ON performers.id = performers_galleries.performer_id
GROUP BY performers_galleries.gallery_id HAVING SUM(performers.favorite) = 0)`, "nofaves", "galleries.id = nofaves.id")
f.addWhere("performers_galleries.gallery_id IS NULL OR nofaves.id IS NOT NULL")
}
}
}
}
func galleryPerformerAgeCriterionHandler(performerAge *models.IntCriterionInput) criterionHandlerFunc {
return func(f *filterBuilder) {
if performerAge != nil {
f.addInnerJoin("performers_galleries", "", "galleries.id = performers_galleries.gallery_id")
f.addInnerJoin("performers", "", "performers_galleries.performer_id = performers.id")
f.addWhere("galleries.date != '' AND performers.birthdate != ''")
f.addWhere("galleries.date IS NOT NULL AND performers.birthdate IS NOT NULL")
f.addWhere("galleries.date != '0001-01-01' AND performers.birthdate != '0001-01-01'")
ageCalc := "cast(strftime('%Y.%m%d', galleries.date) - strftime('%Y.%m%d', performers.birthdate) as int)"
whereClause, args := getIntWhereClause(ageCalc, performerAge.Modifier, performerAge.Value, performerAge.Value2)
f.addWhere(whereClause, args...)
}
}
}
func galleryAverageResolutionCriterionHandler(qb *galleryQueryBuilder, resolution *models.ResolutionCriterionInput) criterionHandlerFunc {
return func(f *filterBuilder) {
if resolution != nil && resolution.Value.IsValid() {

View File

@@ -248,6 +248,7 @@ func (qb *imageQueryBuilder) makeFilter(imageFilter *models.ImageFilterType) *fi
query.handleCriterion(imagePerformerCountCriterionHandler(qb, imageFilter.PerformerCount))
query.handleCriterion(imageStudioCriterionHandler(qb, imageFilter.Studios))
query.handleCriterion(imagePerformerTagsCriterionHandler(qb, imageFilter.PerformerTags))
query.handleCriterion(imagePerformerFavoriteCriterionHandler(imageFilter.PerformerFavorite))
return query
}
@@ -446,6 +447,26 @@ func imagePerformerCountCriterionHandler(qb *imageQueryBuilder, performerCount *
return h.handler(performerCount)
}
func imagePerformerFavoriteCriterionHandler(performerfavorite *bool) criterionHandlerFunc {
return func(f *filterBuilder) {
if performerfavorite != nil {
f.addLeftJoin("performers_images", "", "images.id = performers_images.image_id")
if *performerfavorite {
// contains at least one favorite
f.addLeftJoin("performers", "", "performers.id = performers_images.performer_id")
f.addWhere("performers.favorite = 1")
} else {
// contains zero favorites
f.addLeftJoin(`(SELECT performers_images.image_id as id FROM performers_images
JOIN performers ON performers.id = performers_images.performer_id
GROUP BY performers_images.image_id HAVING SUM(performers.favorite) = 0)`, "nofaves", "images.id = nofaves.id")
f.addWhere("performers_images.image_id IS NULL OR nofaves.id IS NOT NULL")
}
}
}
}
func imageStudioCriterionHandler(qb *imageQueryBuilder, studios *models.HierarchicalMultiCriterionInput) criterionHandlerFunc {
h := hierarchicalMultiCriterionHandlerBuilder{
tx: qb.tx,

View File

@@ -21,6 +21,12 @@ WHERE performers_tags.tag_id = ?
GROUP BY performers_tags.performer_id
`
// KNOWN ISSUE: using \p{L} to find single unicode character names results in
// very slow queries.
// Suggested solution will be to cache single-character names and not include it
// in the autotag query.
const singleFirstCharacterRegex = `^[\w][.\-_ ]`
type performerQueryBuilder struct {
repository
}
@@ -184,7 +190,7 @@ func (qb *performerQueryBuilder) QueryForAutoTag(words []string) ([]*models.Perf
var args []interface{}
whereClauses = append(whereClauses, "name regexp ?")
args = append(args, "^[\\w][.\\-_ ]")
args = append(args, singleFirstCharacterRegex)
for _, w := range words {
whereClauses = append(whereClauses, "name like ?")

View File

@@ -171,6 +171,8 @@ func (r *repository) runSumQuery(query string, args []interface{}) (float64, err
}
func (r *repository) queryFunc(query string, args []interface{}, single bool, f func(rows *sqlx.Rows) error) error {
logger.Tracef("SQL: %s, args: %v", query, args)
rows, err := r.tx.Queryx(query, args...)
if err != nil && !errors.Is(err, sql.ErrNoRows) {

View File

@@ -392,6 +392,9 @@ func (qb *sceneQueryBuilder) makeFilter(sceneFilter *models.SceneFilterType) *fi
query.handleCriterion(sceneStudioCriterionHandler(qb, sceneFilter.Studios))
query.handleCriterion(sceneMoviesCriterionHandler(qb, sceneFilter.Movies))
query.handleCriterion(scenePerformerTagsCriterionHandler(qb, sceneFilter.PerformerTags))
query.handleCriterion(scenePerformerFavoriteCriterionHandler(sceneFilter.PerformerFavorite))
query.handleCriterion(scenePerformerAgeCriterionHandler(sceneFilter.PerformerAge))
query.handleCriterion(scenePhashDuplicatedCriterionHandler(sceneFilter.Duplicated))
return query
}
@@ -504,6 +507,21 @@ func phashCriterionHandler(phashFilter *models.StringCriterionInput) criterionHa
}
}
func scenePhashDuplicatedCriterionHandler(duplicatedFilter *models.PHashDuplicationCriterionInput) criterionHandlerFunc {
return func(f *filterBuilder) {
// TODO: Wishlist item: Implement Distance matching
if duplicatedFilter != nil {
var v string
if *duplicatedFilter.Duplicated {
v = ">"
} else {
v = "="
}
f.addInnerJoin("(SELECT id FROM scenes JOIN (SELECT phash FROM scenes GROUP BY phash HAVING COUNT(phash) "+v+" 1) dupes on scenes.phash = dupes.phash)", "scph", "scenes.id = scph.id")
}
}
}
func durationCriterionHandler(durationFilter *models.IntCriterionInput, column string) criterionHandlerFunc {
return func(f *filterBuilder) {
if durationFilter != nil {
@@ -642,6 +660,43 @@ func scenePerformerCountCriterionHandler(qb *sceneQueryBuilder, performerCount *
return h.handler(performerCount)
}
func scenePerformerFavoriteCriterionHandler(performerfavorite *bool) criterionHandlerFunc {
return func(f *filterBuilder) {
if performerfavorite != nil {
f.addLeftJoin("performers_scenes", "", "scenes.id = performers_scenes.scene_id")
if *performerfavorite {
// contains at least one favorite
f.addLeftJoin("performers", "", "performers.id = performers_scenes.performer_id")
f.addWhere("performers.favorite = 1")
} else {
// contains zero favorites
f.addLeftJoin(`(SELECT performers_scenes.scene_id as id FROM performers_scenes
JOIN performers ON performers.id = performers_scenes.performer_id
GROUP BY performers_scenes.scene_id HAVING SUM(performers.favorite) = 0)`, "nofaves", "scenes.id = nofaves.id")
f.addWhere("performers_scenes.scene_id IS NULL OR nofaves.id IS NOT NULL")
}
}
}
}
func scenePerformerAgeCriterionHandler(performerAge *models.IntCriterionInput) criterionHandlerFunc {
return func(f *filterBuilder) {
if performerAge != nil {
f.addInnerJoin("performers_scenes", "", "scenes.id = performers_scenes.scene_id")
f.addInnerJoin("performers", "", "performers_scenes.performer_id = performers.id")
f.addWhere("scenes.date != '' AND performers.birthdate != ''")
f.addWhere("scenes.date IS NOT NULL AND performers.birthdate IS NOT NULL")
f.addWhere("scenes.date != '0001-01-01' AND performers.birthdate != '0001-01-01'")
ageCalc := "cast(strftime('%Y.%m%d', scenes.date) - strftime('%Y.%m%d', performers.birthdate) as int)"
whereClause, args := getIntWhereClause(ageCalc, performerAge.Modifier, performerAge.Value, performerAge.Value2)
f.addWhere(whereClause, args...)
}
}
}
func sceneStudioCriterionHandler(qb *sceneQueryBuilder, studios *models.HierarchicalMultiCriterionInput) criterionHandlerFunc {
h := hierarchicalMultiCriterionHandlerBuilder{
tx: qb.tx,

View File

@@ -9,7 +9,6 @@ import (
"strconv"
"strings"
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/models"
)
@@ -66,6 +65,10 @@ func getSort(sort string, direction string, tableName string) string {
case strings.Compare(sort, "filesize") == 0:
colName := getColumn(tableName, "size")
return " ORDER BY cast(" + colName + " as integer) " + direction
case strings.Compare(sort, "perceptual_similarity") == 0:
colName := getColumn(tableName, "phash")
secondaryColName := getColumn(tableName, "size")
return " ORDER BY " + colName + " " + direction + ", " + secondaryColName + " DESC"
case strings.HasPrefix(sort, randomSeedPrefix):
// seed as a parameter from the UI
// turn the provided seed into a float
@@ -149,54 +152,39 @@ func getInBinding(length int) string {
return "(" + bindings + ")"
}
func getSimpleCriterionClause(criterionModifier models.CriterionModifier, rhs string) (string, int) {
if modifier := criterionModifier.String(); criterionModifier.IsValid() {
switch modifier {
case "EQUALS":
return "= " + rhs, 1
case "NOT_EQUALS":
return "!= " + rhs, 1
case "GREATER_THAN":
return "> " + rhs, 1
case "LESS_THAN":
return "< " + rhs, 1
case "IS_NULL":
return "IS NULL", 0
case "NOT_NULL":
return "IS NOT NULL", 0
case "BETWEEN":
return "BETWEEN (" + rhs + ") AND (" + rhs + ")", 2
case "NOT_BETWEEN":
return "NOT BETWEEN (" + rhs + ") AND (" + rhs + ")", 2
default:
logger.Errorf("todo")
return "= ?", 1 // TODO
}
}
return "= ?", 1 // TODO
func getIntCriterionWhereClause(column string, input models.IntCriterionInput) (string, []interface{}) {
return getIntWhereClause(column, input.Modifier, input.Value, input.Value2)
}
func getIntCriterionWhereClause(column string, input models.IntCriterionInput) (string, []interface{}) {
binding, _ := getSimpleCriterionClause(input.Modifier, "?")
var args []interface{}
switch input.Modifier {
case "EQUALS", "NOT_EQUALS":
args = []interface{}{input.Value}
case "LESS_THAN":
args = []interface{}{input.Value}
case "GREATER_THAN":
args = []interface{}{input.Value}
case "BETWEEN", "NOT_BETWEEN":
upper := 0
if input.Value2 != nil {
upper = *input.Value2
}
args = []interface{}{input.Value, upper}
func getIntWhereClause(column string, modifier models.CriterionModifier, value int, upper *int) (string, []interface{}) {
if upper == nil {
u := 0
upper = &u
}
return column + " " + binding, args
args := []interface{}{value}
betweenArgs := []interface{}{value, *upper}
switch modifier {
case models.CriterionModifierIsNull:
return fmt.Sprintf("%s IS NULL", column), nil
case models.CriterionModifierNotNull:
return fmt.Sprintf("%s IS NOT NULL", column), nil
case models.CriterionModifierEquals:
return fmt.Sprintf("%s = ?", column), args
case models.CriterionModifierNotEquals:
return fmt.Sprintf("%s != ?", column), args
case models.CriterionModifierBetween:
return fmt.Sprintf("%s BETWEEN ? AND ?", column), betweenArgs
case models.CriterionModifierNotBetween:
return fmt.Sprintf("%s NOT BETWEEN ? AND ?", column), betweenArgs
case models.CriterionModifierLessThan:
return fmt.Sprintf("%s < ?", column), args
case models.CriterionModifierGreaterThan:
return fmt.Sprintf("%s > ?", column), args
}
panic("unsupported int modifier type")
}
// returns where clause and having clause

View File

@@ -145,7 +145,6 @@ func (qb *studioQueryBuilder) QueryForAutoTag(words []string) ([]*models.Studio,
var args []interface{}
// always include names that begin with a single character
singleFirstCharacterRegex := "^[\\w][.\\-_ ]"
whereClauses = append(whereClauses, "studios.name regexp ? OR COALESCE(studio_aliases.alias, '') regexp ?")
args = append(args, singleFirstCharacterRegex, singleFirstCharacterRegex)

View File

@@ -236,7 +236,6 @@ func (qb *tagQueryBuilder) QueryForAutoTag(words []string) ([]*models.Tag, error
var args []interface{}
// always include names that begin with a single character
singleFirstCharacterRegex := "^[\\w][.\\-_ ]"
whereClauses = append(whereClauses, "tags.name regexp ? OR COALESCE(tag_aliases.alias, '') regexp ?")
args = append(args, singleFirstCharacterRegex, singleFirstCharacterRegex)

View File

@@ -1,7 +1,6 @@
#!/bin/bash
# "stashapp/compiler:develop" "stashapp/compiler:4"
COMPILER_CONTAINER="stashapp/compiler:5"
COMPILER_CONTAINER="stashapp/compiler:6"
BUILD_DATE=`go run -mod=vendor scripts/getDate.go`
GITHASH=`git rev-parse --short HEAD`
@@ -10,8 +9,8 @@ STASH_VERSION=`git describe --tags --exclude latest_develop`
SETENV="BUILD_DATE=\"$BUILD_DATE\" GITHASH=$GITHASH STASH_VERSION=\"$STASH_VERSION\""
SETUP="export CGO_ENABLED=1;"
WINDOWS="echo '=== Building Windows binary ==='; $SETENV make cross-compile-windows;"
DARWIN="echo '=== Building OSX binary ==='; $SETENV make cross-compile-osx-intel;"
DARWIN_ARM64="echo '=== Building OSX (arm64) binary ==='; $SETENV make cross-compile-osx-applesilicon;"
DARWIN="echo '=== Building OSX binary ==='; $SETENV make cross-compile-macos-intel;"
DARWIN_ARM64="echo '=== Building OSX (arm64) binary ==='; $SETENV make cross-compile-macos-applesilicon;"
LINUX_AMD64="echo '=== Building Linux (amd64) binary ==='; $SETENV make cross-compile-linux;"
LINUX_ARM64v8="echo '=== Building Linux (armv8/arm64) binary ==='; $SETENV make cross-compile-linux-arm64v8;"
LINUX_ARM32v7="echo '=== Building Linux (armv7/armhf) binary ==='; $SETENV make cross-compile-linux-arm32v7;"

50
scripts/generate_icons.sh Executable file
View 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

View 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>

Binary file not shown.

BIN
scripts/stash-logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 222 KiB

View File

@@ -3,7 +3,8 @@
<head>
<base href="/">
<meta charset="utf-8" />
<link rel="shortcut icon" href="/favicon.ico" />
<link rel="shortcut icon" href="favicon.ico" />
<link rel="apple-touch-icon" href="apple-touch-icon.png" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1"
@@ -13,7 +14,7 @@
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="/%BASE_URL%/manifest.json" />
<link rel="manifest" href="manifest.json" />
<title>Stash</title>
<script>window.STASH_BASE_URL = "/%BASE_URL%/"</script>
</head>

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View File

@@ -8,7 +8,8 @@
"type": "image/x-icon"
}
],
"start_url": ".",
"start_url": "/",
"scope": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"

View File

@@ -15,6 +15,7 @@ import V090 from "./versions/v090.md";
import V0100 from "./versions/v0100.md";
import V0110 from "./versions/v0110.md";
import V0120 from "./versions/v0120.md";
import V0130 from "./versions/v0130.md";
import { MarkdownPage } from "../Shared/MarkdownPage";
// to avoid use of explicit any
@@ -53,9 +54,9 @@ const Changelog: React.FC = () => {
// after new release:
// add entry to releases, using the current* fields
// then update the current fields.
const currentVersion = stashVersion || "v0.12.0";
const currentVersion = stashVersion || "v0.13.0";
const currentDate = buildDate;
const currentPage = V0120;
const currentPage = V0130;
const releases: IStashRelease[] = [
{
@@ -64,9 +65,14 @@ const Changelog: React.FC = () => {
page: currentPage,
defaultOpen: true,
},
{
version: "v0.12.0",
date: "2021-12-29",
page: V0120,
},
{
version: "v0.11.0",
date: "2021-11-15",
date: "2021-11-16",
page: V0110,
},
{

View 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))

View File

@@ -51,12 +51,14 @@ export const GenerateDialog: React.FC<ISceneGenerateDialog> = ({
return;
}
// combine the defaults with the system preview generation settings
if (configuration?.defaults.generate) {
const { generate } = configuration.defaults;
setOptions(withoutTypename(generate));
setConfigRead(true);
} else if (configuration?.general) {
// backwards compatibility
}
if (configuration?.general) {
const { general } = configuration;
setOptions((existing) => ({
...existing,

View 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>
);
};

View File

@@ -9,6 +9,14 @@ import { useToast } from "src/hooks";
import { FormUtils } from "src/utils";
import MultiSet from "../Shared/MultiSet";
import { RatingStars } from "../Scenes/SceneDetails/RatingStars";
import {
getAggregateInputIDs,
getAggregateInputValue,
getAggregatePerformerIds,
getAggregateRating,
getAggregateStudioId,
getAggregateTagIds,
} from "src/utils/bulkUpdate";
interface IListOperationProps {
selected: GQL.SlimGalleryDataFragment[];
@@ -42,22 +50,12 @@ export const EditGalleriesDialog: React.FC<IListOperationProps> = (
const checkboxRef = React.createRef<HTMLInputElement>();
function makeBulkUpdateIds(
ids: string[],
mode: GQL.BulkUpdateIdMode
): GQL.BulkUpdateIds {
return {
mode,
ids,
};
}
function getGalleryInput(): GQL.BulkGalleryUpdateInput {
// need to determine what we are actually setting on each gallery
const aggregateRating = getRating(props.selected);
const aggregateStudioId = getStudioId(props.selected);
const aggregatePerformerIds = getPerformerIds(props.selected);
const aggregateTagIds = getTagIds(props.selected);
const aggregateRating = getAggregateRating(props.selected);
const aggregateStudioId = getAggregateStudioId(props.selected);
const aggregatePerformerIds = getAggregatePerformerIds(props.selected);
const aggregateTagIds = getAggregateTagIds(props.selected);
const galleryInput: GQL.BulkGalleryUpdateInput = {
ids: props.selected.map((gallery) => {
@@ -65,67 +63,22 @@ export const EditGalleriesDialog: React.FC<IListOperationProps> = (
}),
};
// if rating is undefined
if (rating === undefined) {
// and all galleries have the same rating, then we are unsetting the rating.
if (aggregateRating) {
// null to unset rating
galleryInput.rating = null;
}
// otherwise not setting the rating
} else {
// if rating is set, then we are setting the rating for all
galleryInput.rating = rating;
}
galleryInput.rating = getAggregateInputValue(rating, aggregateRating);
galleryInput.studio_id = getAggregateInputValue(
studioId,
aggregateStudioId
);
// if studioId is undefined
if (studioId === undefined) {
// and all galleries have the same studioId,
// then unset the studioId, otherwise ignoring studioId
if (aggregateStudioId) {
// null to unset studio_id
galleryInput.studio_id = null;
}
} else {
// if studioId is set, then we are setting it
galleryInput.studio_id = studioId;
}
// if performerIds are empty
if (
performerMode === GQL.BulkUpdateIdMode.Set &&
(!performerIds || performerIds.length === 0)
) {
// and all galleries have the same ids,
if (aggregatePerformerIds.length > 0) {
// then unset the performerIds, otherwise ignore
galleryInput.performer_ids = makeBulkUpdateIds(
performerIds || [],
performerMode
);
}
} else {
// if performerIds non-empty, then we are setting them
galleryInput.performer_ids = makeBulkUpdateIds(
performerIds || [],
performerMode
);
}
// if tagIds non-empty, then we are setting them
if (
tagMode === GQL.BulkUpdateIdMode.Set &&
(!tagIds || tagIds.length === 0)
) {
// and all galleries have the same ids,
if (aggregateTagIds.length > 0) {
// then unset the tagIds, otherwise ignore
galleryInput.tag_ids = makeBulkUpdateIds(tagIds || [], tagMode);
}
} else {
// if tagIds non-empty, then we are setting them
galleryInput.tag_ids = makeBulkUpdateIds(tagIds || [], tagMode);
}
galleryInput.performer_ids = getAggregateInputIDs(
performerMode,
performerIds,
aggregatePerformerIds
);
galleryInput.tag_ids = getAggregateInputIDs(
tagMode,
tagIds,
aggregateTagIds
);
if (organized !== undefined) {
galleryInput.organized = organized;
@@ -157,85 +110,6 @@ export const EditGalleriesDialog: React.FC<IListOperationProps> = (
setIsUpdating(false);
}
function getRating(state: GQL.SlimGalleryDataFragment[]) {
let ret: number | undefined;
let first = true;
state.forEach((gallery) => {
if (first) {
ret = gallery.rating ?? undefined;
first = false;
} else if (ret !== gallery.rating) {
ret = undefined;
}
});
return ret;
}
function getStudioId(state: GQL.SlimGalleryDataFragment[]) {
let ret: string | undefined;
let first = true;
state.forEach((gallery) => {
if (first) {
ret = gallery?.studio?.id;
first = false;
} else {
const studio = gallery?.studio?.id;
if (ret !== studio) {
ret = undefined;
}
}
});
return ret;
}
function getPerformerIds(state: GQL.SlimGalleryDataFragment[]) {
let ret: string[] = [];
let first = true;
state.forEach((gallery) => {
if (first) {
ret = gallery.performers
? gallery.performers.map((p) => p.id).sort()
: [];
first = false;
} else {
const perfIds = gallery.performers
? gallery.performers.map((p) => p.id).sort()
: [];
if (!_.isEqual(ret, perfIds)) {
ret = [];
}
}
});
return ret;
}
function getTagIds(state: GQL.SlimGalleryDataFragment[]) {
let ret: string[] = [];
let first = true;
state.forEach((gallery) => {
if (first) {
ret = gallery.tags ? gallery.tags.map((t) => t.id).sort() : [];
first = false;
} else {
const tIds = gallery.tags ? gallery.tags.map((t) => t.id).sort() : [];
if (!_.isEqual(ret, tIds)) {
ret = [];
}
}
});
return ret;
}
useEffect(() => {
const state = props.selected;
let updateRating: number | undefined;

View File

@@ -111,7 +111,7 @@ export const GalleryCard: React.FC<IProps> = (props) => {
function maybeRenderOrganized() {
if (props.gallery.organized) {
return (
<div>
<div className="organized">
<Button className="minimal">
<Icon icon="box" />
</Button>

View File

@@ -21,7 +21,9 @@ export const GalleryDetailPanel: React.FC<IGalleryDetailProps> = ({
if (!gallery.details) return;
return (
<>
<h6>Details</h6>
<h6>
<FormattedMessage id="details" />
</h6>
<p className="pre">{gallery.details}</p>
</>
);
@@ -34,7 +36,12 @@ export const GalleryDetailPanel: React.FC<IGalleryDetailProps> = ({
));
return (
<>
<h6>Tags</h6>
<h6>
<FormattedMessage
id="countables.tags"
values={{ count: gallery.tags.length }}
/>
</h6>
{tags}
</>
);
@@ -53,7 +60,12 @@ export const GalleryDetailPanel: React.FC<IGalleryDetailProps> = ({
return (
<>
<h6>Performers</h6>
<h6>
<FormattedMessage
id="countables.performers"
values={{ count: gallery.performers.length }}
/>
</h6>
<div className="row justify-content-center gallery-performers">
{cards}
</div>
@@ -83,18 +95,19 @@ export const GalleryDetailPanel: React.FC<IGalleryDetailProps> = ({
) : undefined}
{gallery.rating ? (
<h6>
Rating: <RatingStars value={gallery.rating} />
<FormattedMessage id="rating" />:{" "}
<RatingStars value={gallery.rating} />
</h6>
) : (
""
)}
<h6>
<FormattedMessage id="created_at" />:{" "}
{TextUtils.formatDate(intl, gallery.created_at)}{" "}
{TextUtils.formatDateTime(intl, gallery.created_at)}{" "}
</h6>
<h6>
<FormattedMessage id="updated_at" />:{" "}
{TextUtils.formatDate(intl, gallery.updated_at)}{" "}
{TextUtils.formatDateTime(intl, gallery.updated_at)}{" "}
</h6>
</div>
{gallery.studio && (

View File

@@ -23,7 +23,7 @@ export const GalleryFileInfoPanel: React.FC<IGalleryFileInfoPanelProps> = (
truncate
/>
<URLField
id="path"
id="media_info.downloaded_from"
url={props.gallery.url}
value={props.gallery.url}
truncate

View File

@@ -9,6 +9,14 @@ import { useToast } from "src/hooks";
import { FormUtils } from "src/utils";
import MultiSet from "../Shared/MultiSet";
import { RatingStars } from "../Scenes/SceneDetails/RatingStars";
import {
getAggregateInputIDs,
getAggregateInputValue,
getAggregatePerformerIds,
getAggregateRating,
getAggregateStudioId,
getAggregateTagIds,
} from "src/utils/bulkUpdate";
interface IListOperationProps {
selected: GQL.SlimImageDataFragment[];
@@ -42,22 +50,12 @@ export const EditImagesDialog: React.FC<IListOperationProps> = (
const checkboxRef = React.createRef<HTMLInputElement>();
function makeBulkUpdateIds(
ids: string[],
mode: GQL.BulkUpdateIdMode
): GQL.BulkUpdateIds {
return {
mode,
ids,
};
}
function getImageInput(): GQL.BulkImageUpdateInput {
// need to determine what we are actually setting on each image
const aggregateRating = getRating(props.selected);
const aggregateStudioId = getStudioId(props.selected);
const aggregatePerformerIds = getPerformerIds(props.selected);
const aggregateTagIds = getTagIds(props.selected);
const aggregateRating = getAggregateRating(props.selected);
const aggregateStudioId = getAggregateStudioId(props.selected);
const aggregatePerformerIds = getAggregatePerformerIds(props.selected);
const aggregateTagIds = getAggregateTagIds(props.selected);
const imageInput: GQL.BulkImageUpdateInput = {
ids: props.selected.map((image) => {
@@ -65,67 +63,15 @@ export const EditImagesDialog: React.FC<IListOperationProps> = (
}),
};
// if rating is undefined
if (rating === undefined) {
// and all images have the same rating, then we are unsetting the rating.
if (aggregateRating) {
// null rating to unset it
imageInput.rating = null;
}
// otherwise not setting the rating
} else {
// if rating is set, then we are setting the rating for all
imageInput.rating = rating;
}
imageInput.rating = getAggregateInputValue(rating, aggregateRating);
imageInput.studio_id = getAggregateInputValue(studioId, aggregateStudioId);
// if studioId is undefined
if (studioId === undefined) {
// and all images have the same studioId,
// then unset the studioId, otherwise ignoring studioId
if (aggregateStudioId) {
// null studio_id to unset it
imageInput.studio_id = null;
}
} else {
// if studioId is set, then we are setting it
imageInput.studio_id = studioId;
}
// if performerIds are empty
if (
performerMode === GQL.BulkUpdateIdMode.Set &&
(!performerIds || performerIds.length === 0)
) {
// and all images have the same ids,
if (aggregatePerformerIds.length > 0) {
// then unset the performerIds, otherwise ignore
imageInput.performer_ids = makeBulkUpdateIds(
performerIds || [],
performerMode
);
}
} else {
// if performerIds non-empty, then we are setting them
imageInput.performer_ids = makeBulkUpdateIds(
performerIds || [],
performerMode
);
}
// if tagIds non-empty, then we are setting them
if (
tagMode === GQL.BulkUpdateIdMode.Set &&
(!tagIds || tagIds.length === 0)
) {
// and all images have the same ids,
if (aggregateTagIds.length > 0) {
// then unset the tagIds, otherwise ignore
imageInput.tag_ids = makeBulkUpdateIds(tagIds || [], tagMode);
}
} else {
// if tagIds non-empty, then we are setting them
imageInput.tag_ids = makeBulkUpdateIds(tagIds || [], tagMode);
}
imageInput.performer_ids = getAggregateInputIDs(
performerMode,
performerIds,
aggregatePerformerIds
);
imageInput.tag_ids = getAggregateInputIDs(tagMode, tagIds, aggregateTagIds);
if (organized !== undefined) {
imageInput.organized = organized;
@@ -155,83 +101,6 @@ export const EditImagesDialog: React.FC<IListOperationProps> = (
setIsUpdating(false);
}
function getRating(state: GQL.SlimImageDataFragment[]) {
let ret: number | undefined;
let first = true;
state.forEach((image: GQL.SlimImageDataFragment) => {
if (first) {
ret = image.rating ?? undefined;
first = false;
} else if (ret !== image.rating) {
ret = undefined;
}
});
return ret;
}
function getStudioId(state: GQL.SlimImageDataFragment[]) {
let ret: string | undefined;
let first = true;
state.forEach((image: GQL.SlimImageDataFragment) => {
if (first) {
ret = image?.studio?.id;
first = false;
} else {
const studio = image?.studio?.id;
if (ret !== studio) {
ret = undefined;
}
}
});
return ret;
}
function getPerformerIds(state: GQL.SlimImageDataFragment[]) {
let ret: string[] = [];
let first = true;
state.forEach((image: GQL.SlimImageDataFragment) => {
if (first) {
ret = image.performers ? image.performers.map((p) => p.id).sort() : [];
first = false;
} else {
const perfIds = image.performers
? image.performers.map((p) => p.id).sort()
: [];
if (!_.isEqual(ret, perfIds)) {
ret = [];
}
}
});
return ret;
}
function getTagIds(state: GQL.SlimImageDataFragment[]) {
let ret: string[] = [];
let first = true;
state.forEach((image: GQL.SlimImageDataFragment) => {
if (first) {
ret = image.tags ? image.tags.map((t) => t.id).sort() : [];
first = false;
} else {
const tIds = image.tags ? image.tags.map((t) => t.id).sort() : [];
if (!_.isEqual(ret, tIds)) {
ret = [];
}
}
});
return ret;
}
useEffect(() => {
const state = props.selected;
let updateRating: number | undefined;

View File

@@ -1,4 +1,4 @@
import React from "react";
import React, { MouseEvent } from "react";
import { Button, ButtonGroup } from "react-bootstrap";
import cx from "classnames";
import * as GQL from "src/core/generated-graphql";
@@ -14,6 +14,7 @@ interface IImageCardProps {
selected: boolean | undefined;
zoomIndex: number;
onSelectedChanged: (selected: boolean, shiftKey: boolean) => void;
onPreview?: (ev: MouseEvent) => void;
}
export const ImageCard: React.FC<IImageCardProps> = (
@@ -49,7 +50,7 @@ export const ImageCard: React.FC<IImageCardProps> = (
function maybeRenderOCounter() {
if (props.image.o_counter) {
return (
<div>
<div className="o-count">
<Button className="minimal">
<span className="fa-icon">
<SweatDrops />
@@ -61,10 +62,31 @@ export const ImageCard: React.FC<IImageCardProps> = (
}
}
function maybeRenderGallery() {
if (props.image.galleries.length <= 0) return;
const popoverContent = props.image.galleries.map((gallery) => (
<TagLink key={gallery.id} gallery={gallery} />
));
return (
<HoverPopover
className="gallery-count"
placement="bottom"
content={popoverContent}
>
<Button className="minimal">
<Icon icon="images" />
<span>{props.image.galleries.length}</span>
</Button>
</HoverPopover>
);
}
function maybeRenderOrganized() {
if (props.image.organized) {
return (
<div>
<div className="organized">
<Button className="minimal">
<Icon icon="box" />
</Button>
@@ -78,6 +100,7 @@ export const ImageCard: React.FC<IImageCardProps> = (
props.image.tags.length > 0 ||
props.image.performers.length > 0 ||
props.image.o_counter ||
props.image.galleries.length > 0 ||
props.image.organized
) {
return (
@@ -87,6 +110,7 @@ export const ImageCard: React.FC<IImageCardProps> = (
{maybeRenderTagPopoverButton()}
{maybeRenderPerformerPopoverButton()}
{maybeRenderOCounter()}
{maybeRenderGallery()}
{maybeRenderOrganized()}
</ButtonGroup>
</>
@@ -119,6 +143,13 @@ export const ImageCard: React.FC<IImageCardProps> = (
alt={props.image.title ?? ""}
src={props.image.paths.thumbnail ?? ""}
/>
{props.onPreview ? (
<div className="preview-button">
<Button onClick={props.onPreview}>
<Icon icon="search" />
</Button>
</div>
) : undefined}
</div>
<RatingBanner rating={props.image.rating} />
</>

View File

@@ -34,7 +34,6 @@ export const Image: React.FC = () => {
const { data, error, loading } = useFindImage(id);
const image = data?.findImage;
const [oLoading, setOLoading] = useState(false);
const [incrementO] = useImageIncrementO(image?.id ?? "0");
const [decrementO] = useImageDecrementO(image?.id ?? "0");
const [resetO] = useImageResetO(image?.id ?? "0");
@@ -87,34 +86,25 @@ export const Image: React.FC = () => {
const onIncrementClick = async () => {
try {
setOLoading(true);
await incrementO();
} catch (e) {
Toast.error(e);
} finally {
setOLoading(false);
}
};
const onDecrementClick = async () => {
try {
setOLoading(true);
await decrementO();
} catch (e) {
Toast.error(e);
} finally {
setOLoading(false);
}
};
const onResetClick = async () => {
try {
setOLoading(true);
await resetO();
} catch (e) {
Toast.error(e);
} finally {
setOLoading(false);
}
};
@@ -196,7 +186,6 @@ export const Image: React.FC = () => {
</Nav.Item>
<Nav.Item className="ml-auto">
<OCounterButton
loading={oLoading}
value={image.o_counter || 0}
onIncrement={onIncrementClick}
onDecrement={onDecrementClick}

Some files were not shown because too many files have changed in this diff Show More