mirror of
https://github.com/stashapp/stash.git
synced 2026-01-28 19:13:46 +01:00
Merge pull request #3667 from stashapp/develop
Merge develop to master for 0.20.2 release
This commit is contained in:
commit
22dc0bbf77
1011 changed files with 59046 additions and 30123 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -17,7 +17,6 @@
|
|||
|
||||
# GraphQL generated output
|
||||
internal/api/generated_*.go
|
||||
ui/v2.5/src/core/generated-*.tsx
|
||||
|
||||
####
|
||||
# Jetbrains
|
||||
|
|
@ -63,4 +62,5 @@ node_modules
|
|||
|
||||
/stash
|
||||
dist
|
||||
.DS_Store
|
||||
.DS_Store
|
||||
/.local
|
||||
31
Makefile
31
Makefile
|
|
@ -9,9 +9,14 @@ endif
|
|||
ifdef IS_WIN_SHELL
|
||||
SEPARATOR := &&
|
||||
SET := set
|
||||
RM := del /s /q
|
||||
RMDIR := rmdir /s /q
|
||||
PWD := $(shell echo %cd%)
|
||||
else
|
||||
SEPARATOR := ;
|
||||
SET := export
|
||||
RM := rm -f
|
||||
RMDIR := rm -rf
|
||||
endif
|
||||
|
||||
# set LDFLAGS environment variable to any extra ldflags required
|
||||
|
|
@ -99,7 +104,7 @@ cross-compile-macos-applesilicon: GO_BUILD_TAGS := $(GO_BUILD_TAGS_DEFAULT)
|
|||
# can't use static build for OSX
|
||||
cross-compile-macos-applesilicon: build-release
|
||||
|
||||
cross-compile-macos:
|
||||
cross-compile-macos:
|
||||
rm -rf dist/Stash.app dist/Stash-macos.zip
|
||||
make cross-compile-macos-applesilicon
|
||||
make cross-compile-macos-intel
|
||||
|
|
@ -175,7 +180,7 @@ generate-frontend:
|
|||
cd ui/v2.5 && yarn run gqlgen
|
||||
|
||||
.PHONY: generate-backend
|
||||
generate-backend: touch-ui
|
||||
generate-backend: touch-ui
|
||||
go generate -mod=vendor ./cmd/stash
|
||||
|
||||
.PHONY: generate-dataloaders
|
||||
|
|
@ -211,6 +216,23 @@ it:
|
|||
generate-test-mocks:
|
||||
go run -mod=vendor github.com/vektra/mockery/v2 --dir ./pkg/models --name '.*ReaderWriter' --outpkg mocks --output ./pkg/models/mocks
|
||||
|
||||
# runs server
|
||||
# sets the config file to use the local dev config
|
||||
.PHONY: server-start
|
||||
server-start: export STASH_CONFIG_FILE=config.yml
|
||||
server-start:
|
||||
ifndef IS_WIN_SHELL
|
||||
@mkdir -p .local
|
||||
else
|
||||
@if not exist ".local" mkdir .local
|
||||
endif
|
||||
cd .local && go run ../cmd/stash
|
||||
|
||||
# removes local dev config files
|
||||
.PHONY: server-clean
|
||||
server-clean:
|
||||
$(RMDIR) .local
|
||||
|
||||
# installs UI dependencies. Run when first cloning repository, or if UI
|
||||
# dependencies have changed
|
||||
.PHONY: pre-ui
|
||||
|
|
@ -256,3 +278,8 @@ validate-backend: lint it
|
|||
.PHONY: docker-build
|
||||
docker-build: pre-build
|
||||
docker build --build-arg GITHASH=$(GITHASH) --build-arg STASH_VERSION=$(STASH_VERSION) -t stash/build -f docker/build/x86_64/Dockerfile .
|
||||
|
||||
# locally builds and tags a 'stash/cuda-build' docker image
|
||||
.PHONY: docker-cuda-build
|
||||
docker-cuda-build: pre-build
|
||||
docker build --build-arg GITHASH=$(GITHASH) --build-arg STASH_VERSION=$(STASH_VERSION) -t stash/cuda-build -f docker/build/x86_64/Dockerfile-CUDA .
|
||||
|
|
|
|||
24
README.md
24
README.md
|
|
@ -3,8 +3,11 @@ https://stashapp.cc
|
|||
|
||||
[](https://github.com/stashapp/stash/actions/workflows/build.yml)
|
||||
[](https://hub.docker.com/r/stashapp/stash 'DockerHub')
|
||||
[](https://opencollective.com/stashapp)
|
||||
[](https://goreportcard.com/report/github.com/stashapp/stash)
|
||||
[](https://discord.gg/2TsNFKt)
|
||||
[](https://github.com/stashapp/stash/releases/latest)
|
||||
[](https://github.com/stashapp/stash/labels/bounty)
|
||||
|
||||
### **Stash is a self-hosted webapp written in Go which organizes and serves your porn.**
|
||||

|
||||
|
|
@ -26,7 +29,7 @@ For further information you can [read the in-app manual](ui/v2.5/src/docs/en).
|
|||
|
||||
## 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.
|
||||
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.
|
||||
|
||||
|
|
@ -39,32 +42,31 @@ On first run, Stash will prompt you for some configuration options and media dir
|
|||
|
||||
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.
|
||||
Many community-maintained scrapers are available for download from [CommunityScrapers repository](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>
|
||||
<sub>[StashDB](http://stashdb.org) is the canonical instance of our open source metadata API, [stash-box](https://github.com/stashapp/stash-box).</sub>
|
||||
|
||||
# Translation
|
||||
[](https://translate.stashapp.cc/engage/stash/)
|
||||
🇧🇷 🇨🇳 🇩🇰 🇳🇱 🇬🇧 🇪🇪 🇫🇮 🇫🇷 🇩🇪 🇮🇹 🇯🇵 🇰🇷 🇵🇱 🇷🇺 🇪🇸 🇸🇪 🇹🇼 🇹🇷
|
||||
|
||||
Stash is available in 18 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 25 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)
|
||||
|
||||
Answers to other Frequently Asked Questions can be found [on our Wiki](https://github.com/stashapp/stash/wiki/FAQ)
|
||||
Check out our documentation on [Stash-Docs](https://docs.stashapp.cc) for information about the software, questions, guides, add-ons and more.
|
||||
|
||||
For issues not addressed there, there are a few options.
|
||||
|
||||
* Read the [Wiki](https://github.com/stashapp/stash/wiki)
|
||||
* Check the in-app documentation, in the top right corner of the app (also available [here](https://github.com/stashapp/stash/tree/develop/ui/v2.5/src/docs/en)
|
||||
For more help you can:
|
||||
* Check the in-app documentation, in the top right corner of the app (it's also mirrored on [Stash-Docs](https://docs.stashapp.cc/in-app-manual))
|
||||
* Join the [Discord server](https://discord.gg/2TsNFKt), where the community can offer support.
|
||||
* Start a [discussion on GitHub](https://github.com/stashapp/stash/discussions)
|
||||
|
||||
# Customization
|
||||
|
||||
## Themes and CSS Customization
|
||||
There is a [directory of community-created themes](https://github.com/stashapp/stash/wiki/Themes) on our Wiki, along with instructions on how to install them.
|
||||
There is a [directory of community-created themes](https://docs.stashapp.cc/user-interface-ui/themes) on Stash-Docs, along with instructions on how to install them.
|
||||
|
||||
You can also make Stash interface fit your desired style with [Custom CSS snippets](https://github.com/stashapp/stash/wiki/Custom-CSS-snippets).
|
||||
You can also change the Stash interface to fit your desired style with various snippets from [Custom CSS snippets](https://docs.stashapp.cc/user-interface-ui/custom-css-snippets).
|
||||
|
||||
# For Developers
|
||||
|
||||
|
|
|
|||
51
docker/build/x86_64/Dockerfile-CUDA
Normal file
51
docker/build/x86_64/Dockerfile-CUDA
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
# This dockerfile should be built with `make docker-cuda-build` from the stash root.
|
||||
|
||||
# Build Frontend
|
||||
FROM node:alpine as frontend
|
||||
RUN apk add --no-cache make
|
||||
## cache node_modules separately
|
||||
COPY ./ui/v2.5/package.json ./ui/v2.5/yarn.lock /stash/ui/v2.5/
|
||||
WORKDIR /stash
|
||||
RUN yarn --cwd ui/v2.5 install --frozen-lockfile.
|
||||
COPY Makefile /stash/
|
||||
COPY ./graphql /stash/graphql/
|
||||
COPY ./ui /stash/ui/
|
||||
RUN make generate-frontend
|
||||
ARG GITHASH
|
||||
ARG STASH_VERSION
|
||||
RUN BUILD_DATE=$(date +"%Y-%m-%d %H:%M:%S") make ui
|
||||
|
||||
# Build Backend
|
||||
FROM golang:1.19-bullseye as backend
|
||||
RUN apt update && apt install -y build-essential golang
|
||||
WORKDIR /stash
|
||||
COPY ./go* ./*.go Makefile gqlgen.yml .gqlgenc.yml /stash/
|
||||
COPY ./scripts /stash/scripts/
|
||||
COPY ./vendor /stash/vendor/
|
||||
COPY ./pkg /stash/pkg/
|
||||
COPY ./cmd /stash/cmd
|
||||
COPY ./internal /stash/internal
|
||||
COPY --from=frontend /stash /stash/
|
||||
RUN make generate-backend
|
||||
ARG GITHASH
|
||||
ARG STASH_VERSION
|
||||
RUN make build
|
||||
|
||||
# Final Runnable Image
|
||||
FROM nvidia/cuda:12.0.1-base-ubuntu22.04
|
||||
RUN apt update && apt upgrade -y && apt install -y ca-certificates libvips-tools ffmpeg wget intel-media-va-driver-non-free vainfo
|
||||
RUN rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=backend /stash/stash /usr/bin/
|
||||
|
||||
# NVENC Patch
|
||||
RUN mkdir -p /usr/local/bin /patched-lib
|
||||
RUN wget https://raw.githubusercontent.com/keylase/nvidia-patch/master/patch.sh -O /usr/local/bin/patch.sh
|
||||
RUN wget https://raw.githubusercontent.com/keylase/nvidia-patch/master/docker-entrypoint.sh -O /usr/local/bin/docker-entrypoint.sh
|
||||
RUN chmod +x /usr/local/bin/patch.sh /usr/local/bin/docker-entrypoint.sh /usr/bin/stash
|
||||
|
||||
ENV LANG C.UTF-8
|
||||
ENV NVIDIA_VISIBLE_DEVICES all
|
||||
ENV NVIDIA_DRIVER_CAPABILITIES=video,utility
|
||||
ENV STASH_CONFIG_FILE=/root/.stash/config.yml
|
||||
EXPOSE 9999
|
||||
ENTRYPOINT ["docker-entrypoint.sh", "stash"]
|
||||
|
|
@ -11,7 +11,11 @@ RUN if [ "$TARGETPLATFORM" = "linux/arm/v6" ]; then BIN=stash-linux-arm32v6; \
|
|||
|
||||
FROM --platform=$TARGETPLATFORM alpine:latest AS app
|
||||
COPY --from=binary /stash /usr/bin/
|
||||
RUN apk add --no-cache ca-certificates python3 py3-requests py3-requests-toolbelt py3-lxml py3-pip ffmpeg vips-tools ruby && pip install --no-cache-dir mechanicalsoup cloudscraper && gem install faraday
|
||||
RUN apk add --no-cache --virtual .build-deps gcc python3-dev musl-dev \
|
||||
&& apk add --no-cache ca-certificates python3 py3-requests py3-requests-toolbelt py3-lxml py3-pip ffmpeg vips-tools ruby tzdata \
|
||||
&& pip install mechanicalsoup cloudscraper bencoder.pyx \
|
||||
&& gem install faraday \
|
||||
&& apk del .build-deps
|
||||
ENV STASH_CONFIG_FILE=/root/.stash/config.yml
|
||||
|
||||
EXPOSE 9999
|
||||
|
|
|
|||
|
|
@ -36,5 +36,7 @@ services:
|
|||
- ./metadata:/metadata
|
||||
## Any other cache content.
|
||||
- ./cache:/cache
|
||||
## Where to store binary blob data (scene covers, images)
|
||||
- ./blobs:/blobs
|
||||
## Where to store generated content (screenshots,previews,transcodes,sprites)
|
||||
- ./generated:/generated
|
||||
|
|
|
|||
|
|
@ -25,7 +25,17 @@ NOTE: The `make` command in Windows will be `mingw32-make` with MingW. For examp
|
|||
### macOS
|
||||
|
||||
1. If you don't have it already, install the [Homebrew package manager](https://brew.sh).
|
||||
2. Install dependencies: `brew install go git yarn gcc make`
|
||||
2. Install dependencies: `brew install go git yarn gcc make node ffmpeg`
|
||||
|
||||
### Linux
|
||||
|
||||
#### Arch Linux
|
||||
1. Install dependencies: `sudo pacman -S go git yarn gcc make nodejs ffmpeg --needed`
|
||||
|
||||
#### Ubuntu
|
||||
1. Install dependencies: `sudo apt-get install golang git gcc nodejs ffmpeg -y`
|
||||
2. Enable corepack in Node.js: `corepack enable`
|
||||
3. Install yarn: `corepack prepare yarn@stable --activate`
|
||||
|
||||
## Commands
|
||||
|
||||
|
|
@ -39,8 +49,35 @@ NOTE: The `make` command in Windows will be `mingw32-make` with MingW. For examp
|
|||
* `make fmt` - Run `go fmt`
|
||||
* `make it` - Run the unit and integration tests
|
||||
* `make validate` - Run all of the tests and checks required to submit a PR
|
||||
* `make server-start` - Runs an instance of the server in the `.local` directory.
|
||||
* `make server-clean` - Removes the `.local` directory and all of its contents.
|
||||
* `make ui-start` - Runs the UI in development mode. Requires a running stash server to connect to. Stash server port can be changed from the default of `9999` using environment variable `VITE_APP_PLATFORM_PORT`. UI runs on port `3000` or the next available port.
|
||||
|
||||
## Local development quickstart
|
||||
|
||||
1. Run `make pre-ui` to install UI dependencies
|
||||
2. Run `make generate` to create generated files
|
||||
3. In one terminal, run `make server-start` to run the server code
|
||||
4. In a separate terminal, run `make ui-start` to run the UI in development mode
|
||||
5. Open the UI in a browser `http://localhost:3000/`
|
||||
|
||||
Changes to the UI code can be seen by reloading the browser page.
|
||||
|
||||
Changes to the server code requires a restart (`CTRL-C` in the server terminal).
|
||||
|
||||
On first launch:
|
||||
1. On the "Stash Setup Wizard" screen, choose a directory with some files to test with
|
||||
2. Press "Next" to use the default locations for the database and generated content
|
||||
3. Press the "Confirm" and "Finish" buttons to get into the UI
|
||||
4. On the side menu, navigate to "Tasks -> Library -> Scan" and press the "Scan" button
|
||||
5. You're all set! Set any other configurations you'd like and test your code changes.
|
||||
|
||||
To start fresh with new configuration:
|
||||
1. Stop the server (`CTRL-C` in the server terminal)
|
||||
2. Run `make server-clean` to clear all config, database, and generated files (under `.local/`)
|
||||
3. Run `make server-start` to restart the server
|
||||
4. Follow the "On first launch" steps above
|
||||
|
||||
## Building a release
|
||||
|
||||
1. Run `make pre-ui` to install UI dependencies
|
||||
|
|
|
|||
27
go.mod
27
go.mod
|
|
@ -15,7 +15,7 @@ require (
|
|||
github.com/golang-migrate/migrate/v4 v4.15.0-beta.1
|
||||
github.com/gorilla/securecookie v1.1.1
|
||||
github.com/gorilla/sessions v1.2.0
|
||||
github.com/gorilla/websocket v1.4.2
|
||||
github.com/gorilla/websocket v1.5.0
|
||||
github.com/jinzhu/copier v0.0.0-20190924061706-b57f9002281a
|
||||
github.com/jmoiron/sqlx v1.3.1
|
||||
github.com/json-iterator/go v1.1.12
|
||||
|
|
@ -30,16 +30,16 @@ require (
|
|||
github.com/spf13/afero v1.8.2 // indirect
|
||||
github.com/spf13/pflag v1.0.5
|
||||
github.com/spf13/viper v1.10.1
|
||||
github.com/stretchr/testify v1.7.0
|
||||
github.com/stretchr/testify v1.7.1
|
||||
github.com/tidwall/gjson v1.9.3
|
||||
github.com/tidwall/pretty v1.2.0 // indirect
|
||||
github.com/vektra/mockery/v2 v2.10.0
|
||||
golang.org/x/crypto v0.0.0-20220321153916-2c7772ba3064
|
||||
golang.org/x/image v0.0.0-20210220032944-ac19c3e999fb
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b
|
||||
golang.org/x/sys v0.0.0-20220808155132-1c4a2a72c664
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211
|
||||
golang.org/x/text v0.3.7
|
||||
golang.org/x/image v0.5.0
|
||||
golang.org/x/net v0.7.0
|
||||
golang.org/x/sys v0.5.0
|
||||
golang.org/x/term v0.5.0
|
||||
golang.org/x/text v0.7.0
|
||||
golang.org/x/tools v0.1.12 // indirect
|
||||
gopkg.in/sourcemap.v1 v1.0.5 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0
|
||||
|
|
@ -57,7 +57,9 @@ require (
|
|||
github.com/spf13/cast v1.4.1
|
||||
github.com/vearutop/statigz v1.1.6
|
||||
github.com/vektah/dataloaden v0.3.0
|
||||
github.com/vektah/gqlparser/v2 v2.4.1
|
||||
github.com/vektah/gqlparser/v2 v2.4.2
|
||||
github.com/xWTF/chardet v0.0.0-20230208095535-c780f2ac244e
|
||||
github.com/zencoder/go-dash/v3 v3.0.2
|
||||
gopkg.in/guregu/null.v4 v4.0.0
|
||||
)
|
||||
|
||||
|
|
@ -82,9 +84,9 @@ require (
|
|||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/magiconair/properties v1.8.6 // indirect
|
||||
github.com/mailru/easyjson v0.7.7 // indirect
|
||||
github.com/matryer/moq v0.2.6 // indirect
|
||||
github.com/matryer/moq v0.2.3 // indirect
|
||||
github.com/mitchellh/go-homedir v1.1.0 // indirect
|
||||
github.com/mitchellh/mapstructure v1.4.3 // indirect
|
||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect
|
||||
|
|
@ -99,11 +101,12 @@ require (
|
|||
github.com/stretchr/objx v0.2.0 // indirect
|
||||
github.com/subosito/gotenv v1.2.0 // indirect
|
||||
github.com/tidwall/match v1.1.1 // indirect
|
||||
github.com/urfave/cli/v2 v2.4.0 // indirect
|
||||
github.com/urfave/cli/v2 v2.8.1 // indirect
|
||||
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect
|
||||
go.uber.org/atomic v1.7.0 // indirect
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4 // indirect
|
||||
gopkg.in/ini.v1 v1.66.4 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
replace git.apache.org/thrift.git => github.com/apache/thrift v0.0.0-20180902110319-2566ecd5d999
|
||||
|
|
|
|||
49
go.sum
49
go.sum
|
|
@ -396,8 +396,9 @@ github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyC
|
|||
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
|
||||
github.com/gorilla/sessions v1.2.0 h1:S7P+1Hm5V/AT9cjEcUD5uDaQSX0OE577aCXgoaKpYbQ=
|
||||
github.com/gorilla/sessions v1.2.0/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
|
||||
github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
|
||||
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
|
||||
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw=
|
||||
github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed/go.mod h1:tMWxXQ9wFIaZeTI9F+hmhFiGpFmhOHzyShyFUhRm0H4=
|
||||
github.com/hashicorp/consul/api v1.11.0/go.mod h1:XjsvQN+RJGWI2TWy1/kqaE16HrR2J/FWgkYjdZQsX9M=
|
||||
|
|
@ -555,9 +556,8 @@ github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJ
|
|||
github.com/markbates/oncer v0.0.0-20181203154359-bf2de49a0be2/go.mod h1:Ld9puTsIW75CHf65OeIOkyKbteujpZVXDpWK6YGZbxE=
|
||||
github.com/markbates/pkger v0.15.1/go.mod h1:0JoVlrol20BSywW79rN3kdFFsE5xYM+rSCQDXbLhiuI=
|
||||
github.com/markbates/safe v1.0.1/go.mod h1:nAqgmRi7cY2nqMc92/bSEeQA+R4OheNU2T1kNSCBdG0=
|
||||
github.com/matryer/moq v0.2.3 h1:Q06vEqnBYjjfx5KKgHfYRKE/lvlRu+Nj+xodG4YdHnU=
|
||||
github.com/matryer/moq v0.2.3/go.mod h1:9RtPYjTnH1bSBIkpvtHkFN7nbWAnO7oRpdJkEIn6UtE=
|
||||
github.com/matryer/moq v0.2.6 h1:X4+LF09udTsi2P+Z+1UhSb4p3K8IyiF7KSNFDR9M3M0=
|
||||
github.com/matryer/moq v0.2.6/go.mod h1:kITsx543GOENm48TUAQyJ9+SAvFSr7iGQXPoth/VUBk=
|
||||
github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
|
||||
github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ=
|
||||
github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
|
||||
|
|
@ -591,8 +591,9 @@ github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:F
|
|||
github.com/mitchellh/mapstructure v0.0.0-20180220230111-00c29f56e238/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
|
||||
github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
|
||||
github.com/mitchellh/mapstructure v1.2.3/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
github.com/mitchellh/mapstructure v1.4.3 h1:OVowDSCllw/YjdLkam3/sm7wEtOy59d8ndGgCcyj8cs=
|
||||
github.com/mitchellh/mapstructure v1.4.3/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
|
|
@ -734,8 +735,9 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV
|
|||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1 h1:5TQK59W5E3v0r2duFAb7P95B6hEeOyEnHRa8MjYSMTY=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/subosito/gotenv v1.2.0 h1:Slr1R9HxAlEKefgq5jn9U+DnETlIUa6HfgEzj0g5d7s=
|
||||
github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw=
|
||||
github.com/tidwall/gjson v1.9.3 h1:hqzS9wAHMO+KVBBkLxYdkEeeFHuqr95GfClRLKlgK0E=
|
||||
|
|
@ -748,22 +750,27 @@ github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhso
|
|||
github.com/tinylib/msgp v1.0.2/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE=
|
||||
github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM=
|
||||
github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI=
|
||||
github.com/urfave/cli/v2 v2.4.0 h1:m2pxjjDFgDxSPtO8WSdbndj17Wu2y8vOT86wE/tjr+I=
|
||||
github.com/urfave/cli/v2 v2.4.0/go.mod h1:NX9W0zmTvedE5oDoOMs2RTC8RvdK98NTYZE5LbaEYPg=
|
||||
github.com/urfave/cli/v2 v2.8.1 h1:CGuYNZF9IKZY/rfBe3lJpccSoIY1ytfvmgQT90cNOl4=
|
||||
github.com/urfave/cli/v2 v2.8.1/go.mod h1:Z41J9TPoffeoqP0Iza0YbAhGvymRdZAd2uPmZ5JxRdY=
|
||||
github.com/vearutop/statigz v1.1.6 h1:si1zvulh/6P4S/SjFticuKQ8/EgQISglaRuycj8PWso=
|
||||
github.com/vearutop/statigz v1.1.6/go.mod h1:czAv7iXgPv/s+xsgXpVEhhD0NSOQ4wZPgmM/n7LANDI=
|
||||
github.com/vektah/dataloaden v0.3.0 h1:ZfVN2QD6swgvp+tDqdH/OIT/wu3Dhu0cus0k5gIZS84=
|
||||
github.com/vektah/dataloaden v0.3.0/go.mod h1:/HUdMve7rvxZma+2ZELQeNh88+003LL7Pf/CZ089j8U=
|
||||
github.com/vektah/gqlparser/v2 v2.4.0/go.mod h1:flJWIR04IMQPGz+BXLrORkrARBxv/rtyIAFvd/MceW0=
|
||||
github.com/vektah/gqlparser/v2 v2.4.1 h1:QOyEn8DAPMUMARGMeshKDkDgNmVoEaEGiDB0uWxcSlQ=
|
||||
github.com/vektah/gqlparser/v2 v2.4.1/go.mod h1:flJWIR04IMQPGz+BXLrORkrARBxv/rtyIAFvd/MceW0=
|
||||
github.com/vektah/gqlparser/v2 v2.4.2 h1:29TGc6QmhEUq5fll+2FPoTmhUhR65WEKN4VK/jo0OlM=
|
||||
github.com/vektah/gqlparser/v2 v2.4.2/go.mod h1:flJWIR04IMQPGz+BXLrORkrARBxv/rtyIAFvd/MceW0=
|
||||
github.com/vektra/mockery/v2 v2.10.0 h1:MiiQWxwdq7/ET6dCXLaJzSGEN17k758H7JHS9kOdiks=
|
||||
github.com/vektra/mockery/v2 v2.10.0/go.mod h1:m/WO2UzWzqgVX3nvqpRQq70I4Z7jbSCRhdmkgtp+Ab4=
|
||||
github.com/willf/bitset v1.1.9/go.mod h1:RjeCKbqT1RxIR/KWY6phxZiaY1IyutSBfGjNPySAYV4=
|
||||
github.com/xWTF/chardet v0.0.0-20230208095535-c780f2ac244e h1:GruPsb+44XvYAzuAgJW1d1WHqmcI73L2XSjsbx/eJZw=
|
||||
github.com/xWTF/chardet v0.0.0-20230208095535-c780f2ac244e/go.mod h1:wA8kQ8WFipMciY9WcWzqQgZordm/P7l8IZdvx1crwmc=
|
||||
github.com/xanzy/go-gitlab v0.15.0/go.mod h1:8zdQa/ri1dfn8eS3Ir1SyfvOKlw7WBJ8DVThkpGiXrs=
|
||||
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
|
||||
github.com/xdg-go/scram v1.0.2/go.mod h1:1WAq6h33pAW+iRreB34OORO2Nf7qel3VV3fjBj+hCSs=
|
||||
github.com/xdg-go/stringprep v1.0.2/go.mod h1:8F9zXuvzgwmyT5DUm4GUfZGDdT3W+LCvS6+da4O5kxM=
|
||||
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU=
|
||||
github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8=
|
||||
github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA=
|
||||
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
|
|
@ -772,7 +779,10 @@ github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9dec
|
|||
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
|
||||
github.com/yuin/goldmark v1.4.0/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
|
||||
github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q=
|
||||
github.com/zencoder/go-dash/v3 v3.0.2 h1:oP1+dOh+Gp57PkvdCyMfbHtrHaxfl3w4kR3KBBbuqQE=
|
||||
github.com/zencoder/go-dash/v3 v3.0.2/go.mod h1:30R5bKy1aUYY45yesjtZ9l8trNc2TwNqbS17WVQmCzk=
|
||||
gitlab.com/nyarla/go-crypt v0.0.0-20160106005555-d9a5dc2b789b/go.mod h1:T3BPAOm2cqquPa0MKWeNkmOM5RQsRhkrwMWonFMN7fE=
|
||||
go.etcd.io/etcd/api/v3 v3.5.1/go.mod h1:cbVKeC6lCfl7j/8jBhAK6aIYO9XOjdptoxU/nLQcPvs=
|
||||
go.etcd.io/etcd/client/pkg/v3 v3.5.1/go.mod h1:IJHfcCEKxYu1Os13ZdwCwIUTUVGYTSAM3YSwc9/Ac1g=
|
||||
|
|
@ -837,8 +847,8 @@ golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMk
|
|||
golang.org/x/image v0.0.0-20180708004352-c73c2afc3b81/go.mod h1:ux5Hcp/YLpHSI86hEcLt0YII63i6oz57MZXIpbrjZUs=
|
||||
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
|
||||
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/image v0.0.0-20210220032944-ac19c3e999fb h1:fqpd0EBDzlHRCjiphRR5Zo/RSWWQlWv34418dnEixWk=
|
||||
golang.org/x/image v0.0.0-20210220032944-ac19c3e999fb/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/image v0.5.0 h1:5JMiNunQeQw++mMOz48/ISeNu3Iweh/JaZU8ZLqHRrI=
|
||||
golang.org/x/image v0.5.0/go.mod h1:FVC7BI/5Ym8R25iw5OLsgshdUBbT1h5jZTpA+mvAdZ4=
|
||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
|
|
@ -922,8 +932,9 @@ golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qx
|
|||
golang.org/x/net v0.0.0-20210813160813-60bc85c4be6d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b h1:PxfKdU9lEEDYjdIzOtC4qFWgkU2rGHdKlKowJSMN9h0=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.7.0 h1:rJrUqqhjsgNp7KqAIc25s9pZnjU7TUcSY7HcVZjdn1g=
|
||||
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/oauth2 v0.0.0-20180227000427-d7d64896b5ff/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.0.0-20181106182150-f42d05182288/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
|
|
@ -956,6 +967,7 @@ golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJ
|
|||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20180224232135-f6cff0780e54/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
|
|
@ -1048,11 +1060,14 @@ golang.org/x/sys v0.0.0-20211205182925-97ca703d548d/go.mod h1:oPkhp1MJrh7nUepCBc
|
|||
golang.org/x/sys v0.0.0-20211210111614-af8b64212486/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220319134239-a9b59b0215f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220808155132-1c4a2a72c664 h1:v1W7bwXHsnLLloWYTVEdvGvA7BHMeBYsPcF0GLDxIRs=
|
||||
golang.org/x/sys v0.0.0-20220808155132-1c4a2a72c664/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU=
|
||||
golang.org/x/sys v0.5.0/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-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0 h1:n2a8QNdAb0sZNpU9R1ALUXBbY+w51fCQDN+7EdxNBsY=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
|
|
@ -1061,8 +1076,9 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
|||
golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0 h1:4BRB4x83lYWy72KwLD/qYDuTu7q9PjSagHvijDw7cLo=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
|
|
@ -1330,8 +1346,9 @@ gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
|||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo=
|
||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/postgres v1.0.8/go.mod h1:4eOzrI1MUfm6ObJU/UcmbXyiHSs8jSwH95G5P5dxcAg=
|
||||
gorm.io/gorm v1.20.12/go.mod h1:0HFTzE/SqkGTzK6TlDPPQbAYCluiVvhzoA1+aVyzenw=
|
||||
gorm.io/gorm v1.21.4/go.mod h1:0HFTzE/SqkGTzK6TlDPPQbAYCluiVvhzoA1+aVyzenw=
|
||||
|
|
|
|||
|
|
@ -34,6 +34,8 @@ models:
|
|||
title:
|
||||
resolver: true
|
||||
# autobind on config causes generation issues
|
||||
BlobsStorageType:
|
||||
model: github.com/stashapp/stash/internal/manager/config.BlobsStorageType
|
||||
StashConfig:
|
||||
model: github.com/stashapp/stash/internal/manager/config.StashConfig
|
||||
StashConfigInput:
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ fragment ConfigGeneralData on ConfigGeneralResult {
|
|||
metadataPath
|
||||
scrapersPath
|
||||
cachePath
|
||||
blobsPath
|
||||
blobsStorage
|
||||
calculateMD5
|
||||
videoFileNamingAlgorithm
|
||||
parallelTasks
|
||||
|
|
@ -19,6 +21,7 @@ fragment ConfigGeneralData on ConfigGeneralResult {
|
|||
previewExcludeStart
|
||||
previewExcludeEnd
|
||||
previewPreset
|
||||
transcodeHardwareAcceleration
|
||||
maxTranscodeSize
|
||||
maxStreamingTranscodeSize
|
||||
writeImageThumbnails
|
||||
|
|
@ -31,6 +34,7 @@ fragment ConfigGeneralData on ConfigGeneralResult {
|
|||
logLevel
|
||||
logAccess
|
||||
createGalleriesFromFolders
|
||||
galleryCoverRegex
|
||||
videoExtensions
|
||||
imageExtensions
|
||||
galleryExtensions
|
||||
|
|
@ -46,6 +50,11 @@ fragment ConfigGeneralData on ConfigGeneralResult {
|
|||
api_key
|
||||
}
|
||||
pythonPath
|
||||
transcodeInputArgs
|
||||
transcodeOutputArgs
|
||||
liveTranscodeInputArgs
|
||||
liveTranscodeOutputArgs
|
||||
drawFunscriptHeatmapRange
|
||||
}
|
||||
|
||||
fragment ConfigInterfaceData on ConfigInterfaceResult {
|
||||
|
|
@ -124,6 +133,7 @@ fragment ConfigDefaultSettingsData on ConfigDefaultSettingsResult {
|
|||
scan {
|
||||
useFileMetadata
|
||||
stripFileExtension
|
||||
scanGenerateCovers
|
||||
scanGeneratePreviews
|
||||
scanGenerateImagePreviews
|
||||
scanGenerateSprites
|
||||
|
|
@ -152,6 +162,7 @@ fragment ConfigDefaultSettingsData on ConfigDefaultSettingsResult {
|
|||
}
|
||||
|
||||
generate {
|
||||
covers
|
||||
sprites
|
||||
previews
|
||||
imagePreviews
|
||||
|
|
|
|||
9
graphql/documents/data/gallery-chapter.graphql
Normal file
9
graphql/documents/data/gallery-chapter.graphql
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
fragment GalleryChapterData on GalleryChapter {
|
||||
id
|
||||
title
|
||||
image_index
|
||||
|
||||
gallery {
|
||||
id
|
||||
}
|
||||
}
|
||||
|
|
@ -22,6 +22,11 @@ fragment SlimGalleryData on Gallery {
|
|||
thumbnail
|
||||
}
|
||||
}
|
||||
chapters {
|
||||
id
|
||||
title
|
||||
image_index
|
||||
}
|
||||
studio {
|
||||
id
|
||||
name
|
||||
|
|
|
|||
|
|
@ -16,6 +16,9 @@ fragment GalleryData on Gallery {
|
|||
...FolderData
|
||||
}
|
||||
|
||||
chapters {
|
||||
...GalleryChapterData
|
||||
}
|
||||
cover {
|
||||
...SlimImageData
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
fragment SlimImageData on Image {
|
||||
id
|
||||
title
|
||||
date
|
||||
url
|
||||
rating100
|
||||
organized
|
||||
o_counter
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ fragment ImageData on Image {
|
|||
id
|
||||
title
|
||||
rating100
|
||||
date
|
||||
url
|
||||
organized
|
||||
o_counter
|
||||
created_at
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
fragment SlimPerformerData on Performer {
|
||||
id
|
||||
name
|
||||
disambiguation
|
||||
gender
|
||||
url
|
||||
twitter
|
||||
|
|
@ -18,6 +19,7 @@ fragment SlimPerformerData on Performer {
|
|||
career_length
|
||||
tattoos
|
||||
piercings
|
||||
alias_list
|
||||
tags {
|
||||
id
|
||||
name
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ fragment PerformerData on Performer {
|
|||
id
|
||||
checksum
|
||||
name
|
||||
disambiguation
|
||||
url
|
||||
gender
|
||||
twitter
|
||||
|
|
@ -16,7 +17,7 @@ fragment PerformerData on Performer {
|
|||
career_length
|
||||
tattoos
|
||||
piercings
|
||||
aliases
|
||||
alias_list
|
||||
favorite
|
||||
ignore_auto_tag
|
||||
image_path
|
||||
|
|
|
|||
|
|
@ -46,6 +46,9 @@ fragment SlimSceneData on Scene {
|
|||
files {
|
||||
path
|
||||
}
|
||||
folder {
|
||||
path
|
||||
}
|
||||
title
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
fragment ScrapedPerformerData on ScrapedPerformer {
|
||||
stored_id
|
||||
name
|
||||
disambiguation
|
||||
gender
|
||||
url
|
||||
twitter
|
||||
|
|
@ -30,6 +31,7 @@ fragment ScrapedPerformerData on ScrapedPerformer {
|
|||
fragment ScrapedScenePerformerData on ScrapedPerformer {
|
||||
stored_id
|
||||
name
|
||||
disambiguation
|
||||
gender
|
||||
url
|
||||
twitter
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ fragment StudioData on Studio {
|
|||
scene_count
|
||||
image_count
|
||||
gallery_count
|
||||
performer_count
|
||||
movie_count
|
||||
stash_ids {
|
||||
stash_id
|
||||
|
|
|
|||
31
graphql/documents/mutations/gallery-chapter.graphql
Normal file
31
graphql/documents/mutations/gallery-chapter.graphql
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
mutation GalleryChapterCreate(
|
||||
$title: String!,
|
||||
$image_index: Int!,
|
||||
$gallery_id: ID!) {
|
||||
galleryChapterCreate(input: {
|
||||
title: $title,
|
||||
image_index: $image_index,
|
||||
gallery_id: $gallery_id,
|
||||
}) {
|
||||
...GalleryChapterData
|
||||
}
|
||||
}
|
||||
|
||||
mutation GalleryChapterUpdate(
|
||||
$id: ID!,
|
||||
$title: String!,
|
||||
$image_index: Int!,
|
||||
$gallery_id: ID!) {
|
||||
galleryChapterUpdate(input: {
|
||||
id: $id,
|
||||
title: $title,
|
||||
image_index: $image_index,
|
||||
gallery_id: $gallery_id,
|
||||
}) {
|
||||
...GalleryChapterData
|
||||
}
|
||||
}
|
||||
|
||||
mutation GalleryChapterDestroy($id: ID!) {
|
||||
galleryChapterDestroy(id: $id)
|
||||
}
|
||||
|
|
@ -41,3 +41,7 @@ mutation MigrateHashNaming {
|
|||
mutation BackupDatabase($input: BackupDatabaseInput!) {
|
||||
backupDatabase(input: $input)
|
||||
}
|
||||
|
||||
mutation AnonymiseDatabase($input: AnonymiseDatabaseInput!) {
|
||||
anonymiseDatabase(input: $input)
|
||||
}
|
||||
|
|
|
|||
7
graphql/documents/mutations/migration.graphql
Normal file
7
graphql/documents/mutations/migration.graphql
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
mutation MigrateSceneScreenshots($input: MigrateSceneScreenshotsInput!) {
|
||||
migrateSceneScreenshots(input: $input)
|
||||
}
|
||||
|
||||
mutation MigrateBlobs($input: MigrateBlobsInput!) {
|
||||
migrateBlobs(input: $input)
|
||||
}
|
||||
|
|
@ -6,26 +6,27 @@ query MarkerStrings($q: String, $sort: String) {
|
|||
}
|
||||
}
|
||||
|
||||
query AllTags {
|
||||
allTags {
|
||||
...TagData
|
||||
}
|
||||
}
|
||||
|
||||
query AllPerformersForFilter {
|
||||
allPerformers {
|
||||
...SlimPerformerData
|
||||
id
|
||||
name
|
||||
disambiguation
|
||||
alias_list
|
||||
}
|
||||
}
|
||||
|
||||
query AllStudiosForFilter {
|
||||
allStudios {
|
||||
...SlimStudioData
|
||||
id
|
||||
name
|
||||
aliases
|
||||
}
|
||||
}
|
||||
|
||||
query AllMoviesForFilter {
|
||||
allMovies {
|
||||
...SlimMovieData
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -67,7 +68,9 @@ query Version {
|
|||
|
||||
query LatestVersion {
|
||||
latestversion {
|
||||
version
|
||||
shorthash
|
||||
release_date
|
||||
url
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -96,7 +96,7 @@ type Query {
|
|||
|
||||
"""Scrapes a complete performer record based on a URL"""
|
||||
scrapePerformerURL(url: String!): ScrapedPerformer
|
||||
"""Scrapes a complete performer record based on a URL"""
|
||||
"""Scrapes a complete scene record based on a URL"""
|
||||
scrapeSceneURL(url: String!): ScrapedScene
|
||||
"""Scrapes a complete gallery record based on a URL"""
|
||||
scrapeGalleryURL(url: String!): ScrapedGallery
|
||||
|
|
@ -144,6 +144,10 @@ type Query {
|
|||
|
||||
# Get everything
|
||||
|
||||
allScenes: [Scene!]!
|
||||
allSceneMarkers: [SceneMarker!]!
|
||||
allImages: [Image!]!
|
||||
allGalleries: [Gallery!]!
|
||||
allPerformers: [Performer!]!
|
||||
allStudios: [Studio!]!
|
||||
allMovies: [Movie!]!
|
||||
|
|
@ -155,7 +159,7 @@ type Query {
|
|||
version: Version!
|
||||
|
||||
# LatestVersion
|
||||
latestversion: ShortVersion!
|
||||
latestversion: LatestVersion!
|
||||
}
|
||||
|
||||
type Mutation {
|
||||
|
|
@ -214,6 +218,10 @@ type Mutation {
|
|||
addGalleryImages(input: GalleryAddInput!): Boolean!
|
||||
removeGalleryImages(input: GalleryRemoveInput!): Boolean!
|
||||
|
||||
galleryChapterCreate(input: GalleryChapterCreateInput!): GalleryChapter
|
||||
galleryChapterUpdate(input: GalleryChapterUpdateInput!): GalleryChapter
|
||||
galleryChapterDestroy(id: ID!): Boolean!
|
||||
|
||||
performerCreate(input: PerformerCreateInput!): Performer
|
||||
performerUpdate(input: PerformerUpdateInput!): Performer
|
||||
performerDestroy(input: PerformerDestroyInput!): Boolean!
|
||||
|
|
@ -237,6 +245,14 @@ type Mutation {
|
|||
tagsDestroy(ids: [ID!]!): Boolean!
|
||||
tagsMerge(input: TagsMergeInput!): Tag
|
||||
|
||||
"""Moves the given files to the given destination. Returns true if successful.
|
||||
Either the destination_folder or destination_folder_id must be provided. If both are provided, the destination_folder_id takes precedence.
|
||||
Destination folder must be a subfolder of one of the stash library paths.
|
||||
If provided, destination_basename must be a valid filename with an extension that
|
||||
matches one of the media extensions.
|
||||
Creates folder hierarchy if needed.
|
||||
"""
|
||||
moveFiles(input: MoveFilesInput!): Boolean!
|
||||
deleteFiles(ids: [ID!]!): Boolean!
|
||||
|
||||
# Saved filters
|
||||
|
|
@ -279,8 +295,16 @@ type Mutation {
|
|||
metadataClean(input: CleanMetadataInput!): ID!
|
||||
"""Identifies scenes using scrapers. Returns the job ID"""
|
||||
metadataIdentify(input: IdentifyMetadataInput!): ID!
|
||||
|
||||
"""Migrate generated files for the current hash naming"""
|
||||
migrateHashNaming: ID!
|
||||
"""Migrates legacy scene screenshot files into the blob storage"""
|
||||
migrateSceneScreenshots(input: MigrateSceneScreenshotsInput!): ID!
|
||||
"""Migrates blobs from the old storage system to the current one"""
|
||||
migrateBlobs(input: MigrateBlobsInput!): ID!
|
||||
|
||||
"""Anonymise the database in a separate file. Optionally returns a link to download the database file"""
|
||||
anonymiseDatabase(input: AnonymiseDatabaseInput!): String
|
||||
|
||||
"""Reload scrapers"""
|
||||
reloadScrapers: Boolean!
|
||||
|
|
|
|||
|
|
@ -6,6 +6,10 @@ input SetupInput {
|
|||
databaseFile: String!
|
||||
"""Empty to indicate default"""
|
||||
generatedLocation: String!
|
||||
"""Empty to indicate default"""
|
||||
cacheLocation: String!
|
||||
"""Empty to indicate database storage for blobs"""
|
||||
blobsLocation: String!
|
||||
}
|
||||
|
||||
enum StreamingResolutionEnum {
|
||||
|
|
@ -32,6 +36,13 @@ enum HashAlgorithm {
|
|||
"oshash", OSHASH
|
||||
}
|
||||
|
||||
enum BlobsStorageType {
|
||||
# blobs are stored in the database
|
||||
"Database", DATABASE
|
||||
# blobs are stored in the filesystem under the configured blobs directory
|
||||
"Filesystem", FILESYSTEM
|
||||
}
|
||||
|
||||
input ConfigGeneralInput {
|
||||
"""Array of file paths to content"""
|
||||
stashes: [StashConfigInput!]
|
||||
|
|
@ -47,6 +58,10 @@ input ConfigGeneralInput {
|
|||
scrapersPath: String
|
||||
"""Path to cache"""
|
||||
cachePath: String
|
||||
"""Path to blobs - required for filesystem blob storage"""
|
||||
blobsPath: String
|
||||
"""Where to store blobs"""
|
||||
blobsStorage: BlobsStorageType
|
||||
"""Whether to calculate MD5 checksums for scene video files"""
|
||||
calculateMD5: Boolean
|
||||
"""Hash algorithm to use for generated file naming"""
|
||||
|
|
@ -65,10 +80,30 @@ input ConfigGeneralInput {
|
|||
previewExcludeEnd: String
|
||||
"""Preset when generating preview"""
|
||||
previewPreset: PreviewPreset
|
||||
"""Transcode Hardware Acceleration"""
|
||||
transcodeHardwareAcceleration: Boolean
|
||||
"""Max generated transcode size"""
|
||||
maxTranscodeSize: StreamingResolutionEnum
|
||||
"""Max streaming transcode size"""
|
||||
maxStreamingTranscodeSize: StreamingResolutionEnum
|
||||
|
||||
"""ffmpeg transcode input args - injected before input file
|
||||
These are applied to generated transcodes (previews and transcodes)"""
|
||||
transcodeInputArgs: [String!]
|
||||
"""ffmpeg transcode output args - injected before output file
|
||||
These are applied to generated transcodes (previews and transcodes)"""
|
||||
transcodeOutputArgs: [String!]
|
||||
|
||||
"""ffmpeg stream input args - injected before input file
|
||||
These are applied when live transcoding"""
|
||||
liveTranscodeInputArgs: [String!]
|
||||
"""ffmpeg stream output args - injected before output file
|
||||
These are applied when live transcoding"""
|
||||
liveTranscodeOutputArgs: [String!]
|
||||
|
||||
"""whether to include range in generated funscript heatmaps"""
|
||||
drawFunscriptHeatmapRange: Boolean
|
||||
|
||||
"""Write image thumbnails to disk when generating on the fly"""
|
||||
writeImageThumbnails: Boolean
|
||||
"""Username"""
|
||||
|
|
@ -89,6 +124,8 @@ input ConfigGeneralInput {
|
|||
logAccess: Boolean
|
||||
"""True if galleries should be created from folders with images"""
|
||||
createGalleriesFromFolders: Boolean
|
||||
"""Regex used to identify images as gallery covers"""
|
||||
galleryCoverRegex: String
|
||||
"""Array of video file extensions"""
|
||||
videoExtensions: [String!]
|
||||
"""Array of image file extensions"""
|
||||
|
|
@ -130,6 +167,10 @@ type ConfigGeneralResult {
|
|||
scrapersPath: String!
|
||||
"""Path to cache"""
|
||||
cachePath: String!
|
||||
"""Path to blobs - required for filesystem blob storage"""
|
||||
blobsPath: String!
|
||||
"""Where to store blobs"""
|
||||
blobsStorage: BlobsStorageType!
|
||||
"""Whether to calculate MD5 checksums for scene video files"""
|
||||
calculateMD5: Boolean!
|
||||
"""Hash algorithm to use for generated file naming"""
|
||||
|
|
@ -148,10 +189,30 @@ type ConfigGeneralResult {
|
|||
previewExcludeEnd: String!
|
||||
"""Preset when generating preview"""
|
||||
previewPreset: PreviewPreset!
|
||||
"""Transcode Hardware Acceleration"""
|
||||
transcodeHardwareAcceleration: Boolean!
|
||||
"""Max generated transcode size"""
|
||||
maxTranscodeSize: StreamingResolutionEnum
|
||||
"""Max streaming transcode size"""
|
||||
maxStreamingTranscodeSize: StreamingResolutionEnum
|
||||
|
||||
"""ffmpeg transcode input args - injected before input file
|
||||
These are applied to generated transcodes (previews and transcodes)"""
|
||||
transcodeInputArgs: [String!]!
|
||||
"""ffmpeg transcode output args - injected before output file
|
||||
These are applied to generated transcodes (previews and transcodes)"""
|
||||
transcodeOutputArgs: [String!]!
|
||||
|
||||
"""ffmpeg stream input args - injected before input file
|
||||
These are applied when live transcoding"""
|
||||
liveTranscodeInputArgs: [String!]!
|
||||
"""ffmpeg stream output args - injected before output file
|
||||
These are applied when live transcoding"""
|
||||
liveTranscodeOutputArgs: [String!]!
|
||||
|
||||
"""whether to include range in generated funscript heatmaps"""
|
||||
drawFunscriptHeatmapRange: Boolean!
|
||||
|
||||
"""Write image thumbnails to disk when generating on the fly"""
|
||||
writeImageThumbnails: Boolean!
|
||||
"""API Key"""
|
||||
|
|
@ -180,6 +241,8 @@ type ConfigGeneralResult {
|
|||
galleryExtensions: [String!]!
|
||||
"""True if galleries should be created from folders with images"""
|
||||
createGalleriesFromFolders: Boolean!
|
||||
"""Regex used to identify images as gallery covers"""
|
||||
galleryCoverRegex: String!
|
||||
"""Array of file regexp to exclude from Video Scans"""
|
||||
excludes: [String!]!
|
||||
"""Array of file regexp to exclude from Image Scans"""
|
||||
|
|
|
|||
|
|
@ -94,4 +94,16 @@ type GalleryFile implements BaseFile {
|
|||
|
||||
created_at: Time!
|
||||
updated_at: Time!
|
||||
}
|
||||
}
|
||||
|
||||
input MoveFilesInput {
|
||||
ids: [ID!]!
|
||||
"""valid for single or multiple file ids"""
|
||||
destination_folder: String
|
||||
|
||||
"""valid for single or multiple file ids"""
|
||||
destination_folder_id: ID
|
||||
|
||||
"""valid only for single file id. If empty, existing basename is used"""
|
||||
destination_basename: String
|
||||
}
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ input PerformerFilterType {
|
|||
NOT: PerformerFilterType
|
||||
|
||||
name: StringCriterionInput
|
||||
disambiguation: StringCriterionInput
|
||||
details: StringCriterionInput
|
||||
|
||||
"""Filter by favorite"""
|
||||
|
|
@ -323,6 +324,8 @@ input GalleryFilterType {
|
|||
organized: Boolean
|
||||
"""Filter by average image resolution"""
|
||||
average_resolution: ResolutionCriterionInput
|
||||
"""Filter to only include galleries that have chapters. `true` or `false`"""
|
||||
has_chapters: String
|
||||
"""Filter to only include galleries with this studio"""
|
||||
studios: HierarchicalMultiCriterionInput
|
||||
"""Filter to only include galleries with these tags"""
|
||||
|
|
@ -424,6 +427,10 @@ input ImageFilterType {
|
|||
rating: IntCriterionInput @deprecated(reason: "Use 1-100 range with rating100")
|
||||
# rating expressed as 1-100
|
||||
rating100: IntCriterionInput
|
||||
"""Filter by date"""
|
||||
date: DateCriterionInput
|
||||
"""Filter by url"""
|
||||
url: StringCriterionInput
|
||||
"""Filter by organized"""
|
||||
organized: Boolean
|
||||
"""Filter by o-counter"""
|
||||
|
|
|
|||
26
graphql/schema/types/gallery-chapter.graphql
Normal file
26
graphql/schema/types/gallery-chapter.graphql
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
type GalleryChapter {
|
||||
id: ID!
|
||||
gallery: Gallery!
|
||||
title: String!
|
||||
image_index: Int!
|
||||
created_at: Time!
|
||||
updated_at: Time!
|
||||
}
|
||||
|
||||
input GalleryChapterCreateInput {
|
||||
gallery_id: ID!
|
||||
title: String!
|
||||
image_index: Int!
|
||||
}
|
||||
|
||||
input GalleryChapterUpdateInput {
|
||||
id: ID!
|
||||
gallery_id: ID!
|
||||
title: String!
|
||||
image_index: Int!
|
||||
}
|
||||
|
||||
type FindGalleryChaptersResultType {
|
||||
count: Int!
|
||||
chapters: [GalleryChapter!]!
|
||||
}
|
||||
|
|
@ -19,6 +19,7 @@ type Gallery {
|
|||
files: [GalleryFile!]!
|
||||
folder: Folder
|
||||
|
||||
chapters: [GalleryChapter!]!
|
||||
scenes: [Scene!]!
|
||||
studio: Studio
|
||||
image_count: Int!
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ type Image {
|
|||
rating: Int @deprecated(reason: "Use 1-100 range with rating100")
|
||||
# rating expressed as 1-100
|
||||
rating100: Int
|
||||
url: String
|
||||
date: String
|
||||
o_counter: Int
|
||||
organized: Boolean!
|
||||
path: String! @deprecated(reason: "Use files.path")
|
||||
|
|
@ -45,6 +47,8 @@ input ImageUpdateInput {
|
|||
# rating expressed as 1-100
|
||||
rating100: Int
|
||||
organized: Boolean
|
||||
url: String
|
||||
date: String
|
||||
|
||||
studio_id: ID
|
||||
performer_ids: [ID!]
|
||||
|
|
@ -63,6 +67,8 @@ input BulkImageUpdateInput {
|
|||
# rating expressed as 1-100
|
||||
rating100: Int
|
||||
organized: Boolean
|
||||
url: String
|
||||
date: String
|
||||
|
||||
studio_id: ID
|
||||
performer_ids: BulkUpdateIds
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
scalar Upload
|
||||
|
||||
input GenerateMetadataInput {
|
||||
covers: Boolean
|
||||
sprites: Boolean
|
||||
previews: Boolean
|
||||
imagePreviews: Boolean
|
||||
|
|
@ -37,6 +38,7 @@ input GeneratePreviewOptionsInput {
|
|||
}
|
||||
|
||||
type GenerateMetadataOptions {
|
||||
covers: Boolean
|
||||
sprites: Boolean
|
||||
previews: Boolean
|
||||
imagePreviews: Boolean
|
||||
|
|
@ -84,6 +86,8 @@ input ScanMetadataInput {
|
|||
|
||||
"""Strip file extension from title"""
|
||||
stripFileExtension: Boolean @deprecated(reason: "Not implemented")
|
||||
"""Generate covers during scan"""
|
||||
scanGenerateCovers: Boolean
|
||||
"""Generate previews during scan"""
|
||||
scanGeneratePreviews: Boolean
|
||||
"""Generate image previews during scan"""
|
||||
|
|
@ -101,9 +105,11 @@ input ScanMetadataInput {
|
|||
|
||||
type ScanMetadataOptions {
|
||||
"""Set name, date, details from metadata (if present)"""
|
||||
useFileMetadata: Boolean!
|
||||
useFileMetadata: Boolean! @deprecated(reason: "Not implemented")
|
||||
"""Strip file extension from title"""
|
||||
stripFileExtension: Boolean!
|
||||
stripFileExtension: Boolean! @deprecated(reason: "Not implemented")
|
||||
"""Generate covers during scan"""
|
||||
scanGenerateCovers: Boolean!
|
||||
"""Generate previews during scan"""
|
||||
scanGeneratePreviews: Boolean!
|
||||
"""Generate image previews during scan"""
|
||||
|
|
@ -263,6 +269,10 @@ input BackupDatabaseInput {
|
|||
download: Boolean
|
||||
}
|
||||
|
||||
input AnonymiseDatabaseInput {
|
||||
download: Boolean
|
||||
}
|
||||
|
||||
enum SystemStatusEnum {
|
||||
SETUP
|
||||
NEEDS_MIGRATION
|
||||
|
|
|
|||
11
graphql/schema/types/migration.graphql
Normal file
11
graphql/schema/types/migration.graphql
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
input MigrateSceneScreenshotsInput {
|
||||
# if true, delete screenshot files after migrating
|
||||
deleteFiles: Boolean
|
||||
# if true, overwrite existing covers with the covers from the screenshots directory
|
||||
overwriteExisting: Boolean
|
||||
}
|
||||
|
||||
input MigrateBlobsInput {
|
||||
# if true, delete blob data from old storage system
|
||||
deleteOld: Boolean
|
||||
}
|
||||
|
|
@ -9,8 +9,9 @@ enum GenderEnum {
|
|||
|
||||
type Performer {
|
||||
id: ID!
|
||||
checksum: String!
|
||||
name: String
|
||||
checksum: String @deprecated(reason: "Not used")
|
||||
name: String!
|
||||
disambiguation: String
|
||||
url: String
|
||||
gender: GenderEnum
|
||||
twitter: String
|
||||
|
|
@ -26,7 +27,8 @@ type Performer {
|
|||
career_length: String
|
||||
tattoos: String
|
||||
piercings: String
|
||||
aliases: String
|
||||
aliases: String @deprecated(reason: "Use alias_list")
|
||||
alias_list: [String!]!
|
||||
favorite: Boolean!
|
||||
tags: [Tag!]!
|
||||
ignore_auto_tag: Boolean!
|
||||
|
|
@ -53,6 +55,7 @@ type Performer {
|
|||
|
||||
input PerformerCreateInput {
|
||||
name: String!
|
||||
disambiguation: String
|
||||
url: String
|
||||
gender: GenderEnum
|
||||
birthdate: String
|
||||
|
|
@ -67,7 +70,8 @@ input PerformerCreateInput {
|
|||
career_length: String
|
||||
tattoos: String
|
||||
piercings: String
|
||||
aliases: String
|
||||
aliases: String @deprecated(reason: "Use alias_list")
|
||||
alias_list: [String!]
|
||||
twitter: String
|
||||
instagram: String
|
||||
favorite: Boolean
|
||||
|
|
@ -89,6 +93,7 @@ input PerformerCreateInput {
|
|||
input PerformerUpdateInput {
|
||||
id: ID!
|
||||
name: String
|
||||
disambiguation: String
|
||||
url: String
|
||||
gender: GenderEnum
|
||||
birthdate: String
|
||||
|
|
@ -103,7 +108,8 @@ input PerformerUpdateInput {
|
|||
career_length: String
|
||||
tattoos: String
|
||||
piercings: String
|
||||
aliases: String
|
||||
aliases: String @deprecated(reason: "Use alias_list")
|
||||
alias_list: [String!]
|
||||
twitter: String
|
||||
instagram: String
|
||||
favorite: Boolean
|
||||
|
|
@ -122,9 +128,15 @@ input PerformerUpdateInput {
|
|||
ignore_auto_tag: Boolean
|
||||
}
|
||||
|
||||
input BulkUpdateStrings {
|
||||
values: [String!]
|
||||
mode: BulkUpdateIdMode!
|
||||
}
|
||||
|
||||
input BulkPerformerUpdateInput {
|
||||
clientMutationId: String
|
||||
ids: [ID!]
|
||||
disambiguation: String
|
||||
url: String
|
||||
gender: GenderEnum
|
||||
birthdate: String
|
||||
|
|
@ -139,7 +151,8 @@ input BulkPerformerUpdateInput {
|
|||
career_length: String
|
||||
tattoos: String
|
||||
piercings: String
|
||||
aliases: String
|
||||
aliases: String @deprecated(reason: "Use alias_list")
|
||||
alias_list: BulkUpdateStrings
|
||||
twitter: String
|
||||
instagram: String
|
||||
favorite: Boolean
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ type ScrapedPerformer {
|
|||
"""Set if performer matched"""
|
||||
stored_id: ID
|
||||
name: String
|
||||
disambiguation: String
|
||||
gender: String
|
||||
url: String
|
||||
twitter: String
|
||||
|
|
@ -17,6 +18,7 @@ type ScrapedPerformer {
|
|||
career_length: String
|
||||
tattoos: String
|
||||
piercings: String
|
||||
# aliases must be comma-delimited to be parsed correctly
|
||||
aliases: String
|
||||
tags: [ScrapedTag!]
|
||||
|
||||
|
|
@ -34,6 +36,7 @@ input ScrapedPerformerInput {
|
|||
"""Set if performer matched"""
|
||||
stored_id: ID
|
||||
name: String
|
||||
disambiguation: String
|
||||
gender: String
|
||||
url: String
|
||||
twitter: String
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ type Studio {
|
|||
scene_count: Int # Resolver
|
||||
image_count: Int # Resolver
|
||||
gallery_count: Int # Resolver
|
||||
performer_count: Int # Resolver
|
||||
stash_ids: [StashID!]!
|
||||
# rating expressed as 1-5
|
||||
rating: Int @deprecated(reason: "Use 1-100 range with rating100")
|
||||
|
|
|
|||
|
|
@ -4,7 +4,9 @@ type Version {
|
|||
build_time: String!
|
||||
}
|
||||
|
||||
type ShortVersion {
|
||||
type LatestVersion {
|
||||
version: String!
|
||||
shorthash: String!
|
||||
release_date: String!
|
||||
url: String!
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,21 +13,24 @@ import (
|
|||
"github.com/stashapp/stash/pkg/session"
|
||||
)
|
||||
|
||||
const loginEndPoint = "/login"
|
||||
const (
|
||||
loginEndPoint = "/login"
|
||||
logoutEndPoint = "/logout"
|
||||
)
|
||||
|
||||
const (
|
||||
tripwireActivatedErrMsg = "Stash is exposed to the public internet without authentication, and is not serving any more content to protect your privacy. " +
|
||||
"More information and fixes are available at https://github.com/stashapp/stash/wiki/Authentication-Required-When-Accessing-Stash-From-the-Internet"
|
||||
"More information and fixes are available at https://docs.stashapp.cc/networking/authentication-required-when-accessing-stash-from-the-internet"
|
||||
|
||||
externalAccessErrMsg = "You have attempted to access Stash over the internet, and authentication is not enabled. " +
|
||||
"This is extremely dangerous! The whole world can see your your stash page and browse your files! " +
|
||||
"Stash is not answering any other requests to protect your privacy. " +
|
||||
"Please read the log entry or visit https://github.com/stashapp/stash/wiki/Authentication-Required-When-Accessing-Stash-From-the-Internet"
|
||||
"Please read the log entry or visit https://docs.stashapp.cc/networking/authentication-required-when-accessing-stash-from-the-internet"
|
||||
)
|
||||
|
||||
func allowUnauthenticated(r *http.Request) bool {
|
||||
// #2715 - allow access to UI files
|
||||
return strings.HasPrefix(r.URL.Path, loginEndPoint) || r.URL.Path == "/css" || strings.HasPrefix(r.URL.Path, "/assets")
|
||||
return strings.HasPrefix(r.URL.Path, loginEndPoint) || r.URL.Path == logoutEndPoint || r.URL.Path == "/css" || strings.HasPrefix(r.URL.Path, "/assets")
|
||||
}
|
||||
|
||||
func authenticateHandler() func(http.Handler) http.Handler {
|
||||
|
|
@ -85,12 +88,16 @@ func authenticateHandler() func(http.Handler) http.Handler {
|
|||
prefix := getProxyPrefix(r.Header)
|
||||
|
||||
// otherwise redirect to the login page
|
||||
u := url.URL{
|
||||
Path: prefix + "/login",
|
||||
returnURL := url.URL{
|
||||
Path: prefix + r.URL.Path,
|
||||
RawQuery: r.URL.RawQuery,
|
||||
}
|
||||
q := make(url.Values)
|
||||
q.Set(returnURLParam, returnURL.String())
|
||||
u := url.URL{
|
||||
Path: prefix + "/login",
|
||||
RawQuery: q.Encode(),
|
||||
}
|
||||
q := u.Query()
|
||||
q.Set(returnURLParam, prefix+r.URL.Path)
|
||||
u.RawQuery = q.Encode()
|
||||
http.Redirect(w, r, u.String(), http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,51 +0,0 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type byteRange struct {
|
||||
Start int64
|
||||
End *int64
|
||||
RawString string
|
||||
}
|
||||
|
||||
func createByteRange(s string) byteRange {
|
||||
// strip bytes=
|
||||
r := strings.TrimPrefix(s, "bytes=")
|
||||
e := strings.Split(r, "-")
|
||||
|
||||
ret := byteRange{
|
||||
RawString: s,
|
||||
}
|
||||
if len(e) > 0 {
|
||||
ret.Start, _ = strconv.ParseInt(e[0], 10, 64)
|
||||
}
|
||||
if len(e) > 1 && e[1] != "" {
|
||||
end, _ := strconv.ParseInt(e[1], 10, 64)
|
||||
ret.End = &end
|
||||
}
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
func (r byteRange) toHeaderValue(fileLength int64) string {
|
||||
if r.End == nil {
|
||||
return ""
|
||||
}
|
||||
end := *r.End
|
||||
return "bytes " + strconv.FormatInt(r.Start, 10) + "-" + strconv.FormatInt(end, 10) + "/" + strconv.FormatInt(fileLength, 10)
|
||||
}
|
||||
|
||||
func (r byteRange) apply(bytes []byte) []byte {
|
||||
if r.End == nil {
|
||||
return bytes[r.Start:]
|
||||
}
|
||||
|
||||
end := *r.End + 1
|
||||
if int(end) > len(bytes) {
|
||||
end = int64(len(bytes))
|
||||
}
|
||||
return bytes[r.Start:end]
|
||||
}
|
||||
|
|
@ -143,7 +143,7 @@ func (t changesetTranslator) optionalDate(value *string, field string) models.Op
|
|||
return models.OptionalDate{}
|
||||
}
|
||||
|
||||
if value == nil {
|
||||
if value == nil || *value == "" {
|
||||
return models.OptionalDate{
|
||||
Set: true,
|
||||
Null: true,
|
||||
|
|
|
|||
|
|
@ -21,11 +21,7 @@ const apiReleases string = "https://api.github.com/repos/stashapp/stash/releases
|
|||
const apiTags string = "https://api.github.com/repos/stashapp/stash/tags"
|
||||
const apiAcceptHeader string = "application/vnd.github.v3+json"
|
||||
const developmentTag string = "latest_develop"
|
||||
const defaultSHLength int = 7 // default length of SHA short hash returned by <git rev-parse --short HEAD>
|
||||
|
||||
// ErrNoVersion indicates that no version information has been embedded in the
|
||||
// stash binary
|
||||
var ErrNoVersion = errors.New("no stash version")
|
||||
const defaultSHLength int = 8 // default length of SHA short hash returned by <git rev-parse --short HEAD>
|
||||
|
||||
var stashReleases = func() map[string]string {
|
||||
return map[string]string{
|
||||
|
|
@ -108,9 +104,21 @@ type githubTagResponse struct {
|
|||
Node_id string
|
||||
}
|
||||
|
||||
type LatestRelease struct {
|
||||
Version string
|
||||
Hash string
|
||||
ShortHash string
|
||||
Date string
|
||||
Url string
|
||||
}
|
||||
|
||||
func makeGithubRequest(ctx context.Context, url string, output interface{}) error {
|
||||
|
||||
transport := &http.Transport{Proxy: http.ProxyFromEnvironment}
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: 3 * time.Second,
|
||||
Timeout: 3 * time.Second,
|
||||
Transport: transport,
|
||||
}
|
||||
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
|
|
@ -144,14 +152,16 @@ func makeGithubRequest(ctx context.Context, url string, output interface{}) erro
|
|||
return nil
|
||||
}
|
||||
|
||||
// GetLatestVersion gets latest version (git commit hash) from github API
|
||||
// GetLatestRelease gets latest release information from github API
|
||||
// If running a build from the "master" branch, then the latest full release
|
||||
// is used, otherwise it uses the release that is tagged with "latest_develop"
|
||||
// which is the latest pre-release build.
|
||||
func GetLatestVersion(ctx context.Context, shortHash bool) (latestVersion string, latestRelease string, err error) {
|
||||
func GetLatestRelease(ctx context.Context) (*LatestRelease, error) {
|
||||
arch := runtime.GOARCH
|
||||
|
||||
arch := runtime.GOARCH // https://en.wikipedia.org/wiki/Comparison_of_ARM_cores
|
||||
isARMv7 := cpu.ARM.HasNEON || cpu.ARM.HasVFPv3 || cpu.ARM.HasVFPv3D16 || cpu.ARM.HasVFPv4 // armv6 doesn't support any of these features
|
||||
// https://en.wikipedia.org/wiki/Comparison_of_ARM_cores
|
||||
// armv6 doesn't support any of these features
|
||||
isARMv7 := cpu.ARM.HasNEON || cpu.ARM.HasVFPv3 || cpu.ARM.HasVFPv3D16 || cpu.ARM.HasVFPv4
|
||||
if arch == "arm" && isARMv7 {
|
||||
arch = "armv7"
|
||||
}
|
||||
|
|
@ -159,125 +169,98 @@ func GetLatestVersion(ctx context.Context, shortHash bool) (latestVersion string
|
|||
platform := fmt.Sprintf("%s/%s", runtime.GOOS, arch)
|
||||
wantedRelease := stashReleases()[platform]
|
||||
|
||||
version, _, _ := GetVersion()
|
||||
if version == "" {
|
||||
return "", "", ErrNoVersion
|
||||
}
|
||||
|
||||
// if the version is suffixed with -x-xxxx, then we are running a development build
|
||||
usePreRelease := false
|
||||
re := regexp.MustCompile(`-\d+-g\w+$`)
|
||||
if re.MatchString(version) {
|
||||
usePreRelease = true
|
||||
}
|
||||
|
||||
url := apiReleases
|
||||
if !usePreRelease {
|
||||
// just get the latest full release
|
||||
url += "/latest"
|
||||
} else {
|
||||
if IsDevelop() {
|
||||
// get the release tagged with the development tag
|
||||
url += "/tags/" + developmentTag
|
||||
} else {
|
||||
// just get the latest full release
|
||||
url += "/latest"
|
||||
}
|
||||
|
||||
release := githubReleasesResponse{}
|
||||
err = makeGithubRequest(ctx, url, &release)
|
||||
|
||||
var release githubReleasesResponse
|
||||
err := makeGithubRequest(ctx, url, &release)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if release.Prerelease == usePreRelease {
|
||||
latestVersion = getReleaseHash(ctx, release, shortHash, usePreRelease)
|
||||
version := release.Name
|
||||
if release.Prerelease {
|
||||
// find version in prerelease name
|
||||
re := regexp.MustCompile(`v[\w-\.]+-\d+-g[0-9a-f]+`)
|
||||
if match := re.FindString(version); match != "" {
|
||||
version = match
|
||||
}
|
||||
}
|
||||
|
||||
if wantedRelease != "" {
|
||||
for _, asset := range release.Assets {
|
||||
if asset.Name == wantedRelease {
|
||||
latestRelease = asset.Browser_download_url
|
||||
break
|
||||
}
|
||||
latestHash, err := getReleaseHash(ctx, release.Tag_name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var releaseDate string
|
||||
if publishedAt, err := time.Parse(time.RFC3339, release.Published_at); err == nil {
|
||||
releaseDate = publishedAt.Format("2006-01-02")
|
||||
}
|
||||
|
||||
var releaseUrl string
|
||||
if wantedRelease != "" {
|
||||
for _, asset := range release.Assets {
|
||||
if asset.Name == wantedRelease {
|
||||
releaseUrl = asset.Browser_download_url
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if latestVersion == "" {
|
||||
return "", "", fmt.Errorf("no version found for \"%s\"", version)
|
||||
_, githash, _ := GetVersion()
|
||||
shLength := len(githash)
|
||||
if shLength == 0 {
|
||||
shLength = defaultSHLength
|
||||
}
|
||||
return latestVersion, latestRelease, nil
|
||||
|
||||
return &LatestRelease{
|
||||
Version: version,
|
||||
Hash: latestHash,
|
||||
ShortHash: latestHash[:shLength],
|
||||
Date: releaseDate,
|
||||
Url: releaseUrl,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func getReleaseHash(ctx context.Context, release githubReleasesResponse, shortHash bool, usePreRelease bool) string {
|
||||
shaLength := len(release.Target_commitish)
|
||||
// the /latest API call doesn't return the hash in target_commitish
|
||||
// also add sanity check in case Target_commitish is not 40 characters
|
||||
if !usePreRelease || shaLength != 40 {
|
||||
return getShaFromTags(ctx, shortHash, release.Tag_name)
|
||||
}
|
||||
|
||||
if shortHash {
|
||||
last := defaultSHLength // default length of git short hash
|
||||
_, gitShort, _ := GetVersion() // retrieve it to check actual length
|
||||
if len(gitShort) > last && len(gitShort) < shaLength { // sometimes short hash is longer
|
||||
last = len(gitShort)
|
||||
}
|
||||
return release.Target_commitish[0:last]
|
||||
}
|
||||
|
||||
return release.Target_commitish
|
||||
}
|
||||
|
||||
func printLatestVersion(ctx context.Context) {
|
||||
_, githash, _ = GetVersion()
|
||||
latest, _, err := GetLatestVersion(ctx, true)
|
||||
if err != nil {
|
||||
logger.Errorf("Couldn't find latest version: %s", err)
|
||||
} else {
|
||||
if githash == latest {
|
||||
logger.Infof("Version: (%s) is already the latest released.", latest)
|
||||
} else {
|
||||
logger.Infof("New version: (%s) available.", latest)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// get sha from the github api tags endpoint
|
||||
// returns the sha1 hash/shorthash or "" if something's wrong
|
||||
func getShaFromTags(ctx context.Context, shortHash bool, name string) string {
|
||||
func getReleaseHash(ctx context.Context, tagName string) (string, error) {
|
||||
url := apiTags
|
||||
tags := []githubTagResponse{}
|
||||
err := makeGithubRequest(ctx, url, &tags)
|
||||
|
||||
if err != nil {
|
||||
// If the context is canceled, we don't want to log this as an error
|
||||
// in the path. The function here just gives up and returns "" if
|
||||
// something goes wrong. Hence, log the error at the info-level so
|
||||
// it's still present, but don't treat this as an error.
|
||||
if errors.Is(err, context.Canceled) {
|
||||
logger.Infof("aborting sha request due to context cancellation")
|
||||
} else {
|
||||
logger.Errorf("Github Tags Api: %v", err)
|
||||
}
|
||||
return ""
|
||||
return "", err
|
||||
}
|
||||
_, gitShort, _ := GetVersion() // retrieve short hash to check actual length
|
||||
|
||||
for _, tag := range tags {
|
||||
if tag.Name == name {
|
||||
shaLength := len(tag.Commit.Sha)
|
||||
if shaLength != 40 {
|
||||
return ""
|
||||
if tag.Name == tagName {
|
||||
if len(tag.Commit.Sha) != 40 {
|
||||
return "", errors.New("invalid Github API response")
|
||||
}
|
||||
if shortHash {
|
||||
last := defaultSHLength // default length of git short hash
|
||||
if len(gitShort) > last && len(gitShort) < shaLength { // sometimes short hash is longer
|
||||
last = len(gitShort)
|
||||
}
|
||||
return tag.Commit.Sha[0:last]
|
||||
}
|
||||
|
||||
return tag.Commit.Sha
|
||||
return tag.Commit.Sha, nil
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
return "", errors.New("invalid Github API response")
|
||||
}
|
||||
|
||||
func printLatestVersion(ctx context.Context) {
|
||||
latestRelease, err := GetLatestRelease(ctx)
|
||||
if err != nil {
|
||||
logger.Errorf("Couldn't retrieve latest version: %v", err)
|
||||
} else {
|
||||
_, githash, _ = GetVersion()
|
||||
switch {
|
||||
case githash == "":
|
||||
logger.Infof("Latest version: %s (%s)", latestRelease.Version, latestRelease.ShortHash)
|
||||
case githash == latestRelease.ShortHash:
|
||||
logger.Infof("Version %s (%s) is already the latest released", latestRelease.Version, latestRelease.ShortHash)
|
||||
default:
|
||||
logger.Infof("New version available: %s (%s)", latestRelease.Version, latestRelease.ShortHash)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,6 +47,9 @@ func (r *Resolver) scraperCache() *scraper.Cache {
|
|||
func (r *Resolver) Gallery() GalleryResolver {
|
||||
return &galleryResolver{r}
|
||||
}
|
||||
func (r *Resolver) GalleryChapter() GalleryChapterResolver {
|
||||
return &galleryChapterResolver{r}
|
||||
}
|
||||
func (r *Resolver) Mutation() MutationResolver {
|
||||
return &mutationResolver{r}
|
||||
}
|
||||
|
|
@ -83,6 +86,7 @@ type queryResolver struct{ *Resolver }
|
|||
type subscriptionResolver struct{ *Resolver }
|
||||
|
||||
type galleryResolver struct{ *Resolver }
|
||||
type galleryChapterResolver struct{ *Resolver }
|
||||
type performerResolver struct{ *Resolver }
|
||||
type sceneResolver struct{ *Resolver }
|
||||
type sceneMarkerResolver struct{ *Resolver }
|
||||
|
|
@ -184,19 +188,22 @@ func (r *queryResolver) Version(ctx context.Context) (*Version, error) {
|
|||
}, nil
|
||||
}
|
||||
|
||||
// Latestversion returns the latest git shorthash commit.
|
||||
func (r *queryResolver) Latestversion(ctx context.Context) (*ShortVersion, error) {
|
||||
ver, url, err := GetLatestVersion(ctx, true)
|
||||
if err == nil {
|
||||
logger.Infof("Retrieved latest hash: %s", ver)
|
||||
} else {
|
||||
logger.Errorf("Error while retrieving latest hash: %s", err)
|
||||
func (r *queryResolver) Latestversion(ctx context.Context) (*LatestVersion, error) {
|
||||
latestRelease, err := GetLatestRelease(ctx)
|
||||
if err != nil {
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
logger.Errorf("Error while retrieving latest version: %v", err)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
logger.Infof("Retrieved latest version: %s (%s)", latestRelease.Version, latestRelease.ShortHash)
|
||||
|
||||
return &ShortVersion{
|
||||
Shorthash: ver,
|
||||
URL: url,
|
||||
}, err
|
||||
return &LatestVersion{
|
||||
Version: latestRelease.Version,
|
||||
Shorthash: latestRelease.ShortHash,
|
||||
ReleaseDate: latestRelease.Date,
|
||||
URL: latestRelease.Url,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Get scene marker tags which show up under the video.
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/stashapp/stash/internal/api/loaders"
|
||||
"github.com/stashapp/stash/internal/manager/config"
|
||||
|
||||
"github.com/stashapp/stash/pkg/file"
|
||||
"github.com/stashapp/stash/pkg/image"
|
||||
|
|
@ -145,8 +146,8 @@ func (r *galleryResolver) Images(ctx context.Context, obj *models.Gallery) (ret
|
|||
|
||||
func (r *galleryResolver) Cover(ctx context.Context, obj *models.Gallery) (ret *models.Image, err error) {
|
||||
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
|
||||
// find cover.jpg first
|
||||
ret, err = image.FindGalleryCover(ctx, r.repository.Image, obj.ID)
|
||||
// Find cover image first
|
||||
ret, err = image.FindGalleryCover(ctx, r.repository.Image, obj.ID, config.GetInstance().GetGalleryCoverRegex())
|
||||
return err
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
|
|
@ -248,3 +249,14 @@ func (r *galleryResolver) ImageCount(ctx context.Context, obj *models.Gallery) (
|
|||
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func (r *galleryResolver) Chapters(ctx context.Context, obj *models.Gallery) (ret []*models.GalleryChapter, err error) {
|
||||
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
|
||||
ret, err = r.repository.GalleryChapter.FindByGalleryID(ctx, obj.ID)
|
||||
return err
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
|
|
|||
32
internal/api/resolver_model_gallery_chapter.go
Normal file
32
internal/api/resolver_model_gallery_chapter.go
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
)
|
||||
|
||||
func (r *galleryChapterResolver) Gallery(ctx context.Context, obj *models.GalleryChapter) (ret *models.Gallery, err error) {
|
||||
if !obj.GalleryID.Valid {
|
||||
panic("Invalid gallery id")
|
||||
}
|
||||
|
||||
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
|
||||
galleryID := int(obj.GalleryID.Int64)
|
||||
ret, err = r.repository.Gallery.Find(ctx, galleryID)
|
||||
return err
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func (r *galleryChapterResolver) CreatedAt(ctx context.Context, obj *models.GalleryChapter) (*time.Time, error) {
|
||||
return &obj.CreatedAt.Timestamp, nil
|
||||
}
|
||||
|
||||
func (r *galleryChapterResolver) UpdatedAt(ctx context.Context, obj *models.GalleryChapter) (*time.Time, error) {
|
||||
return &obj.UpdatedAt.Timestamp, nil
|
||||
}
|
||||
|
|
@ -75,6 +75,14 @@ func (r *imageResolver) File(ctx context.Context, obj *models.Image) (*ImageFile
|
|||
}, nil
|
||||
}
|
||||
|
||||
func (r *imageResolver) Date(ctx context.Context, obj *models.Image) (*string, error) {
|
||||
if obj.Date != nil {
|
||||
result := obj.Date.String()
|
||||
return &result, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (r *imageResolver) Files(ctx context.Context, obj *models.Image) ([]*ImageFile, error) {
|
||||
files, err := r.getFiles(ctx, obj)
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -93,10 +93,10 @@ func (r *movieResolver) FrontImagePath(ctx context.Context, obj *models.Movie) (
|
|||
|
||||
func (r *movieResolver) BackImagePath(ctx context.Context, obj *models.Movie) (*string, error) {
|
||||
// don't return any thing if there is no back image
|
||||
var img []byte
|
||||
hasImage := false
|
||||
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
|
||||
var err error
|
||||
img, err = r.repository.Movie.GetBackImage(ctx, obj.ID)
|
||||
hasImage, err = r.repository.Movie.HasBackImage(ctx, obj.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -106,7 +106,7 @@ func (r *movieResolver) BackImagePath(ctx context.Context, obj *models.Movie) (*
|
|||
return nil, err
|
||||
}
|
||||
|
||||
if img == nil {
|
||||
if !hasImage {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,13 +3,45 @@ package api
|
|||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/stashapp/stash/internal/api/loaders"
|
||||
"github.com/stashapp/stash/internal/api/urlbuilders"
|
||||
"github.com/stashapp/stash/pkg/gallery"
|
||||
"github.com/stashapp/stash/pkg/image"
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
)
|
||||
|
||||
// Checksum is deprecated
|
||||
func (r *performerResolver) Checksum(ctx context.Context, obj *models.Performer) (*string, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (r *performerResolver) Aliases(ctx context.Context, obj *models.Performer) (*string, error) {
|
||||
if !obj.Aliases.Loaded() {
|
||||
if err := r.withTxn(ctx, func(ctx context.Context) error {
|
||||
return obj.LoadAliases(ctx, r.repository.Performer)
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
ret := strings.Join(obj.Aliases.List(), ", ")
|
||||
return &ret, nil
|
||||
}
|
||||
|
||||
func (r *performerResolver) AliasList(ctx context.Context, obj *models.Performer) ([]string, error) {
|
||||
if !obj.Aliases.Loaded() {
|
||||
if err := r.withTxn(ctx, func(ctx context.Context) error {
|
||||
return obj.LoadAliases(ctx, r.repository.Performer)
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return obj.Aliases.List(), nil
|
||||
}
|
||||
|
||||
func (r *performerResolver) Height(ctx context.Context, obj *models.Performer) (*string, error) {
|
||||
if obj.Height != nil {
|
||||
ret := strconv.Itoa(*obj.Height)
|
||||
|
|
@ -37,14 +69,17 @@ func (r *performerResolver) ImagePath(ctx context.Context, obj *models.Performer
|
|||
}
|
||||
|
||||
func (r *performerResolver) Tags(ctx context.Context, obj *models.Performer) (ret []*models.Tag, err error) {
|
||||
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
|
||||
ret, err = r.repository.Tag.FindByPerformerID(ctx, obj.ID)
|
||||
return err
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
if !obj.TagIDs.Loaded() {
|
||||
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
|
||||
return obj.LoadTagIDs(ctx, r.repository.Performer)
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return ret, nil
|
||||
var errs []error
|
||||
ret, errs = loaders.From(ctx).TagByID.LoadAll(obj.TagIDs.List())
|
||||
return ret, firstError(errs)
|
||||
}
|
||||
|
||||
func (r *performerResolver) SceneCount(ctx context.Context, obj *models.Performer) (ret *int, err error) {
|
||||
|
|
@ -95,16 +130,13 @@ func (r *performerResolver) Scenes(ctx context.Context, obj *models.Performer) (
|
|||
}
|
||||
|
||||
func (r *performerResolver) StashIds(ctx context.Context, obj *models.Performer) ([]*models.StashID, error) {
|
||||
var ret []models.StashID
|
||||
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
|
||||
var err error
|
||||
ret, err = r.repository.Performer.GetStashIDs(ctx, obj.ID)
|
||||
return err
|
||||
return obj.LoadStashIDs(ctx, r.repository.Performer)
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return stashIDsSliceToPtrSlice(ret), nil
|
||||
return stashIDsSliceToPtrSlice(obj.StashIDs.List()), nil
|
||||
}
|
||||
|
||||
func (r *performerResolver) Rating(ctx context.Context, obj *models.Performer) (*int, error) {
|
||||
|
|
|
|||
|
|
@ -179,13 +179,13 @@ func (r *sceneResolver) Paths(ctx context.Context, obj *models.Scene) (*ScenePat
|
|||
baseURL, _ := ctx.Value(BaseURLCtxKey).(string)
|
||||
config := manager.GetInstance().Config
|
||||
builder := urlbuilders.NewSceneURLBuilder(baseURL, obj.ID)
|
||||
builder.APIKey = config.GetAPIKey()
|
||||
screenshotPath := builder.GetScreenshotURL(obj.UpdatedAt)
|
||||
previewPath := builder.GetStreamPreviewURL()
|
||||
streamPath := builder.GetStreamURL().String()
|
||||
streamPath := builder.GetStreamURL(config.GetAPIKey()).String()
|
||||
webpPath := builder.GetStreamPreviewImageURL()
|
||||
vttPath := builder.GetSpriteVTTURL()
|
||||
spritePath := builder.GetSpriteURL()
|
||||
objHash := obj.GetHash(config.GetVideoFileNamingAlgorithm())
|
||||
vttPath := builder.GetSpriteVTTURL(objHash)
|
||||
spritePath := builder.GetSpriteURL(objHash)
|
||||
chaptersVttPath := builder.GetChaptersVTTURL()
|
||||
funscriptPath := builder.GetFunscriptURL()
|
||||
captionBasePath := builder.GetCaptionURL()
|
||||
|
|
@ -371,9 +371,9 @@ func (r *sceneResolver) SceneStreams(ctx context.Context, obj *models.Scene) ([]
|
|||
|
||||
baseURL, _ := ctx.Value(BaseURLCtxKey).(string)
|
||||
builder := urlbuilders.NewSceneURLBuilder(baseURL, obj.ID)
|
||||
builder.APIKey = config.GetAPIKey()
|
||||
apiKey := config.GetAPIKey()
|
||||
|
||||
return manager.GetSceneStreamPaths(obj, builder.GetStreamURL(), config.GetMaxStreamingTranscodeSize())
|
||||
return manager.GetSceneStreamPaths(obj, builder.GetStreamURL(apiKey), config.GetMaxStreamingTranscodeSize())
|
||||
}
|
||||
|
||||
func (r *sceneResolver) Interactive(ctx context.Context, obj *models.Scene) (bool, error) {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import (
|
|||
"github.com/stashapp/stash/pkg/gallery"
|
||||
"github.com/stashapp/stash/pkg/image"
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stashapp/stash/pkg/performer"
|
||||
)
|
||||
|
||||
func (r *studioResolver) Name(ctx context.Context, obj *models.Studio) (string, error) {
|
||||
|
|
@ -93,6 +94,18 @@ func (r *studioResolver) GalleryCount(ctx context.Context, obj *models.Studio) (
|
|||
return &res, nil
|
||||
}
|
||||
|
||||
func (r *studioResolver) PerformerCount(ctx context.Context, obj *models.Studio) (ret *int, err error) {
|
||||
var res int
|
||||
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
|
||||
res, err = performer.CountByStudioID(ctx, r.repository.Performer, obj.ID)
|
||||
return err
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &res, nil
|
||||
}
|
||||
|
||||
func (r *studioResolver) ParentStudio(ctx context.Context, obj *models.Studio) (ret *models.Studio, err error) {
|
||||
if !obj.ParentID.Valid {
|
||||
return nil, nil
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import (
|
|||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
|
||||
"github.com/stashapp/stash/internal/manager"
|
||||
"github.com/stashapp/stash/internal/manager/config"
|
||||
|
|
@ -122,6 +123,7 @@ func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input ConfigGen
|
|||
c.Set(config.Metadata, input.MetadataPath)
|
||||
}
|
||||
|
||||
refreshStreamManager := false
|
||||
existingCachePath := c.GetCachePath()
|
||||
if input.CachePath != nil && existingCachePath != *input.CachePath {
|
||||
if err := validateDir(config.Cache, *input.CachePath, true); err != nil {
|
||||
|
|
@ -129,6 +131,29 @@ func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input ConfigGen
|
|||
}
|
||||
|
||||
c.Set(config.Cache, input.CachePath)
|
||||
refreshStreamManager = true
|
||||
}
|
||||
|
||||
refreshBlobStorage := false
|
||||
existingBlobsPath := c.GetBlobsPath()
|
||||
if input.BlobsPath != nil && existingBlobsPath != *input.BlobsPath {
|
||||
if err := validateDir(config.BlobsPath, *input.BlobsPath, true); err != nil {
|
||||
return makeConfigGeneralResult(), err
|
||||
}
|
||||
|
||||
c.Set(config.BlobsPath, input.BlobsPath)
|
||||
refreshBlobStorage = true
|
||||
}
|
||||
|
||||
if input.BlobsStorage != nil && *input.BlobsStorage != c.GetBlobsStorage() {
|
||||
if *input.BlobsStorage == config.BlobStorageTypeFilesystem && c.GetBlobsPath() == "" {
|
||||
return makeConfigGeneralResult(), fmt.Errorf("blobs path must be set when using filesystem storage")
|
||||
}
|
||||
|
||||
// TODO - migrate between systems
|
||||
c.Set(config.BlobsStorage, input.BlobsStorage)
|
||||
|
||||
refreshBlobStorage = true
|
||||
}
|
||||
|
||||
if input.VideoFileNamingAlgorithm != nil && *input.VideoFileNamingAlgorithm != c.GetVideoFileNamingAlgorithm() {
|
||||
|
|
@ -178,6 +203,9 @@ func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input ConfigGen
|
|||
c.Set(config.PreviewPreset, input.PreviewPreset.String())
|
||||
}
|
||||
|
||||
if input.TranscodeHardwareAcceleration != nil {
|
||||
c.Set(config.TranscodeHardwareAcceleration, *input.TranscodeHardwareAcceleration)
|
||||
}
|
||||
if input.MaxTranscodeSize != nil {
|
||||
c.Set(config.MaxTranscodeSize, input.MaxTranscodeSize.String())
|
||||
}
|
||||
|
|
@ -190,6 +218,16 @@ func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input ConfigGen
|
|||
c.Set(config.WriteImageThumbnails, *input.WriteImageThumbnails)
|
||||
}
|
||||
|
||||
if input.GalleryCoverRegex != nil {
|
||||
|
||||
_, err := regexp.Compile(*input.GalleryCoverRegex)
|
||||
if err != nil {
|
||||
return makeConfigGeneralResult(), fmt.Errorf("Gallery cover regex '%v' invalid, '%v'", *input.GalleryCoverRegex, err.Error())
|
||||
}
|
||||
|
||||
c.Set(config.GalleryCoverRegex, *input.GalleryCoverRegex)
|
||||
}
|
||||
|
||||
if input.Username != nil {
|
||||
c.Set(config.Username, input.Username)
|
||||
}
|
||||
|
|
@ -227,10 +265,22 @@ func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input ConfigGen
|
|||
}
|
||||
|
||||
if input.Excludes != nil {
|
||||
for _, r := range input.Excludes {
|
||||
_, err := regexp.Compile(r)
|
||||
if err != nil {
|
||||
return makeConfigGeneralResult(), fmt.Errorf("video exclusion pattern '%v' invalid: %w", r, err)
|
||||
}
|
||||
}
|
||||
c.Set(config.Exclude, input.Excludes)
|
||||
}
|
||||
|
||||
if input.ImageExcludes != nil {
|
||||
for _, r := range input.ImageExcludes {
|
||||
_, err := regexp.Compile(r)
|
||||
if err != nil {
|
||||
return makeConfigGeneralResult(), fmt.Errorf("image/gallery exclusion pattern '%v' invalid: %w", r, err)
|
||||
}
|
||||
}
|
||||
c.Set(config.ImageExclude, input.ImageExcludes)
|
||||
}
|
||||
|
||||
|
|
@ -280,6 +330,23 @@ func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input ConfigGen
|
|||
c.Set(config.PythonPath, input.PythonPath)
|
||||
}
|
||||
|
||||
if input.TranscodeInputArgs != nil {
|
||||
c.Set(config.TranscodeInputArgs, input.TranscodeInputArgs)
|
||||
}
|
||||
if input.TranscodeOutputArgs != nil {
|
||||
c.Set(config.TranscodeOutputArgs, input.TranscodeOutputArgs)
|
||||
}
|
||||
if input.LiveTranscodeInputArgs != nil {
|
||||
c.Set(config.LiveTranscodeInputArgs, input.LiveTranscodeInputArgs)
|
||||
}
|
||||
if input.LiveTranscodeOutputArgs != nil {
|
||||
c.Set(config.LiveTranscodeOutputArgs, input.LiveTranscodeOutputArgs)
|
||||
}
|
||||
|
||||
if input.DrawFunscriptHeatmapRange != nil {
|
||||
c.Set(config.DrawFunscriptHeatmapRange, input.DrawFunscriptHeatmapRange)
|
||||
}
|
||||
|
||||
if err := c.Write(); err != nil {
|
||||
return makeConfigGeneralResult(), err
|
||||
}
|
||||
|
|
@ -288,6 +355,12 @@ func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input ConfigGen
|
|||
if refreshScraperCache {
|
||||
manager.GetInstance().RefreshScraperCache()
|
||||
}
|
||||
if refreshStreamManager {
|
||||
manager.GetInstance().RefreshStreamManager()
|
||||
}
|
||||
if refreshBlobStorage {
|
||||
manager.GetInstance().SetBlobStoreOptions()
|
||||
}
|
||||
|
||||
return makeConfigGeneralResult(), nil
|
||||
}
|
||||
|
|
@ -451,6 +524,12 @@ func (r *mutationResolver) ConfigureScraping(ctx context.Context, input ConfigSc
|
|||
}
|
||||
|
||||
if input.ExcludeTagPatterns != nil {
|
||||
for _, r := range input.ExcludeTagPatterns {
|
||||
_, err := regexp.Compile(r)
|
||||
if err != nil {
|
||||
return makeConfigScrapingResult(), fmt.Errorf("tag exclusion pattern '%v' invalid: %w", r, err)
|
||||
}
|
||||
}
|
||||
c.Set(config.ScraperExcludeTagPatterns, input.ExcludeTagPatterns)
|
||||
}
|
||||
|
||||
|
|
@ -476,6 +555,8 @@ func (r *mutationResolver) ConfigureDefaults(ctx context.Context, input ConfigDe
|
|||
}
|
||||
|
||||
if input.Scan != nil {
|
||||
// if input.Scan is used then ScanMetadataOptions is included in the config file
|
||||
// this causes the values to not be read correctly
|
||||
c.Set(config.DefaultScanSettings, input.Scan.ScanMetadataOptions)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,11 +3,145 @@ package api
|
|||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"github.com/stashapp/stash/internal/manager"
|
||||
"github.com/stashapp/stash/pkg/file"
|
||||
"github.com/stashapp/stash/pkg/fsutil"
|
||||
"github.com/stashapp/stash/pkg/sliceutil/stringslice"
|
||||
)
|
||||
|
||||
func (r *mutationResolver) MoveFiles(ctx context.Context, input MoveFilesInput) (bool, error) {
|
||||
if err := r.withTxn(ctx, func(ctx context.Context) error {
|
||||
fileStore := r.repository.File
|
||||
folderStore := r.repository.Folder
|
||||
mover := file.NewMover(fileStore, folderStore)
|
||||
mover.RegisterHooks(ctx, r.txnManager)
|
||||
|
||||
var (
|
||||
folder *file.Folder
|
||||
basename string
|
||||
)
|
||||
|
||||
fileIDs, err := stringslice.StringSliceToIntSlice(input.Ids)
|
||||
if err != nil {
|
||||
return fmt.Errorf("converting file ids: %w", err)
|
||||
}
|
||||
|
||||
switch {
|
||||
case input.DestinationFolderID != nil:
|
||||
var err error
|
||||
|
||||
folderID, err := strconv.Atoi(*input.DestinationFolderID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid folder id %s: %w", *input.DestinationFolderID, err)
|
||||
}
|
||||
|
||||
folder, err = folderStore.Find(ctx, file.FolderID(folderID))
|
||||
if err != nil {
|
||||
return fmt.Errorf("finding destination folder: %w", err)
|
||||
}
|
||||
|
||||
if folder == nil {
|
||||
return fmt.Errorf("folder with id %d not found", input.DestinationFolderID)
|
||||
}
|
||||
|
||||
if folder.ZipFileID != nil {
|
||||
return fmt.Errorf("cannot move to %s, is in a zip file", folder.Path)
|
||||
}
|
||||
case input.DestinationFolder != nil:
|
||||
folderPath := *input.DestinationFolder
|
||||
|
||||
// ensure folder path is within the library
|
||||
if err := r.validateFolderPath(folderPath); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// get or create folder hierarchy
|
||||
var err error
|
||||
folder, err = file.GetOrCreateFolderHierarchy(ctx, folderStore, folderPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting or creating folder hierarchy: %w", err)
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("must specify destination folder or path")
|
||||
}
|
||||
|
||||
if input.DestinationBasename != nil {
|
||||
// ensure only one file was supplied
|
||||
if len(input.Ids) != 1 {
|
||||
return fmt.Errorf("must specify one file when providing destination path")
|
||||
}
|
||||
|
||||
basename = *input.DestinationBasename
|
||||
}
|
||||
|
||||
// create the folder hierarchy in the filesystem if needed
|
||||
if err := mover.CreateFolderHierarchy(folder.Path); err != nil {
|
||||
return fmt.Errorf("creating folder hierarchy %s in filesystem: %w", folder.Path, err)
|
||||
}
|
||||
|
||||
for _, fileIDInt := range fileIDs {
|
||||
fileID := file.ID(fileIDInt)
|
||||
f, err := fileStore.Find(ctx, fileID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("finding file %d: %w", fileID, err)
|
||||
}
|
||||
|
||||
// ensure that the file extension matches the existing file type
|
||||
if basename != "" {
|
||||
if err := r.validateFileExtension(f[0].Base().Basename, basename); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := mover.Move(ctx, f[0], folder, basename); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (r *mutationResolver) validateFolderPath(folderPath string) error {
|
||||
paths := manager.GetInstance().Config.GetStashPaths()
|
||||
if l := paths.GetStashFromDirPath(folderPath); l == nil {
|
||||
return fmt.Errorf("folder path %s must be within a stash library path", folderPath)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *mutationResolver) validateFileExtension(oldBasename, newBasename string) error {
|
||||
c := manager.GetInstance().Config
|
||||
if err := r.validateFileExtensionList(c.GetVideoExtensions(), oldBasename, newBasename); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := r.validateFileExtensionList(c.GetImageExtensions(), oldBasename, newBasename); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := r.validateFileExtensionList(c.GetGalleryExtensions(), oldBasename, newBasename); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *mutationResolver) validateFileExtensionList(exts []string, oldBasename, newBasename string) error {
|
||||
if fsutil.MatchExtension(oldBasename, exts) && !fsutil.MatchExtension(newBasename, exts) {
|
||||
return fmt.Errorf("file extension for %s is inconsistent with old filename %s", newBasename, oldBasename)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *mutationResolver) DeleteFiles(ctx context.Context, ids []string) (ret bool, err error) {
|
||||
fileIDs, err := stringslice.StringSliceToIntSlice(ids)
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package api
|
|||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
|
|
@ -10,6 +11,7 @@ import (
|
|||
|
||||
"github.com/stashapp/stash/internal/manager"
|
||||
"github.com/stashapp/stash/pkg/file"
|
||||
"github.com/stashapp/stash/pkg/gallery"
|
||||
"github.com/stashapp/stash/pkg/image"
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stashapp/stash/pkg/plugin"
|
||||
|
|
@ -489,3 +491,150 @@ func (r *mutationResolver) RemoveGalleryImages(ctx context.Context, input Galler
|
|||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (r *mutationResolver) getGalleryChapter(ctx context.Context, id int) (ret *models.GalleryChapter, err error) {
|
||||
if err := r.withTxn(ctx, func(ctx context.Context) error {
|
||||
ret, err = r.repository.GalleryChapter.Find(ctx, id)
|
||||
return err
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func (r *mutationResolver) GalleryChapterCreate(ctx context.Context, input GalleryChapterCreateInput) (*models.GalleryChapter, error) {
|
||||
galleryID, err := strconv.Atoi(input.GalleryID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var imageCount int
|
||||
if err := r.withTxn(ctx, func(ctx context.Context) error {
|
||||
imageCount, err = r.repository.Image.CountByGalleryID(ctx, galleryID)
|
||||
return err
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Sanity Check of Index
|
||||
if input.ImageIndex > imageCount || input.ImageIndex < 1 {
|
||||
return nil, errors.New("Image # must greater than zero and in range of the gallery images")
|
||||
}
|
||||
|
||||
currentTime := time.Now()
|
||||
newGalleryChapter := models.GalleryChapter{
|
||||
Title: input.Title,
|
||||
ImageIndex: input.ImageIndex,
|
||||
GalleryID: sql.NullInt64{Int64: int64(galleryID), Valid: galleryID != 0},
|
||||
CreatedAt: models.SQLiteTimestamp{Timestamp: currentTime},
|
||||
UpdatedAt: models.SQLiteTimestamp{Timestamp: currentTime},
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ret, err := r.changeChapter(ctx, create, newGalleryChapter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
r.hookExecutor.ExecutePostHooks(ctx, ret.ID, plugin.GalleryChapterCreatePost, input, nil)
|
||||
return r.getGalleryChapter(ctx, ret.ID)
|
||||
}
|
||||
|
||||
func (r *mutationResolver) GalleryChapterUpdate(ctx context.Context, input GalleryChapterUpdateInput) (*models.GalleryChapter, error) {
|
||||
// Populate gallery chapter from the input
|
||||
galleryChapterID, err := strconv.Atoi(input.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
galleryID, err := strconv.Atoi(input.GalleryID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var imageCount int
|
||||
if err := r.withTxn(ctx, func(ctx context.Context) error {
|
||||
imageCount, err = r.repository.Image.CountByGalleryID(ctx, galleryID)
|
||||
return err
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Sanity Check of Index
|
||||
if input.ImageIndex > imageCount || input.ImageIndex < 1 {
|
||||
return nil, errors.New("Image # must greater than zero and in range of the gallery images")
|
||||
}
|
||||
|
||||
updatedGalleryChapter := models.GalleryChapter{
|
||||
ID: galleryChapterID,
|
||||
Title: input.Title,
|
||||
ImageIndex: input.ImageIndex,
|
||||
GalleryID: sql.NullInt64{Int64: int64(galleryID), Valid: galleryID != 0},
|
||||
UpdatedAt: models.SQLiteTimestamp{Timestamp: time.Now()},
|
||||
}
|
||||
|
||||
ret, err := r.changeChapter(ctx, update, updatedGalleryChapter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
translator := changesetTranslator{
|
||||
inputMap: getUpdateInputMap(ctx),
|
||||
}
|
||||
r.hookExecutor.ExecutePostHooks(ctx, ret.ID, plugin.GalleryChapterUpdatePost, input, translator.getFields())
|
||||
return r.getGalleryChapter(ctx, ret.ID)
|
||||
}
|
||||
|
||||
func (r *mutationResolver) GalleryChapterDestroy(ctx context.Context, id string) (bool, error) {
|
||||
chapterID, err := strconv.Atoi(id)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if err := r.withTxn(ctx, func(ctx context.Context) error {
|
||||
qb := r.repository.GalleryChapter
|
||||
|
||||
chapter, err := qb.Find(ctx, chapterID)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if chapter == nil {
|
||||
return fmt.Errorf("Chapter with id %d not found", chapterID)
|
||||
}
|
||||
|
||||
return gallery.DestroyChapter(ctx, chapter, qb)
|
||||
}); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
r.hookExecutor.ExecutePostHooks(ctx, chapterID, plugin.GalleryChapterDestroyPost, id, nil)
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (r *mutationResolver) changeChapter(ctx context.Context, changeType int, changedChapter models.GalleryChapter) (*models.GalleryChapter, error) {
|
||||
var galleryChapter *models.GalleryChapter
|
||||
|
||||
// Start the transaction and save the gallery chapter
|
||||
var err = r.withTxn(ctx, func(ctx context.Context) error {
|
||||
qb := r.repository.GalleryChapter
|
||||
var err error
|
||||
|
||||
switch changeType {
|
||||
case create:
|
||||
galleryChapter, err = qb.Create(ctx, changedChapter)
|
||||
case update:
|
||||
galleryChapter, err = qb.Update(ctx, changedChapter)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return err
|
||||
})
|
||||
|
||||
return galleryChapter, err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -104,6 +104,8 @@ func (r *mutationResolver) imageUpdate(ctx context.Context, input ImageUpdateInp
|
|||
updatedImage := models.NewImagePartial()
|
||||
updatedImage.Title = translator.optionalString(input.Title, "title")
|
||||
updatedImage.Rating = translator.ratingConversionOptional(input.Rating, input.Rating100)
|
||||
updatedImage.URL = translator.optionalString(input.URL, "url")
|
||||
updatedImage.Date = translator.optionalDate(input.Date, "date")
|
||||
updatedImage.StudioID, err = translator.optionalIntFromString(input.StudioID, "studio_id")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("converting studio id: %w", err)
|
||||
|
|
@ -190,6 +192,8 @@ func (r *mutationResolver) BulkImageUpdate(ctx context.Context, input BulkImageU
|
|||
|
||||
updatedImage.Title = translator.optionalString(input.Title, "title")
|
||||
updatedImage.Rating = translator.ratingConversionOptional(input.Rating, input.Rating100)
|
||||
updatedImage.URL = translator.optionalString(input.URL, "url")
|
||||
updatedImage.Date = translator.optionalDate(input.Date, "date")
|
||||
updatedImage.StudioID, err = translator.optionalIntFromString(input.StudioID, "studio_id")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("converting studio id: %w", err)
|
||||
|
|
|
|||
|
|
@ -156,3 +156,55 @@ func (r *mutationResolver) BackupDatabase(ctx context.Context, input BackupDatab
|
|||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (r *mutationResolver) AnonymiseDatabase(ctx context.Context, input AnonymiseDatabaseInput) (*string, error) {
|
||||
// if download is true, then backup to temporary file and return a link
|
||||
download := input.Download != nil && *input.Download
|
||||
mgr := manager.GetInstance()
|
||||
database := mgr.Database
|
||||
var outPath string
|
||||
if download {
|
||||
if err := fsutil.EnsureDir(mgr.Paths.Generated.Downloads); err != nil {
|
||||
return nil, fmt.Errorf("could not create backup directory %v: %w", mgr.Paths.Generated.Downloads, err)
|
||||
}
|
||||
f, err := os.CreateTemp(mgr.Paths.Generated.Downloads, "anonymous*.sqlite")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
outPath = f.Name()
|
||||
f.Close()
|
||||
} else {
|
||||
backupDirectoryPath := mgr.Config.GetBackupDirectoryPathOrDefault()
|
||||
if backupDirectoryPath != "" {
|
||||
if err := fsutil.EnsureDir(backupDirectoryPath); err != nil {
|
||||
return nil, fmt.Errorf("could not create backup directory %v: %w", backupDirectoryPath, err)
|
||||
}
|
||||
}
|
||||
outPath = database.AnonymousDatabasePath(backupDirectoryPath)
|
||||
}
|
||||
|
||||
err := database.Anonymise(outPath)
|
||||
if err != nil {
|
||||
logger.Errorf("Error anonymising database: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if download {
|
||||
downloadHash, err := mgr.DownloadStore.RegisterFile(outPath, "", false)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error registering file for download: %w", err)
|
||||
}
|
||||
logger.Debugf("Generated anonymised file %s with hash %s", outPath, downloadHash)
|
||||
|
||||
baseURL, _ := ctx.Value(BaseURLCtxKey).(string)
|
||||
|
||||
fn := filepath.Base(database.DatabaseBackupPath(""))
|
||||
ret := baseURL + "/downloads/" + downloadHash + "/" + fn
|
||||
return &ret, nil
|
||||
} else {
|
||||
logger.Infof("Successfully anonymised database to: %s", outPath)
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
|
|
|||
40
internal/api/resolver_mutation_migrate.go
Normal file
40
internal/api/resolver_mutation_migrate.go
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
|
||||
"github.com/stashapp/stash/internal/manager"
|
||||
"github.com/stashapp/stash/internal/manager/task"
|
||||
"github.com/stashapp/stash/pkg/scene"
|
||||
"github.com/stashapp/stash/pkg/utils"
|
||||
)
|
||||
|
||||
func (r *mutationResolver) MigrateSceneScreenshots(ctx context.Context, input MigrateSceneScreenshotsInput) (string, error) {
|
||||
db := manager.GetInstance().Database
|
||||
t := &task.MigrateSceneScreenshotsJob{
|
||||
ScreenshotsPath: manager.GetInstance().Paths.Generated.Screenshots,
|
||||
Input: scene.MigrateSceneScreenshotsInput{
|
||||
DeleteFiles: utils.IsTrue(input.DeleteFiles),
|
||||
OverwriteExisting: utils.IsTrue(input.OverwriteExisting),
|
||||
},
|
||||
SceneRepo: db.Scene,
|
||||
TxnManager: db,
|
||||
}
|
||||
jobID := manager.GetInstance().JobManager.Add(ctx, "Migrating scene screenshots to blobs...", t)
|
||||
|
||||
return strconv.Itoa(jobID), nil
|
||||
}
|
||||
|
||||
func (r *mutationResolver) MigrateBlobs(ctx context.Context, input MigrateBlobsInput) (string, error) {
|
||||
db := manager.GetInstance().Database
|
||||
t := &task.MigrateBlobsJob{
|
||||
TxnManager: db,
|
||||
BlobStore: db.Blobs,
|
||||
Vacuumer: db,
|
||||
DeleteOld: utils.IsTrue(input.DeleteOld),
|
||||
}
|
||||
jobID := manager.GetInstance().JobManager.Add(ctx, "Migrating blobs...", t)
|
||||
|
||||
return strconv.Itoa(jobID), nil
|
||||
}
|
||||
|
|
@ -111,7 +111,13 @@ func (r *mutationResolver) MovieCreate(ctx context.Context, input MovieCreateInp
|
|||
|
||||
// update image table
|
||||
if len(frontimageData) > 0 {
|
||||
if err := qb.UpdateImages(ctx, movie.ID, frontimageData, backimageData); err != nil {
|
||||
if err := qb.UpdateFrontImage(ctx, movie.ID, frontimageData); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if len(backimageData) > 0 {
|
||||
if err := qb.UpdateBackImage(ctx, movie.ID, backimageData); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
|
@ -184,35 +190,15 @@ func (r *mutationResolver) MovieUpdate(ctx context.Context, input MovieUpdateInp
|
|||
}
|
||||
|
||||
// update image table
|
||||
if frontImageIncluded || backImageIncluded {
|
||||
if !frontImageIncluded {
|
||||
frontimageData, err = qb.GetFrontImage(ctx, updatedMovie.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if !backImageIncluded {
|
||||
backimageData, err = qb.GetBackImage(ctx, updatedMovie.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if frontImageIncluded {
|
||||
if err := qb.UpdateFrontImage(ctx, movie.ID, frontimageData); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if len(frontimageData) == 0 && len(backimageData) == 0 {
|
||||
// both images are being nulled. Destroy them.
|
||||
if err := qb.DestroyImages(ctx, movie.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
// HACK - if front image is null and back image is not null, then set the front image
|
||||
// to the default image since we can't have a null front image and a non-null back image
|
||||
if frontimageData == nil && backimageData != nil {
|
||||
frontimageData, _ = utils.ProcessImageInput(ctx, models.DefaultMovieImage)
|
||||
}
|
||||
|
||||
if err := qb.UpdateImages(ctx, movie.ID, frontimageData, backimageData); err != nil {
|
||||
return err
|
||||
}
|
||||
if backImageIncluded {
|
||||
if err := qb.UpdateBackImage(ctx, movie.ID, backimageData); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import (
|
|||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/stashapp/stash/pkg/hash/md5"
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stashapp/stash/pkg/performer"
|
||||
"github.com/stashapp/stash/pkg/plugin"
|
||||
|
|
@ -36,9 +35,6 @@ func stashIDPtrSliceToSlice(v []*models.StashID) []models.StashID {
|
|||
}
|
||||
|
||||
func (r *mutationResolver) PerformerCreate(ctx context.Context, input PerformerCreateInput) (*models.Performer, error) {
|
||||
// generate checksum from performer name rather than image
|
||||
checksum := md5.FromString(input.Name)
|
||||
|
||||
var imageData []byte
|
||||
var err error
|
||||
|
||||
|
|
@ -50,14 +46,23 @@ func (r *mutationResolver) PerformerCreate(ctx context.Context, input PerformerC
|
|||
return nil, err
|
||||
}
|
||||
|
||||
tagIDs, err := stringslice.StringSliceToIntSlice(input.TagIds)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("converting tag ids: %w", err)
|
||||
}
|
||||
|
||||
// Populate a new performer from the input
|
||||
currentTime := time.Now()
|
||||
newPerformer := models.Performer{
|
||||
Name: input.Name,
|
||||
Checksum: checksum,
|
||||
TagIDs: models.NewRelatedIDs(tagIDs),
|
||||
StashIDs: models.NewRelatedStashIDs(stashIDPtrSliceToSlice(input.StashIds)),
|
||||
CreatedAt: currentTime,
|
||||
UpdatedAt: currentTime,
|
||||
}
|
||||
if input.Disambiguation != nil {
|
||||
newPerformer.Disambiguation = *input.Disambiguation
|
||||
}
|
||||
if input.URL != nil {
|
||||
newPerformer.URL = *input.URL
|
||||
}
|
||||
|
|
@ -102,8 +107,10 @@ func (r *mutationResolver) PerformerCreate(ctx context.Context, input PerformerC
|
|||
if input.Piercings != nil {
|
||||
newPerformer.Piercings = *input.Piercings
|
||||
}
|
||||
if input.Aliases != nil {
|
||||
newPerformer.Aliases = *input.Aliases
|
||||
if input.AliasList != nil {
|
||||
newPerformer.Aliases = models.NewRelatedStrings(input.AliasList)
|
||||
} else if input.Aliases != nil {
|
||||
newPerformer.Aliases = models.NewRelatedStrings(stringslice.FromString(*input.Aliases, ","))
|
||||
}
|
||||
if input.Twitter != nil {
|
||||
newPerformer.Twitter = *input.Twitter
|
||||
|
|
@ -152,12 +159,6 @@ func (r *mutationResolver) PerformerCreate(ctx context.Context, input PerformerC
|
|||
return err
|
||||
}
|
||||
|
||||
if len(input.TagIds) > 0 {
|
||||
if err := r.updatePerformerTags(ctx, newPerformer.ID, input.TagIds); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// update image table
|
||||
if len(imageData) > 0 {
|
||||
if err := qb.UpdateImage(ctx, newPerformer.ID, imageData); err != nil {
|
||||
|
|
@ -165,14 +166,6 @@ func (r *mutationResolver) PerformerCreate(ctx context.Context, input PerformerC
|
|||
}
|
||||
}
|
||||
|
||||
// Save the stash_ids
|
||||
if input.StashIds != nil {
|
||||
stashIDJoins := stashIDPtrSliceToSlice(input.StashIds)
|
||||
if err := qb.UpdateStashIDs(ctx, newPerformer.ID, stashIDJoins); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
|
|
@ -201,14 +194,8 @@ func (r *mutationResolver) PerformerUpdate(ctx context.Context, input PerformerU
|
|||
}
|
||||
}
|
||||
|
||||
if input.Name != nil {
|
||||
// generate checksum from performer name rather than image
|
||||
checksum := md5.FromString(*input.Name)
|
||||
|
||||
updatedPerformer.Name = models.NewOptionalString(*input.Name)
|
||||
updatedPerformer.Checksum = models.NewOptionalString(checksum)
|
||||
}
|
||||
|
||||
updatedPerformer.Name = translator.optionalString(input.Name, "name")
|
||||
updatedPerformer.Disambiguation = translator.optionalString(input.Disambiguation, "disambiguation")
|
||||
updatedPerformer.URL = translator.optionalString(input.URL, "url")
|
||||
|
||||
if translator.hasField("gender") {
|
||||
|
|
@ -238,7 +225,6 @@ func (r *mutationResolver) PerformerUpdate(ctx context.Context, input PerformerU
|
|||
updatedPerformer.CareerLength = translator.optionalString(input.CareerLength, "career_length")
|
||||
updatedPerformer.Tattoos = translator.optionalString(input.Tattoos, "tattoos")
|
||||
updatedPerformer.Piercings = translator.optionalString(input.Piercings, "piercings")
|
||||
updatedPerformer.Aliases = translator.optionalString(input.Aliases, "aliases")
|
||||
updatedPerformer.Twitter = translator.optionalString(input.Twitter, "twitter")
|
||||
updatedPerformer.Instagram = translator.optionalString(input.Instagram, "instagram")
|
||||
updatedPerformer.Favorite = translator.optionalBool(input.Favorite, "favorite")
|
||||
|
|
@ -249,6 +235,33 @@ func (r *mutationResolver) PerformerUpdate(ctx context.Context, input PerformerU
|
|||
updatedPerformer.Weight = translator.optionalInt(input.Weight, "weight")
|
||||
updatedPerformer.IgnoreAutoTag = translator.optionalBool(input.IgnoreAutoTag, "ignore_auto_tag")
|
||||
|
||||
if translator.hasField("alias_list") {
|
||||
updatedPerformer.Aliases = &models.UpdateStrings{
|
||||
Values: input.AliasList,
|
||||
Mode: models.RelationshipUpdateModeSet,
|
||||
}
|
||||
} else if translator.hasField("aliases") {
|
||||
updatedPerformer.Aliases = &models.UpdateStrings{
|
||||
Values: stringslice.FromString(*input.Aliases, ","),
|
||||
Mode: models.RelationshipUpdateModeSet,
|
||||
}
|
||||
}
|
||||
|
||||
if translator.hasField("tag_ids") {
|
||||
updatedPerformer.TagIDs, err = translateUpdateIDs(input.TagIds, models.RelationshipUpdateModeSet)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("converting tag ids: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Save the stash_ids
|
||||
if translator.hasField("stash_ids") {
|
||||
updatedPerformer.StashIDs = &models.UpdateStashIDs{
|
||||
StashIDs: stashIDPtrSliceToSlice(input.StashIds),
|
||||
Mode: models.RelationshipUpdateModeSet,
|
||||
}
|
||||
}
|
||||
|
||||
// Start the transaction and save the p
|
||||
if err := r.withTxn(ctx, func(ctx context.Context) error {
|
||||
qb := r.repository.Performer
|
||||
|
|
@ -274,13 +287,6 @@ func (r *mutationResolver) PerformerUpdate(ctx context.Context, input PerformerU
|
|||
return err
|
||||
}
|
||||
|
||||
// Save the tags
|
||||
if translator.hasField("tag_ids") {
|
||||
if err := r.updatePerformerTags(ctx, performerID, input.TagIds); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// update image table
|
||||
if len(imageData) > 0 {
|
||||
if err := qb.UpdateImage(ctx, performerID, imageData); err != nil {
|
||||
|
|
@ -293,14 +299,6 @@ func (r *mutationResolver) PerformerUpdate(ctx context.Context, input PerformerU
|
|||
}
|
||||
}
|
||||
|
||||
// Save the stash_ids
|
||||
if translator.hasField("stash_ids") {
|
||||
stashIDJoins := stashIDPtrSliceToSlice(input.StashIds)
|
||||
if err := qb.UpdateStashIDs(ctx, performerID, stashIDJoins); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
|
|
@ -310,14 +308,6 @@ func (r *mutationResolver) PerformerUpdate(ctx context.Context, input PerformerU
|
|||
return r.getPerformer(ctx, performerID)
|
||||
}
|
||||
|
||||
func (r *mutationResolver) updatePerformerTags(ctx context.Context, performerID int, tagsIDs []string) error {
|
||||
ids, err := stringslice.StringSliceToIntSlice(tagsIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return r.repository.Performer.UpdateTags(ctx, performerID, ids)
|
||||
}
|
||||
|
||||
func (r *mutationResolver) BulkPerformerUpdate(ctx context.Context, input BulkPerformerUpdateInput) ([]*models.Performer, error) {
|
||||
performerIDs, err := stringslice.StringSliceToIntSlice(input.Ids)
|
||||
if err != nil {
|
||||
|
|
@ -331,6 +321,7 @@ func (r *mutationResolver) BulkPerformerUpdate(ctx context.Context, input BulkPe
|
|||
|
||||
updatedPerformer := models.NewPerformerPartial()
|
||||
|
||||
updatedPerformer.Disambiguation = translator.optionalString(input.Disambiguation, "disambiguation")
|
||||
updatedPerformer.URL = translator.optionalString(input.URL, "url")
|
||||
updatedPerformer.Birthdate = translator.optionalDate(input.Birthdate, "birthdate")
|
||||
updatedPerformer.Ethnicity = translator.optionalString(input.Ethnicity, "ethnicity")
|
||||
|
|
@ -351,7 +342,6 @@ func (r *mutationResolver) BulkPerformerUpdate(ctx context.Context, input BulkPe
|
|||
updatedPerformer.CareerLength = translator.optionalString(input.CareerLength, "career_length")
|
||||
updatedPerformer.Tattoos = translator.optionalString(input.Tattoos, "tattoos")
|
||||
updatedPerformer.Piercings = translator.optionalString(input.Piercings, "piercings")
|
||||
updatedPerformer.Aliases = translator.optionalString(input.Aliases, "aliases")
|
||||
updatedPerformer.Twitter = translator.optionalString(input.Twitter, "twitter")
|
||||
updatedPerformer.Instagram = translator.optionalString(input.Instagram, "instagram")
|
||||
updatedPerformer.Favorite = translator.optionalBool(input.Favorite, "favorite")
|
||||
|
|
@ -362,6 +352,18 @@ func (r *mutationResolver) BulkPerformerUpdate(ctx context.Context, input BulkPe
|
|||
updatedPerformer.Weight = translator.optionalInt(input.Weight, "weight")
|
||||
updatedPerformer.IgnoreAutoTag = translator.optionalBool(input.IgnoreAutoTag, "ignore_auto_tag")
|
||||
|
||||
if translator.hasField("alias_list") {
|
||||
updatedPerformer.Aliases = &models.UpdateStrings{
|
||||
Values: input.AliasList.Values,
|
||||
Mode: input.AliasList.Mode,
|
||||
}
|
||||
} else if translator.hasField("aliases") {
|
||||
updatedPerformer.Aliases = &models.UpdateStrings{
|
||||
Values: stringslice.FromString(*input.Aliases, ","),
|
||||
Mode: models.RelationshipUpdateModeSet,
|
||||
}
|
||||
}
|
||||
|
||||
if translator.hasField("gender") {
|
||||
if input.Gender != nil {
|
||||
updatedPerformer.Gender = models.NewOptionalString(input.Gender.String())
|
||||
|
|
@ -370,6 +372,13 @@ func (r *mutationResolver) BulkPerformerUpdate(ctx context.Context, input BulkPe
|
|||
}
|
||||
}
|
||||
|
||||
if translator.hasField("tag_ids") {
|
||||
updatedPerformer.TagIDs, err = translateUpdateIDs(input.TagIds.Ids, input.TagIds.Mode)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("converting tag ids: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
ret := []*models.Performer{}
|
||||
|
||||
// Start the transaction and save the scene marker
|
||||
|
|
@ -399,18 +408,6 @@ func (r *mutationResolver) BulkPerformerUpdate(ctx context.Context, input BulkPe
|
|||
}
|
||||
|
||||
ret = append(ret, performer)
|
||||
|
||||
// Save the tags
|
||||
if translator.hasField("tag_ids") {
|
||||
tagIDs, err := adjustTagIDs(ctx, qb, performerID, *input.TagIds)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := qb.UpdateTags(ctx, performerID, tagIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
|
|
|
|||
|
|
@ -9,14 +9,12 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/stashapp/stash/internal/manager"
|
||||
"github.com/stashapp/stash/internal/manager/config"
|
||||
"github.com/stashapp/stash/pkg/file"
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stashapp/stash/pkg/plugin"
|
||||
"github.com/stashapp/stash/pkg/scene"
|
||||
"github.com/stashapp/stash/pkg/sliceutil/intslice"
|
||||
"github.com/stashapp/stash/pkg/sliceutil/stringslice"
|
||||
"github.com/stashapp/stash/pkg/txn"
|
||||
"github.com/stashapp/stash/pkg/utils"
|
||||
)
|
||||
|
||||
|
|
@ -320,13 +318,6 @@ func (r *mutationResolver) sceneUpdateCoverImage(ctx context.Context, s *models.
|
|||
if err := qb.UpdateCover(ctx, s.ID, coverImageData); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if s.Path != "" {
|
||||
// update the file-based screenshot after commit
|
||||
txn.AddPostCommitHook(ctx, func(ctx context.Context) error {
|
||||
return scene.SetScreenshot(manager.GetInstance().Paths, s.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm()), coverImageData)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
|
|
@ -423,56 +414,6 @@ func (r *mutationResolver) BulkSceneUpdate(ctx context.Context, input BulkSceneU
|
|||
return newRet, nil
|
||||
}
|
||||
|
||||
func adjustIDs(existingIDs []int, updateIDs BulkUpdateIds) []int {
|
||||
// if we are setting the ids, just return the ids
|
||||
if updateIDs.Mode == models.RelationshipUpdateModeSet {
|
||||
existingIDs = []int{}
|
||||
for _, idStr := range updateIDs.Ids {
|
||||
id, _ := strconv.Atoi(idStr)
|
||||
existingIDs = append(existingIDs, id)
|
||||
}
|
||||
|
||||
return existingIDs
|
||||
}
|
||||
|
||||
for _, idStr := range updateIDs.Ids {
|
||||
id, _ := strconv.Atoi(idStr)
|
||||
|
||||
// look for the id in the list
|
||||
foundExisting := false
|
||||
for idx, existingID := range existingIDs {
|
||||
if existingID == id {
|
||||
if updateIDs.Mode == models.RelationshipUpdateModeRemove {
|
||||
// remove from the list
|
||||
existingIDs = append(existingIDs[:idx], existingIDs[idx+1:]...)
|
||||
}
|
||||
|
||||
foundExisting = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !foundExisting && updateIDs.Mode != models.RelationshipUpdateModeRemove {
|
||||
existingIDs = append(existingIDs, id)
|
||||
}
|
||||
}
|
||||
|
||||
return existingIDs
|
||||
}
|
||||
|
||||
type tagIDsGetter interface {
|
||||
GetTagIDs(ctx context.Context, id int) ([]int, error)
|
||||
}
|
||||
|
||||
func adjustTagIDs(ctx context.Context, qb tagIDsGetter, sceneID int, ids BulkUpdateIds) (ret []int, err error) {
|
||||
ret, err = qb.GetTagIDs(ctx, sceneID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return adjustIDs(ret, ids), nil
|
||||
}
|
||||
|
||||
func (r *mutationResolver) SceneDestroy(ctx context.Context, input models.SceneDestroyInput) (bool, error) {
|
||||
sceneID, err := strconv.Atoi(input.ID)
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import (
|
|||
|
||||
"github.com/stashapp/stash/internal/manager"
|
||||
"github.com/stashapp/stash/internal/manager/config"
|
||||
"github.com/stashapp/stash/pkg/logger"
|
||||
"github.com/stashapp/stash/pkg/scraper/stashbox"
|
||||
)
|
||||
|
||||
|
|
@ -58,9 +59,16 @@ func (r *mutationResolver) SubmitStashBoxSceneDraft(ctx context.Context, input S
|
|||
return err
|
||||
}
|
||||
|
||||
filepath := manager.GetInstance().Paths.Scene.GetScreenshotPath(scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm()))
|
||||
if scene == nil {
|
||||
return fmt.Errorf("scene with id %d not found", id)
|
||||
}
|
||||
|
||||
res, err = client.SubmitSceneDraft(ctx, scene, boxes[input.StashBoxIndex].Endpoint, filepath)
|
||||
cover, err := qb.GetCover(ctx, id)
|
||||
if err != nil {
|
||||
logger.Errorf("Error getting scene cover: %v", err)
|
||||
}
|
||||
|
||||
res, err = client.SubmitSceneDraft(ctx, scene, boxes[input.StashBoxIndex].Endpoint, cover)
|
||||
return err
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -176,15 +176,10 @@ func (r *mutationResolver) StudioUpdate(ctx context.Context, input StudioUpdateI
|
|||
}
|
||||
|
||||
// update image table
|
||||
if len(imageData) > 0 {
|
||||
if imageIncluded {
|
||||
if err := qb.UpdateImage(ctx, s.ID, imageData); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if imageIncluded {
|
||||
// must be unsetting
|
||||
if err := qb.DestroyImage(ctx, s.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Save the stash_ids
|
||||
|
|
|
|||
|
|
@ -208,15 +208,10 @@ func (r *mutationResolver) TagUpdate(ctx context.Context, input TagUpdateInput)
|
|||
}
|
||||
|
||||
// update image table
|
||||
if len(imageData) > 0 {
|
||||
if imageIncluded {
|
||||
if err := qb.UpdateImage(ctx, tagID, imageData); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if imageIncluded {
|
||||
// must be unsetting
|
||||
if err := qb.DestroyImage(ctx, tagID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if translator.hasField("aliases") {
|
||||
|
|
|
|||
|
|
@ -83,46 +83,55 @@ func makeConfigGeneralResult() *ConfigGeneralResult {
|
|||
scraperCDPPath := config.GetScraperCDPPath()
|
||||
|
||||
return &ConfigGeneralResult{
|
||||
Stashes: config.GetStashPaths(),
|
||||
DatabasePath: config.GetDatabasePath(),
|
||||
BackupDirectoryPath: config.GetBackupDirectoryPath(),
|
||||
GeneratedPath: config.GetGeneratedPath(),
|
||||
MetadataPath: config.GetMetadataPath(),
|
||||
ConfigFilePath: config.GetConfigFile(),
|
||||
ScrapersPath: config.GetScrapersPath(),
|
||||
CachePath: config.GetCachePath(),
|
||||
CalculateMd5: config.IsCalculateMD5(),
|
||||
VideoFileNamingAlgorithm: config.GetVideoFileNamingAlgorithm(),
|
||||
ParallelTasks: config.GetParallelTasks(),
|
||||
PreviewAudio: config.GetPreviewAudio(),
|
||||
PreviewSegments: config.GetPreviewSegments(),
|
||||
PreviewSegmentDuration: config.GetPreviewSegmentDuration(),
|
||||
PreviewExcludeStart: config.GetPreviewExcludeStart(),
|
||||
PreviewExcludeEnd: config.GetPreviewExcludeEnd(),
|
||||
PreviewPreset: config.GetPreviewPreset(),
|
||||
MaxTranscodeSize: &maxTranscodeSize,
|
||||
MaxStreamingTranscodeSize: &maxStreamingTranscodeSize,
|
||||
WriteImageThumbnails: config.IsWriteImageThumbnails(),
|
||||
APIKey: config.GetAPIKey(),
|
||||
Username: config.GetUsername(),
|
||||
Password: config.GetPasswordHash(),
|
||||
MaxSessionAge: config.GetMaxSessionAge(),
|
||||
LogFile: &logFile,
|
||||
LogOut: config.GetLogOut(),
|
||||
LogLevel: config.GetLogLevel(),
|
||||
LogAccess: config.GetLogAccess(),
|
||||
VideoExtensions: config.GetVideoExtensions(),
|
||||
ImageExtensions: config.GetImageExtensions(),
|
||||
GalleryExtensions: config.GetGalleryExtensions(),
|
||||
CreateGalleriesFromFolders: config.GetCreateGalleriesFromFolders(),
|
||||
Excludes: config.GetExcludes(),
|
||||
ImageExcludes: config.GetImageExcludes(),
|
||||
CustomPerformerImageLocation: &customPerformerImageLocation,
|
||||
ScraperUserAgent: &scraperUserAgent,
|
||||
ScraperCertCheck: config.GetScraperCertCheck(),
|
||||
ScraperCDPPath: &scraperCDPPath,
|
||||
StashBoxes: config.GetStashBoxes(),
|
||||
PythonPath: config.GetPythonPath(),
|
||||
Stashes: config.GetStashPaths(),
|
||||
DatabasePath: config.GetDatabasePath(),
|
||||
BackupDirectoryPath: config.GetBackupDirectoryPath(),
|
||||
GeneratedPath: config.GetGeneratedPath(),
|
||||
MetadataPath: config.GetMetadataPath(),
|
||||
ConfigFilePath: config.GetConfigFile(),
|
||||
ScrapersPath: config.GetScrapersPath(),
|
||||
CachePath: config.GetCachePath(),
|
||||
BlobsPath: config.GetBlobsPath(),
|
||||
BlobsStorage: config.GetBlobsStorage(),
|
||||
CalculateMd5: config.IsCalculateMD5(),
|
||||
VideoFileNamingAlgorithm: config.GetVideoFileNamingAlgorithm(),
|
||||
ParallelTasks: config.GetParallelTasks(),
|
||||
PreviewAudio: config.GetPreviewAudio(),
|
||||
PreviewSegments: config.GetPreviewSegments(),
|
||||
PreviewSegmentDuration: config.GetPreviewSegmentDuration(),
|
||||
PreviewExcludeStart: config.GetPreviewExcludeStart(),
|
||||
PreviewExcludeEnd: config.GetPreviewExcludeEnd(),
|
||||
PreviewPreset: config.GetPreviewPreset(),
|
||||
TranscodeHardwareAcceleration: config.GetTranscodeHardwareAcceleration(),
|
||||
MaxTranscodeSize: &maxTranscodeSize,
|
||||
MaxStreamingTranscodeSize: &maxStreamingTranscodeSize,
|
||||
WriteImageThumbnails: config.IsWriteImageThumbnails(),
|
||||
GalleryCoverRegex: config.GetGalleryCoverRegex(),
|
||||
APIKey: config.GetAPIKey(),
|
||||
Username: config.GetUsername(),
|
||||
Password: config.GetPasswordHash(),
|
||||
MaxSessionAge: config.GetMaxSessionAge(),
|
||||
LogFile: &logFile,
|
||||
LogOut: config.GetLogOut(),
|
||||
LogLevel: config.GetLogLevel(),
|
||||
LogAccess: config.GetLogAccess(),
|
||||
VideoExtensions: config.GetVideoExtensions(),
|
||||
ImageExtensions: config.GetImageExtensions(),
|
||||
GalleryExtensions: config.GetGalleryExtensions(),
|
||||
CreateGalleriesFromFolders: config.GetCreateGalleriesFromFolders(),
|
||||
Excludes: config.GetExcludes(),
|
||||
ImageExcludes: config.GetImageExcludes(),
|
||||
CustomPerformerImageLocation: &customPerformerImageLocation,
|
||||
ScraperUserAgent: &scraperUserAgent,
|
||||
ScraperCertCheck: config.GetScraperCertCheck(),
|
||||
ScraperCDPPath: &scraperCDPPath,
|
||||
StashBoxes: config.GetStashBoxes(),
|
||||
PythonPath: config.GetPythonPath(),
|
||||
TranscodeInputArgs: config.GetTranscodeInputArgs(),
|
||||
TranscodeOutputArgs: config.GetTranscodeOutputArgs(),
|
||||
LiveTranscodeInputArgs: config.GetLiveTranscodeInputArgs(),
|
||||
LiveTranscodeOutputArgs: config.GetLiveTranscodeOutputArgs(),
|
||||
DrawFunscriptHeatmapRange: config.GetDrawFunscriptHeatmapRange(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ package api
|
|||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"strconv"
|
||||
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
|
|
@ -16,7 +18,7 @@ func (r *queryResolver) FindGallery(ctx context.Context, id string) (ret *models
|
|||
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
|
||||
ret, err = r.repository.Gallery.Find(ctx, idInt)
|
||||
return err
|
||||
}); err != nil {
|
||||
}); err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
|
@ -41,3 +43,14 @@ func (r *queryResolver) FindGalleries(ctx context.Context, galleryFilter *models
|
|||
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func (r *queryResolver) AllGalleries(ctx context.Context) (ret []*models.Gallery, err error) {
|
||||
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
|
||||
ret, err = r.repository.Gallery.All(ctx)
|
||||
return err
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ package api
|
|||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"strconv"
|
||||
|
||||
"github.com/99designs/gqlgen/graphql"
|
||||
|
|
@ -23,7 +25,7 @@ func (r *queryResolver) FindImage(ctx context.Context, id *string, checksum *str
|
|||
}
|
||||
|
||||
image, err = qb.Find(ctx, idInt)
|
||||
if err != nil {
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return err
|
||||
}
|
||||
} else if checksum != nil {
|
||||
|
|
@ -84,3 +86,14 @@ func (r *queryResolver) FindImages(ctx context.Context, imageFilter *models.Imag
|
|||
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func (r *queryResolver) AllImages(ctx context.Context) (ret []*models.Image, err error) {
|
||||
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
|
||||
ret, err = r.repository.Image.All(ctx)
|
||||
return err
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ package api
|
|||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"strconv"
|
||||
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
|
|
@ -16,7 +18,7 @@ func (r *queryResolver) FindMovie(ctx context.Context, id string) (ret *models.M
|
|||
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
|
||||
ret, err = r.repository.Movie.Find(ctx, idInt)
|
||||
return err
|
||||
}); err != nil {
|
||||
}); err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
|
@ -34,7 +36,6 @@ func (r *queryResolver) FindMovies(ctx context.Context, movieFilter *models.Movi
|
|||
Count: total,
|
||||
Movies: movies,
|
||||
}
|
||||
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ package api
|
|||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"strconv"
|
||||
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
|
|
@ -16,7 +18,7 @@ func (r *queryResolver) FindPerformer(ctx context.Context, id string) (ret *mode
|
|||
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
|
||||
ret, err = r.repository.Performer.Find(ctx, idInt)
|
||||
return err
|
||||
}); err != nil {
|
||||
}); err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ package api
|
|||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"strconv"
|
||||
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
|
|
@ -16,7 +18,7 @@ func (r *queryResolver) FindSavedFilter(ctx context.Context, id string) (ret *mo
|
|||
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
|
||||
ret, err = r.repository.SavedFilter.Find(ctx, idInt)
|
||||
return err
|
||||
}); err != nil {
|
||||
}); err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, err
|
||||
}
|
||||
return ret, err
|
||||
|
|
@ -40,7 +42,7 @@ func (r *queryResolver) FindDefaultFilter(ctx context.Context, mode models.Filte
|
|||
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
|
||||
ret, err = r.repository.SavedFilter.FindDefault(ctx, mode)
|
||||
return err
|
||||
}); err != nil {
|
||||
}); err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, err
|
||||
}
|
||||
return ret, err
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ package api
|
|||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"strconv"
|
||||
|
||||
"github.com/99designs/gqlgen/graphql"
|
||||
|
|
@ -21,7 +23,7 @@ func (r *queryResolver) FindScene(ctx context.Context, id *string, checksum *str
|
|||
return err
|
||||
}
|
||||
scene, err = qb.Find(ctx, idInt)
|
||||
if err != nil {
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return err
|
||||
}
|
||||
} else if checksum != nil {
|
||||
|
|
@ -232,3 +234,14 @@ func (r *queryResolver) FindDuplicateScenes(ctx context.Context, distance *int)
|
|||
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func (r *queryResolver) AllScenes(ctx context.Context) (ret []*models.Scene, err error) {
|
||||
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
|
||||
ret, err = r.repository.Scene.All(ctx)
|
||||
return err
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,3 +24,14 @@ func (r *queryResolver) FindSceneMarkers(ctx context.Context, sceneMarkerFilter
|
|||
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func (r *queryResolver) AllSceneMarkers(ctx context.Context) (ret []*models.SceneMarker, err error) {
|
||||
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
|
||||
ret, err = r.repository.SceneMarker.All(ctx)
|
||||
return err
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ package api
|
|||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"strconv"
|
||||
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
|
|
@ -17,7 +19,7 @@ func (r *queryResolver) FindStudio(ctx context.Context, id string) (ret *models.
|
|||
var err error
|
||||
ret, err = r.repository.Studio.Find(ctx, idInt)
|
||||
return err
|
||||
}); err != nil {
|
||||
}); err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ package api
|
|||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"strconv"
|
||||
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
|
|
@ -16,7 +18,7 @@ func (r *queryResolver) FindTag(ctx context.Context, id string) (ret *models.Tag
|
|||
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
|
||||
ret, err = r.repository.Tag.Find(ctx, idInt)
|
||||
return err
|
||||
}); err != nil {
|
||||
}); err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ import (
|
|||
|
||||
"github.com/stashapp/stash/internal/api/urlbuilders"
|
||||
"github.com/stashapp/stash/internal/manager"
|
||||
"github.com/stashapp/stash/internal/manager/config"
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
)
|
||||
|
||||
|
|
@ -32,8 +31,11 @@ func (r *queryResolver) SceneStreams(ctx context.Context, id *string) ([]*manage
|
|||
return nil, errors.New("nil scene")
|
||||
}
|
||||
|
||||
config := manager.GetInstance().Config
|
||||
|
||||
baseURL, _ := ctx.Value(BaseURLCtxKey).(string)
|
||||
builder := urlbuilders.NewSceneURLBuilder(baseURL, scene.ID)
|
||||
apiKey := config.GetAPIKey()
|
||||
|
||||
return manager.GetSceneStreamPaths(scene, builder.GetStreamURL(), config.GetInstance().GetMaxStreamingTranscodeSize())
|
||||
return manager.GetSceneStreamPaths(scene, builder.GetStreamURL(apiKey), config.GetMaxStreamingTranscodeSize())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,8 +42,9 @@ func (rs movieRoutes) FrontImage(w http.ResponseWriter, r *http.Request) {
|
|||
var image []byte
|
||||
if defaultParam != "true" {
|
||||
readTxnErr := txn.WithReadTxn(r.Context(), rs.txnManager, func(ctx context.Context) error {
|
||||
image, _ = rs.movieFinder.GetFrontImage(ctx, movie.ID)
|
||||
return nil
|
||||
var err error
|
||||
image, err = rs.movieFinder.GetFrontImage(ctx, movie.ID)
|
||||
return err
|
||||
})
|
||||
if errors.Is(readTxnErr, context.Canceled) {
|
||||
return
|
||||
|
|
@ -68,8 +69,9 @@ func (rs movieRoutes) BackImage(w http.ResponseWriter, r *http.Request) {
|
|||
var image []byte
|
||||
if defaultParam != "true" {
|
||||
readTxnErr := txn.WithReadTxn(r.Context(), rs.txnManager, func(ctx context.Context) error {
|
||||
image, _ = rs.movieFinder.GetBackImage(ctx, movie.ID)
|
||||
return nil
|
||||
var err error
|
||||
image, err = rs.movieFinder.GetBackImage(ctx, movie.ID)
|
||||
return err
|
||||
})
|
||||
if errors.Is(readTxnErr, context.Canceled) {
|
||||
return
|
||||
|
|
|
|||
|
|
@ -42,8 +42,9 @@ func (rs performerRoutes) Image(w http.ResponseWriter, r *http.Request) {
|
|||
var image []byte
|
||||
if defaultParam != "true" {
|
||||
readTxnErr := txn.WithReadTxn(r.Context(), rs.txnManager, func(ctx context.Context) error {
|
||||
image, _ = rs.performerFinder.GetImage(ctx, performer.ID)
|
||||
return nil
|
||||
var err error
|
||||
image, err = rs.performerFinder.GetImage(ctx, performer.ID)
|
||||
return err
|
||||
})
|
||||
if errors.Is(readTxnErr, context.Canceled) {
|
||||
return
|
||||
|
|
|
|||
|
|
@ -56,16 +56,21 @@ func (rs sceneRoutes) Routes() chi.Router {
|
|||
|
||||
// streaming endpoints
|
||||
r.Get("/stream", rs.StreamDirect)
|
||||
r.Get("/stream.mkv", rs.StreamMKV)
|
||||
r.Get("/stream.webm", rs.StreamWebM)
|
||||
r.Get("/stream.m3u8", rs.StreamHLS)
|
||||
r.Get("/stream.ts", rs.StreamTS)
|
||||
r.Get("/stream.mp4", rs.StreamMp4)
|
||||
r.Get("/stream.webm", rs.StreamWebM)
|
||||
r.Get("/stream.mkv", rs.StreamMKV)
|
||||
r.Get("/stream.m3u8", rs.StreamHLS)
|
||||
r.Get("/stream.m3u8/{segment}.ts", rs.StreamHLSSegment)
|
||||
r.Get("/stream.mpd", rs.StreamDASH)
|
||||
r.Get("/stream.mpd/{segment}_v.webm", rs.StreamDASHVideoSegment)
|
||||
r.Get("/stream.mpd/{segment}_a.webm", rs.StreamDASHAudioSegment)
|
||||
|
||||
r.Get("/screenshot", rs.Screenshot)
|
||||
r.Get("/preview", rs.Preview)
|
||||
r.Get("/webp", rs.Webp)
|
||||
r.Get("/vtt/chapter", rs.ChapterVtt)
|
||||
r.Get("/vtt/chapter", rs.VttChapter)
|
||||
r.Get("/vtt/thumbs", rs.VttThumbs)
|
||||
r.Get("/vtt/sprite", rs.VttSprite)
|
||||
r.Get("/funscript", rs.Funscript)
|
||||
r.Get("/interactive_heatmap", rs.InteractiveHeatmap)
|
||||
r.Get("/caption", rs.CaptionLang)
|
||||
|
|
@ -74,8 +79,8 @@ func (rs sceneRoutes) Routes() chi.Router {
|
|||
r.Get("/scene_marker/{sceneMarkerId}/preview", rs.SceneMarkerPreview)
|
||||
r.Get("/scene_marker/{sceneMarkerId}/screenshot", rs.SceneMarkerScreenshot)
|
||||
})
|
||||
r.With(rs.SceneCtx).Get("/{sceneId}_thumbs.vtt", rs.VttThumbs)
|
||||
r.With(rs.SceneCtx).Get("/{sceneId}_sprite.jpg", rs.VttSprite)
|
||||
r.Get("/{sceneHash}_thumbs.vtt", rs.VttThumbs)
|
||||
r.Get("/{sceneHash}_sprite.jpg", rs.VttSprite)
|
||||
|
||||
return r
|
||||
}
|
||||
|
|
@ -83,13 +88,32 @@ func (rs sceneRoutes) Routes() chi.Router {
|
|||
// region Handlers
|
||||
|
||||
func (rs sceneRoutes) StreamDirect(w http.ResponseWriter, r *http.Request) {
|
||||
scene := r.Context().Value(sceneKey).(*models.Scene)
|
||||
|
||||
ss := manager.SceneServer{
|
||||
TxnManager: rs.txnManager,
|
||||
SceneCoverGetter: rs.sceneFinder,
|
||||
scene := r.Context().Value(sceneKey).(*models.Scene)
|
||||
// #3526 - return 404 if the scene does not have any files
|
||||
if scene.Path == "" {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
ss.StreamSceneDirect(scene, w, r)
|
||||
|
||||
sceneHash := scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm())
|
||||
|
||||
filepath := manager.GetInstance().Paths.Scene.GetStreamPath(scene.Path, sceneHash)
|
||||
streamRequestCtx := ffmpeg.NewStreamRequestContext(w, r)
|
||||
|
||||
// #2579 - hijacking and closing the connection here causes video playback to fail in Safari
|
||||
// We trust that the request context will be closed, so we don't need to call Cancel on the
|
||||
// returned context here.
|
||||
_ = manager.GetInstance().ReadLockManager.ReadLock(streamRequestCtx, filepath)
|
||||
http.ServeFile(w, r, filepath)
|
||||
}
|
||||
|
||||
func (rs sceneRoutes) StreamMp4(w http.ResponseWriter, r *http.Request) {
|
||||
rs.streamTranscode(w, r, ffmpeg.StreamTypeMP4)
|
||||
}
|
||||
|
||||
func (rs sceneRoutes) StreamWebM(w http.ResponseWriter, r *http.Request) {
|
||||
rs.streamTranscode(w, r, ffmpeg.StreamTypeWEBM)
|
||||
}
|
||||
|
||||
func (rs sceneRoutes) StreamMKV(w http.ResponseWriter, r *http.Request) {
|
||||
|
|
@ -114,118 +138,118 @@ func (rs sceneRoutes) StreamMKV(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
rs.streamTranscode(w, r, ffmpeg.StreamFormatMKVAudio)
|
||||
rs.streamTranscode(w, r, ffmpeg.StreamTypeMKV)
|
||||
}
|
||||
|
||||
func (rs sceneRoutes) StreamWebM(w http.ResponseWriter, r *http.Request) {
|
||||
rs.streamTranscode(w, r, ffmpeg.StreamFormatVP9)
|
||||
}
|
||||
|
||||
func (rs sceneRoutes) StreamMp4(w http.ResponseWriter, r *http.Request) {
|
||||
rs.streamTranscode(w, r, ffmpeg.StreamFormatH264)
|
||||
}
|
||||
|
||||
func (rs sceneRoutes) StreamHLS(w http.ResponseWriter, r *http.Request) {
|
||||
func (rs sceneRoutes) streamTranscode(w http.ResponseWriter, r *http.Request, streamType ffmpeg.StreamFormat) {
|
||||
scene := r.Context().Value(sceneKey).(*models.Scene)
|
||||
|
||||
pf := scene.Files.Primary()
|
||||
if pf == nil {
|
||||
streamManager := manager.GetInstance().StreamManager
|
||||
if streamManager == nil {
|
||||
http.Error(w, "Live transcoding disabled", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
logger.Debug("Returning HLS playlist")
|
||||
|
||||
// getting the playlist manifest only
|
||||
w.Header().Set("Content-Type", ffmpeg.MimeHLS)
|
||||
var str strings.Builder
|
||||
|
||||
ffmpeg.WriteHLSPlaylist(pf.Duration, r.URL.String(), &str)
|
||||
|
||||
requestByteRange := createByteRange(r.Header.Get("Range"))
|
||||
if requestByteRange.RawString != "" {
|
||||
logger.Debugf("Requested range: %s", requestByteRange.RawString)
|
||||
}
|
||||
|
||||
ret := requestByteRange.apply([]byte(str.String()))
|
||||
rangeStr := requestByteRange.toHeaderValue(int64(str.Len()))
|
||||
w.Header().Set("Content-Range", rangeStr)
|
||||
|
||||
if n, err := w.Write(ret); err != nil {
|
||||
logger.Warnf("[stream] error writing stream (wrote %v bytes): %v", n, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (rs sceneRoutes) StreamTS(w http.ResponseWriter, r *http.Request) {
|
||||
rs.streamTranscode(w, r, ffmpeg.StreamFormatHLS)
|
||||
}
|
||||
|
||||
func (rs sceneRoutes) streamTranscode(w http.ResponseWriter, r *http.Request, streamFormat ffmpeg.StreamFormat) {
|
||||
scene := r.Context().Value(sceneKey).(*models.Scene)
|
||||
|
||||
f := scene.Files.Primary()
|
||||
if f == nil {
|
||||
return
|
||||
}
|
||||
logger.Debugf("Streaming as %s", streamFormat.MimeType)
|
||||
|
||||
// start stream based on query param, if provided
|
||||
if err := r.ParseForm(); err != nil {
|
||||
logger.Warnf("[stream] error parsing query form: %v", err)
|
||||
logger.Warnf("[transcode] error parsing query form: %v", err)
|
||||
}
|
||||
|
||||
startTime := r.Form.Get("start")
|
||||
ss, _ := strconv.ParseFloat(startTime, 64)
|
||||
requestedSize := r.Form.Get("resolution")
|
||||
resolution := r.Form.Get("resolution")
|
||||
|
||||
audioCodec := ffmpeg.MissingUnsupported
|
||||
if f.AudioCodec != "" {
|
||||
audioCodec = ffmpeg.ProbeAudioCodec(f.AudioCodec)
|
||||
options := ffmpeg.TranscodeOptions{
|
||||
StreamType: streamType,
|
||||
VideoFile: f,
|
||||
Resolution: resolution,
|
||||
StartTime: ss,
|
||||
}
|
||||
|
||||
width := f.Width
|
||||
height := f.Height
|
||||
logger.Debugf("[transcode] streaming scene %d as %s", scene.ID, streamType.MimeType)
|
||||
streamManager.ServeTranscode(w, r, options)
|
||||
}
|
||||
|
||||
options := ffmpeg.TranscodeStreamOptions{
|
||||
Input: f.Path,
|
||||
Codec: streamFormat,
|
||||
VideoOnly: audioCodec == ffmpeg.MissingUnsupported,
|
||||
func (rs sceneRoutes) StreamHLS(w http.ResponseWriter, r *http.Request) {
|
||||
rs.streamManifest(w, r, ffmpeg.StreamTypeHLS, "HLS")
|
||||
}
|
||||
|
||||
VideoWidth: width,
|
||||
VideoHeight: height,
|
||||
func (rs sceneRoutes) StreamDASH(w http.ResponseWriter, r *http.Request) {
|
||||
rs.streamManifest(w, r, ffmpeg.StreamTypeDASHVideo, "DASH")
|
||||
}
|
||||
|
||||
StartTime: ss,
|
||||
MaxTranscodeSize: config.GetInstance().GetMaxStreamingTranscodeSize().GetMaxResolution(),
|
||||
}
|
||||
func (rs sceneRoutes) streamManifest(w http.ResponseWriter, r *http.Request, streamType *ffmpeg.StreamType, logName string) {
|
||||
scene := r.Context().Value(sceneKey).(*models.Scene)
|
||||
|
||||
if requestedSize != "" {
|
||||
options.MaxTranscodeSize = models.StreamingResolutionEnum(requestedSize).GetMaxResolution()
|
||||
}
|
||||
|
||||
encoder := manager.GetInstance().FFMPEG
|
||||
|
||||
lm := manager.GetInstance().ReadLockManager
|
||||
streamRequestCtx := manager.NewStreamRequestContext(w, r)
|
||||
lockCtx := lm.ReadLock(streamRequestCtx, f.Path)
|
||||
|
||||
// hijacking and closing the connection here causes video playback to hang in Chrome
|
||||
// due to ERR_INCOMPLETE_CHUNKED_ENCODING
|
||||
// We trust that the request context will be closed, so we don't need to call Cancel on the returned context here.
|
||||
|
||||
stream, err := encoder.GetTranscodeStream(lockCtx, options)
|
||||
|
||||
if err != nil {
|
||||
logger.Errorf("[stream] error transcoding video file: %v", err)
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
if _, err := w.Write([]byte(err.Error())); err != nil {
|
||||
logger.Warnf("[stream] error writing response: %v", err)
|
||||
}
|
||||
streamManager := manager.GetInstance().StreamManager
|
||||
if streamManager == nil {
|
||||
http.Error(w, "Live transcoding disabled", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
lockCtx.AttachCommand(stream.Cmd)
|
||||
f := scene.Files.Primary()
|
||||
if f == nil {
|
||||
return
|
||||
}
|
||||
|
||||
stream.Serve(w, r)
|
||||
w.(http.Flusher).Flush()
|
||||
if err := r.ParseForm(); err != nil {
|
||||
logger.Warnf("[transcode] error parsing query form: %v", err)
|
||||
}
|
||||
|
||||
resolution := r.Form.Get("resolution")
|
||||
|
||||
logger.Debugf("[transcode] returning %s manifest for scene %d", logName, scene.ID)
|
||||
streamManager.ServeManifest(w, r, streamType, f, resolution)
|
||||
}
|
||||
|
||||
func (rs sceneRoutes) StreamHLSSegment(w http.ResponseWriter, r *http.Request) {
|
||||
rs.streamSegment(w, r, ffmpeg.StreamTypeHLS)
|
||||
}
|
||||
|
||||
func (rs sceneRoutes) StreamDASHVideoSegment(w http.ResponseWriter, r *http.Request) {
|
||||
rs.streamSegment(w, r, ffmpeg.StreamTypeDASHVideo)
|
||||
}
|
||||
|
||||
func (rs sceneRoutes) StreamDASHAudioSegment(w http.ResponseWriter, r *http.Request) {
|
||||
rs.streamSegment(w, r, ffmpeg.StreamTypeDASHAudio)
|
||||
}
|
||||
|
||||
func (rs sceneRoutes) streamSegment(w http.ResponseWriter, r *http.Request, streamType *ffmpeg.StreamType) {
|
||||
scene := r.Context().Value(sceneKey).(*models.Scene)
|
||||
|
||||
streamManager := manager.GetInstance().StreamManager
|
||||
if streamManager == nil {
|
||||
http.Error(w, "Live transcoding disabled", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
f := scene.Files.Primary()
|
||||
if f == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err := r.ParseForm(); err != nil {
|
||||
logger.Warnf("[transcode] error parsing query form: %v", err)
|
||||
}
|
||||
|
||||
sceneHash := scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm())
|
||||
|
||||
segment := chi.URLParam(r, "segment")
|
||||
resolution := r.Form.Get("resolution")
|
||||
|
||||
options := ffmpeg.StreamOptions{
|
||||
StreamType: streamType,
|
||||
VideoFile: f,
|
||||
Resolution: resolution,
|
||||
Hash: sceneHash,
|
||||
Segment: segment,
|
||||
}
|
||||
|
||||
streamManager.ServeSegment(w, r, options)
|
||||
}
|
||||
|
||||
func (rs sceneRoutes) Screenshot(w http.ResponseWriter, r *http.Request) {
|
||||
|
|
@ -240,7 +264,8 @@ func (rs sceneRoutes) Screenshot(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
func (rs sceneRoutes) Preview(w http.ResponseWriter, r *http.Request) {
|
||||
scene := r.Context().Value(sceneKey).(*models.Scene)
|
||||
filepath := manager.GetInstance().Paths.Scene.GetVideoPreviewPath(scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm()))
|
||||
sceneHash := scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm())
|
||||
filepath := manager.GetInstance().Paths.Scene.GetVideoPreviewPath(sceneHash)
|
||||
serveFileNoCache(w, r, filepath)
|
||||
}
|
||||
|
||||
|
|
@ -254,7 +279,8 @@ func serveFileNoCache(w http.ResponseWriter, r *http.Request, filepath string) {
|
|||
|
||||
func (rs sceneRoutes) Webp(w http.ResponseWriter, r *http.Request) {
|
||||
scene := r.Context().Value(sceneKey).(*models.Scene)
|
||||
filepath := manager.GetInstance().Paths.Scene.GetWebpPreviewPath(scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm()))
|
||||
sceneHash := scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm())
|
||||
filepath := manager.GetInstance().Paths.Scene.GetWebpPreviewPath(sceneHash)
|
||||
http.ServeFile(w, r, filepath)
|
||||
}
|
||||
|
||||
|
|
@ -290,7 +316,7 @@ func (rs sceneRoutes) getChapterVttTitle(ctx context.Context, marker *models.Sce
|
|||
return &title, nil
|
||||
}
|
||||
|
||||
func (rs sceneRoutes) ChapterVtt(w http.ResponseWriter, r *http.Request) {
|
||||
func (rs sceneRoutes) VttChapter(w http.ResponseWriter, r *http.Request) {
|
||||
scene := r.Context().Value(sceneKey).(*models.Scene)
|
||||
var sceneMarkers []*models.SceneMarker
|
||||
readTxnErr := txn.WithReadTxn(r.Context(), rs.txnManager, func(ctx context.Context) error {
|
||||
|
|
@ -332,6 +358,32 @@ func (rs sceneRoutes) ChapterVtt(w http.ResponseWriter, r *http.Request) {
|
|||
_, _ = w.Write([]byte(vtt))
|
||||
}
|
||||
|
||||
func (rs sceneRoutes) VttThumbs(w http.ResponseWriter, r *http.Request) {
|
||||
scene, ok := r.Context().Value(sceneKey).(*models.Scene)
|
||||
var sceneHash string
|
||||
if ok && scene != nil {
|
||||
sceneHash = scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm())
|
||||
} else {
|
||||
sceneHash = chi.URLParam(r, "sceneHash")
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/vtt")
|
||||
filepath := manager.GetInstance().Paths.Scene.GetSpriteVttFilePath(sceneHash)
|
||||
http.ServeFile(w, r, filepath)
|
||||
}
|
||||
|
||||
func (rs sceneRoutes) VttSprite(w http.ResponseWriter, r *http.Request) {
|
||||
scene, ok := r.Context().Value(sceneKey).(*models.Scene)
|
||||
var sceneHash string
|
||||
if ok && scene != nil {
|
||||
sceneHash = scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm())
|
||||
} else {
|
||||
sceneHash = chi.URLParam(r, "sceneHash")
|
||||
}
|
||||
w.Header().Set("Content-Type", "image/jpeg")
|
||||
filepath := manager.GetInstance().Paths.Scene.GetSpriteImageFilePath(sceneHash)
|
||||
http.ServeFile(w, r, filepath)
|
||||
}
|
||||
|
||||
func (rs sceneRoutes) Funscript(w http.ResponseWriter, r *http.Request) {
|
||||
s := r.Context().Value(sceneKey).(*models.Scene)
|
||||
funscript := video.GetFunscriptPath(s.Path)
|
||||
|
|
@ -340,8 +392,9 @@ func (rs sceneRoutes) Funscript(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
func (rs sceneRoutes) InteractiveHeatmap(w http.ResponseWriter, r *http.Request) {
|
||||
scene := r.Context().Value(sceneKey).(*models.Scene)
|
||||
sceneHash := scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm())
|
||||
w.Header().Set("Content-Type", "image/png")
|
||||
filepath := manager.GetInstance().Paths.Scene.GetInteractiveHeatmapPath(scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm()))
|
||||
filepath := manager.GetInstance().Paths.Scene.GetInteractiveHeatmapPath(sceneHash)
|
||||
http.ServeFile(w, r, filepath)
|
||||
}
|
||||
|
||||
|
|
@ -405,22 +458,9 @@ func (rs sceneRoutes) CaptionLang(w http.ResponseWriter, r *http.Request) {
|
|||
rs.Caption(w, r, l, ext)
|
||||
}
|
||||
|
||||
func (rs sceneRoutes) VttThumbs(w http.ResponseWriter, r *http.Request) {
|
||||
scene := r.Context().Value(sceneKey).(*models.Scene)
|
||||
w.Header().Set("Content-Type", "text/vtt")
|
||||
filepath := manager.GetInstance().Paths.Scene.GetSpriteVttFilePath(scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm()))
|
||||
http.ServeFile(w, r, filepath)
|
||||
}
|
||||
|
||||
func (rs sceneRoutes) VttSprite(w http.ResponseWriter, r *http.Request) {
|
||||
scene := r.Context().Value(sceneKey).(*models.Scene)
|
||||
w.Header().Set("Content-Type", "image/jpeg")
|
||||
filepath := manager.GetInstance().Paths.Scene.GetSpriteImageFilePath(scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm()))
|
||||
http.ServeFile(w, r, filepath)
|
||||
}
|
||||
|
||||
func (rs sceneRoutes) SceneMarkerStream(w http.ResponseWriter, r *http.Request) {
|
||||
scene := r.Context().Value(sceneKey).(*models.Scene)
|
||||
sceneHash := scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm())
|
||||
sceneMarkerID, _ := strconv.Atoi(chi.URLParam(r, "sceneMarkerId"))
|
||||
var sceneMarker *models.SceneMarker
|
||||
readTxnErr := txn.WithReadTxn(r.Context(), rs.txnManager, func(ctx context.Context) error {
|
||||
|
|
@ -442,12 +482,13 @@ func (rs sceneRoutes) SceneMarkerStream(w http.ResponseWriter, r *http.Request)
|
|||
return
|
||||
}
|
||||
|
||||
filepath := manager.GetInstance().Paths.SceneMarkers.GetVideoPreviewPath(scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm()), int(sceneMarker.Seconds))
|
||||
filepath := manager.GetInstance().Paths.SceneMarkers.GetVideoPreviewPath(sceneHash, int(sceneMarker.Seconds))
|
||||
http.ServeFile(w, r, filepath)
|
||||
}
|
||||
|
||||
func (rs sceneRoutes) SceneMarkerPreview(w http.ResponseWriter, r *http.Request) {
|
||||
scene := r.Context().Value(sceneKey).(*models.Scene)
|
||||
sceneHash := scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm())
|
||||
sceneMarkerID, _ := strconv.Atoi(chi.URLParam(r, "sceneMarkerId"))
|
||||
var sceneMarker *models.SceneMarker
|
||||
readTxnErr := txn.WithReadTxn(r.Context(), rs.txnManager, func(ctx context.Context) error {
|
||||
|
|
@ -469,7 +510,7 @@ func (rs sceneRoutes) SceneMarkerPreview(w http.ResponseWriter, r *http.Request)
|
|||
return
|
||||
}
|
||||
|
||||
filepath := manager.GetInstance().Paths.SceneMarkers.GetWebpPreviewPath(scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm()), int(sceneMarker.Seconds))
|
||||
filepath := manager.GetInstance().Paths.SceneMarkers.GetWebpPreviewPath(sceneHash, int(sceneMarker.Seconds))
|
||||
|
||||
// If the image doesn't exist, send the placeholder
|
||||
exists, _ := fsutil.FileExists(filepath)
|
||||
|
|
@ -485,6 +526,7 @@ func (rs sceneRoutes) SceneMarkerPreview(w http.ResponseWriter, r *http.Request)
|
|||
|
||||
func (rs sceneRoutes) SceneMarkerScreenshot(w http.ResponseWriter, r *http.Request) {
|
||||
scene := r.Context().Value(sceneKey).(*models.Scene)
|
||||
sceneHash := scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm())
|
||||
sceneMarkerID, _ := strconv.Atoi(chi.URLParam(r, "sceneMarkerId"))
|
||||
var sceneMarker *models.SceneMarker
|
||||
readTxnErr := txn.WithReadTxn(r.Context(), rs.txnManager, func(ctx context.Context) error {
|
||||
|
|
@ -506,7 +548,7 @@ func (rs sceneRoutes) SceneMarkerScreenshot(w http.ResponseWriter, r *http.Reque
|
|||
return
|
||||
}
|
||||
|
||||
filepath := manager.GetInstance().Paths.SceneMarkers.GetScreenshotPath(scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm()), int(sceneMarker.Seconds))
|
||||
filepath := manager.GetInstance().Paths.SceneMarkers.GetScreenshotPath(sceneHash, int(sceneMarker.Seconds))
|
||||
|
||||
// If the image doesn't exist, send the placeholder
|
||||
exists, _ := fsutil.FileExists(filepath)
|
||||
|
|
@ -524,28 +566,16 @@ func (rs sceneRoutes) SceneMarkerScreenshot(w http.ResponseWriter, r *http.Reque
|
|||
|
||||
func (rs sceneRoutes) SceneCtx(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
sceneIdentifierQueryParam := chi.URLParam(r, "sceneId")
|
||||
sceneID, _ := strconv.Atoi(sceneIdentifierQueryParam)
|
||||
sceneID, err := strconv.Atoi(chi.URLParam(r, "sceneId"))
|
||||
if err != nil {
|
||||
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var scene *models.Scene
|
||||
_ = txn.WithReadTxn(r.Context(), rs.txnManager, func(ctx context.Context) error {
|
||||
qb := rs.sceneFinder
|
||||
if sceneID == 0 {
|
||||
var scenes []*models.Scene
|
||||
// determine checksum/os by the length of the query param
|
||||
if len(sceneIdentifierQueryParam) == 32 {
|
||||
scenes, _ = qb.FindByChecksum(ctx, sceneIdentifierQueryParam)
|
||||
|
||||
} else {
|
||||
scenes, _ = qb.FindByOSHash(ctx, sceneIdentifierQueryParam)
|
||||
}
|
||||
|
||||
if len(scenes) > 0 {
|
||||
scene = scenes[0]
|
||||
}
|
||||
} else {
|
||||
scene, _ = qb.Find(ctx, sceneID)
|
||||
}
|
||||
scene, _ = qb.Find(ctx, sceneID)
|
||||
|
||||
if scene != nil {
|
||||
if err := scene.LoadPrimaryFile(ctx, rs.fileFinder); err != nil {
|
||||
|
|
|
|||
|
|
@ -42,8 +42,9 @@ func (rs studioRoutes) Image(w http.ResponseWriter, r *http.Request) {
|
|||
var image []byte
|
||||
if defaultParam != "true" {
|
||||
readTxnErr := txn.WithReadTxn(r.Context(), rs.txnManager, func(ctx context.Context) error {
|
||||
image, _ = rs.studioFinder.GetImage(ctx, studio.ID)
|
||||
return nil
|
||||
var err error
|
||||
image, err = rs.studioFinder.GetImage(ctx, studio.ID)
|
||||
return err
|
||||
})
|
||||
if errors.Is(readTxnErr, context.Canceled) {
|
||||
return
|
||||
|
|
|
|||
|
|
@ -42,8 +42,9 @@ func (rs tagRoutes) Image(w http.ResponseWriter, r *http.Request) {
|
|||
var image []byte
|
||||
if defaultParam != "true" {
|
||||
readTxnErr := txn.WithReadTxn(r.Context(), rs.txnManager, func(ctx context.Context) error {
|
||||
image, _ = rs.tagFinder.GetImage(ctx, tag.ID)
|
||||
return nil
|
||||
var err error
|
||||
image, err = rs.tagFinder.GetImage(ctx, tag.ID)
|
||||
return err
|
||||
})
|
||||
if errors.Is(readTxnErr, context.Canceled) {
|
||||
return
|
||||
|
|
|
|||
|
|
@ -1,14 +1,17 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"regexp"
|
||||
"runtime/debug"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
|
@ -31,6 +34,7 @@ import (
|
|||
"github.com/stashapp/stash/internal/manager/config"
|
||||
"github.com/stashapp/stash/pkg/fsutil"
|
||||
"github.com/stashapp/stash/pkg/logger"
|
||||
"github.com/stashapp/stash/pkg/plugin"
|
||||
"github.com/stashapp/stash/ui"
|
||||
)
|
||||
|
||||
|
|
@ -131,7 +135,7 @@ func Start() error {
|
|||
|
||||
// session handlers
|
||||
r.Post(loginEndPoint, handleLogin(loginUIBox))
|
||||
r.Get("/logout", handleLogout(loginUIBox))
|
||||
r.Get(logoutEndPoint, handleLogout(loginUIBox))
|
||||
|
||||
r.Get(loginEndPoint, getLoginHandler(loginUIBox))
|
||||
|
||||
|
|
@ -166,36 +170,8 @@ func Start() error {
|
|||
}.Routes())
|
||||
r.Mount("/downloads", downloadsRoutes{}.Routes())
|
||||
|
||||
r.HandleFunc("/css", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/css")
|
||||
if !c.GetCSSEnabled() {
|
||||
return
|
||||
}
|
||||
|
||||
// search for custom.css in current directory, then $HOME/.stash
|
||||
fn := c.GetCSSPath()
|
||||
exists, _ := fsutil.FileExists(fn)
|
||||
if !exists {
|
||||
return
|
||||
}
|
||||
|
||||
http.ServeFile(w, r, fn)
|
||||
})
|
||||
r.HandleFunc("/javascript", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/javascript")
|
||||
if !c.GetJavascriptEnabled() {
|
||||
return
|
||||
}
|
||||
|
||||
// search for custom.js in current directory, then $HOME/.stash
|
||||
fn := c.GetJavascriptPath()
|
||||
exists, _ := fsutil.FileExists(fn)
|
||||
if !exists {
|
||||
return
|
||||
}
|
||||
|
||||
http.ServeFile(w, r, fn)
|
||||
})
|
||||
r.HandleFunc("/css", cssHandler(c, pluginCache))
|
||||
r.HandleFunc("/javascript", javascriptHandler(c, pluginCache))
|
||||
r.HandleFunc("/customlocales", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if c.GetCustomLocalesEnabled() {
|
||||
|
|
@ -329,23 +305,139 @@ func Start() error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func copyFile(w io.Writer, path string) (time.Time, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
info, err := f.Stat()
|
||||
if err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
|
||||
_, err = io.Copy(w, f)
|
||||
|
||||
return info.ModTime(), err
|
||||
}
|
||||
|
||||
func serveFiles(w http.ResponseWriter, r *http.Request, name string, paths []string) {
|
||||
buffer := bytes.Buffer{}
|
||||
|
||||
latestModTime := time.Time{}
|
||||
|
||||
for _, path := range paths {
|
||||
modTime, err := copyFile(&buffer, path)
|
||||
if err != nil {
|
||||
logger.Errorf("error serving file %s: %v", path, err)
|
||||
} else {
|
||||
if modTime.After(latestModTime) {
|
||||
latestModTime = modTime
|
||||
}
|
||||
buffer.Write([]byte("\n"))
|
||||
}
|
||||
}
|
||||
|
||||
// Always revalidate with server
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
|
||||
bufferReader := bytes.NewReader(buffer.Bytes())
|
||||
http.ServeContent(w, r, name, latestModTime, bufferReader)
|
||||
}
|
||||
|
||||
func cssHandler(c *config.Instance, pluginCache *plugin.Cache) func(w http.ResponseWriter, r *http.Request) {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
// concatenate with plugin css files
|
||||
w.Header().Set("Content-Type", "text/css")
|
||||
|
||||
// add plugin css files first
|
||||
var paths []string
|
||||
|
||||
for _, p := range pluginCache.ListPlugins() {
|
||||
paths = append(paths, p.UI.CSS...)
|
||||
}
|
||||
|
||||
if c.GetCSSEnabled() {
|
||||
// search for custom.css in current directory, then $HOME/.stash
|
||||
fn := c.GetCSSPath()
|
||||
exists, _ := fsutil.FileExists(fn)
|
||||
if exists {
|
||||
paths = append(paths, fn)
|
||||
}
|
||||
}
|
||||
|
||||
serveFiles(w, r, "custom.css", paths)
|
||||
}
|
||||
}
|
||||
|
||||
func javascriptHandler(c *config.Instance, pluginCache *plugin.Cache) func(w http.ResponseWriter, r *http.Request) {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/javascript")
|
||||
|
||||
// add plugin javascript files first
|
||||
var paths []string
|
||||
|
||||
for _, p := range pluginCache.ListPlugins() {
|
||||
paths = append(paths, p.UI.Javascript...)
|
||||
}
|
||||
|
||||
if c.GetJavascriptEnabled() {
|
||||
// search for custom.js in current directory, then $HOME/.stash
|
||||
fn := c.GetJavascriptPath()
|
||||
exists, _ := fsutil.FileExists(fn)
|
||||
if exists {
|
||||
paths = append(paths, fn)
|
||||
}
|
||||
}
|
||||
|
||||
serveFiles(w, r, "custom.js", paths)
|
||||
}
|
||||
}
|
||||
|
||||
func printVersion() {
|
||||
versionString := githash
|
||||
var versionString string
|
||||
switch {
|
||||
case version != "":
|
||||
if githash != "" && !IsDevelop() {
|
||||
versionString = version + " (" + githash + ")"
|
||||
} else {
|
||||
versionString = version
|
||||
}
|
||||
case githash != "":
|
||||
versionString = githash
|
||||
default:
|
||||
versionString = "unknown"
|
||||
}
|
||||
if config.IsOfficialBuild() {
|
||||
versionString += " - Official Build"
|
||||
} else {
|
||||
versionString += " - Unofficial Build"
|
||||
}
|
||||
if version != "" {
|
||||
versionString = version + " (" + versionString + ")"
|
||||
if buildstamp != "" {
|
||||
versionString += " - " + buildstamp
|
||||
}
|
||||
fmt.Printf("stash version: %s - %s\n", versionString, buildstamp)
|
||||
logger.Infof("stash version: %s\n", versionString)
|
||||
}
|
||||
|
||||
func GetVersion() (string, string, string) {
|
||||
return version, githash, buildstamp
|
||||
}
|
||||
|
||||
func IsDevelop() bool {
|
||||
if githash == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
// if the version is suffixed with -x-xxxx, then we are running a development build
|
||||
develop := false
|
||||
re := regexp.MustCompile(`-\d+-g\w+$`)
|
||||
if re.MatchString(version) {
|
||||
develop = true
|
||||
}
|
||||
return develop
|
||||
}
|
||||
|
||||
func makeTLSConfig(c *config.Instance) (*tls.Config, error) {
|
||||
c.InitTLS()
|
||||
certFile, keyFile := c.GetTLSFiles()
|
||||
|
|
@ -366,12 +458,12 @@ func makeTLSConfig(c *config.Instance) (*tls.Config, error) {
|
|||
|
||||
cert, err := os.ReadFile(certFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error reading SSL certificate file %s: %s", certFile, err.Error())
|
||||
return nil, fmt.Errorf("error reading SSL certificate file %s: %v", certFile, err)
|
||||
}
|
||||
|
||||
key, err := os.ReadFile(keyFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error reading SSL key file %s: %s", keyFile, err.Error())
|
||||
return nil, fmt.Errorf("error reading SSL key file %s: %v", keyFile, err)
|
||||
}
|
||||
|
||||
certs := make([]tls.Certificate, 1)
|
||||
|
|
@ -411,7 +503,7 @@ func SecurityHeadersMiddleware(next http.Handler) http.Handler {
|
|||
}
|
||||
connectableOrigins += "; "
|
||||
|
||||
cspDirectives := "default-src data: 'self' 'unsafe-inline';" + connectableOrigins + "img-src data: *; script-src 'self' https://cdn.jsdelivr.net 'unsafe-inline' 'unsafe-eval'; style-src 'self' https://cdn.jsdelivr.net 'unsafe-inline'; style-src-elem 'self' https://cdn.jsdelivr.net 'unsafe-inline'; media-src 'self' blob:; child-src 'none'; object-src 'none'; form-action 'self'"
|
||||
cspDirectives := "default-src data: 'self' 'unsafe-inline';" + connectableOrigins + "img-src data: *; script-src 'self' https://cdn.jsdelivr.net 'unsafe-inline' 'unsafe-eval'; style-src 'self' https://cdn.jsdelivr.net 'unsafe-inline'; style-src-elem 'self' https://cdn.jsdelivr.net 'unsafe-inline'; media-src 'self' blob:; child-src 'none'; worker-src blob:; object-src 'none'; form-action 'self'"
|
||||
|
||||
w.Header().Set("Referrer-Policy", "same-origin")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ import (
|
|||
type SceneURLBuilder struct {
|
||||
BaseURL string
|
||||
SceneID string
|
||||
APIKey string
|
||||
}
|
||||
|
||||
func NewSceneURLBuilder(baseURL string, sceneID int) SceneURLBuilder {
|
||||
|
|
@ -20,16 +19,16 @@ func NewSceneURLBuilder(baseURL string, sceneID int) SceneURLBuilder {
|
|||
}
|
||||
}
|
||||
|
||||
func (b SceneURLBuilder) GetStreamURL() *url.URL {
|
||||
func (b SceneURLBuilder) GetStreamURL(apiKey string) *url.URL {
|
||||
u, err := url.Parse(fmt.Sprintf("%s/scene/%s/stream", b.BaseURL, b.SceneID))
|
||||
if err != nil {
|
||||
// shouldn't happen
|
||||
panic(err)
|
||||
}
|
||||
|
||||
if b.APIKey != "" {
|
||||
if apiKey != "" {
|
||||
v := u.Query()
|
||||
v.Set("apikey", b.APIKey)
|
||||
v.Set("apikey", apiKey)
|
||||
u.RawQuery = v.Encode()
|
||||
}
|
||||
return u
|
||||
|
|
@ -43,12 +42,12 @@ func (b SceneURLBuilder) GetStreamPreviewImageURL() string {
|
|||
return b.BaseURL + "/scene/" + b.SceneID + "/webp"
|
||||
}
|
||||
|
||||
func (b SceneURLBuilder) GetSpriteVTTURL() string {
|
||||
return b.BaseURL + "/scene/" + b.SceneID + "_thumbs.vtt"
|
||||
func (b SceneURLBuilder) GetSpriteVTTURL(checksum string) string {
|
||||
return b.BaseURL + "/scene/" + checksum + "_thumbs.vtt"
|
||||
}
|
||||
|
||||
func (b SceneURLBuilder) GetSpriteURL() string {
|
||||
return b.BaseURL + "/scene/" + b.SceneID + "_sprite.jpg"
|
||||
func (b SceneURLBuilder) GetSpriteURL(checksum string) string {
|
||||
return b.BaseURL + "/scene/" + checksum + "_sprite.jpg"
|
||||
}
|
||||
|
||||
func (b SceneURLBuilder) GetScreenshotURL(updateTime time.Time) string {
|
||||
|
|
|
|||
|
|
@ -21,15 +21,17 @@ func TestGalleryPerformers(t *testing.T) {
|
|||
const performerName = "performer name"
|
||||
const performerID = 2
|
||||
performer := models.Performer{
|
||||
ID: performerID,
|
||||
Name: performerName,
|
||||
ID: performerID,
|
||||
Name: performerName,
|
||||
Aliases: models.NewRelatedStrings([]string{}),
|
||||
}
|
||||
|
||||
const reversedPerformerName = "name performer"
|
||||
const reversedPerformerID = 3
|
||||
reversedPerformer := models.Performer{
|
||||
ID: reversedPerformerID,
|
||||
Name: reversedPerformerName,
|
||||
ID: reversedPerformerID,
|
||||
Name: reversedPerformerName,
|
||||
Aliases: models.NewRelatedStrings([]string{}),
|
||||
}
|
||||
|
||||
testTables := generateTestTable(performerName, galleryExt)
|
||||
|
|
|
|||
|
|
@ -18,15 +18,17 @@ func TestImagePerformers(t *testing.T) {
|
|||
const performerName = "performer name"
|
||||
const performerID = 2
|
||||
performer := models.Performer{
|
||||
ID: performerID,
|
||||
Name: performerName,
|
||||
ID: performerID,
|
||||
Name: performerName,
|
||||
Aliases: models.NewRelatedStrings([]string{}),
|
||||
}
|
||||
|
||||
const reversedPerformerName = "name performer"
|
||||
const reversedPerformerID = 3
|
||||
reversedPerformer := models.Performer{
|
||||
ID: reversedPerformerID,
|
||||
Name: reversedPerformerName,
|
||||
ID: reversedPerformerID,
|
||||
Name: reversedPerformerName,
|
||||
Aliases: models.NewRelatedStrings([]string{}),
|
||||
}
|
||||
|
||||
testTables := generateTestTable(performerName, imageExt)
|
||||
|
|
|
|||
|
|
@ -86,8 +86,7 @@ func TestMain(m *testing.M) {
|
|||
func createPerformer(ctx context.Context, pqb models.PerformerWriter) error {
|
||||
// create the performer
|
||||
performer := models.Performer{
|
||||
Checksum: testName,
|
||||
Name: testName,
|
||||
Name: testName,
|
||||
}
|
||||
|
||||
err := pqb.Create(ctx, &performer)
|
||||
|
|
@ -548,6 +547,9 @@ func TestParsePerformerScenes(t *testing.T) {
|
|||
|
||||
for _, p := range performers {
|
||||
if err := withDB(func(ctx context.Context) error {
|
||||
if err := p.LoadAliases(ctx, r.Performer); err != nil {
|
||||
return err
|
||||
}
|
||||
return tagger.PerformerScenes(ctx, p, nil, r.Scene)
|
||||
}); err != nil {
|
||||
t.Errorf("Error auto-tagging performers: %s", err)
|
||||
|
|
@ -715,6 +717,9 @@ func TestParsePerformerImages(t *testing.T) {
|
|||
|
||||
for _, p := range performers {
|
||||
if err := withDB(func(ctx context.Context) error {
|
||||
if err := p.LoadAliases(ctx, r.Performer); err != nil {
|
||||
return err
|
||||
}
|
||||
return tagger.PerformerImages(ctx, p, nil, r.Image)
|
||||
}); err != nil {
|
||||
t.Errorf("Error auto-tagging performers: %s", err)
|
||||
|
|
@ -884,6 +889,9 @@ func TestParsePerformerGalleries(t *testing.T) {
|
|||
|
||||
for _, p := range performers {
|
||||
if err := withDB(func(ctx context.Context) error {
|
||||
if err := p.LoadAliases(ctx, r.Performer); err != nil {
|
||||
return err
|
||||
}
|
||||
return tagger.PerformerGalleries(ctx, p, nil, r.Gallery)
|
||||
}); err != nil {
|
||||
t.Errorf("Error auto-tagging performers: %s", err)
|
||||
|
|
|
|||
|
|
@ -30,83 +30,111 @@ type GalleryQueryPerformerUpdater interface {
|
|||
gallery.PartialUpdater
|
||||
}
|
||||
|
||||
func getPerformerTagger(p *models.Performer, cache *match.Cache) tagger {
|
||||
return tagger{
|
||||
func getPerformerTaggers(p *models.Performer, cache *match.Cache) []tagger {
|
||||
ret := []tagger{{
|
||||
ID: p.ID,
|
||||
Type: "performer",
|
||||
Name: p.Name,
|
||||
cache: cache,
|
||||
}
|
||||
}}
|
||||
|
||||
// TODO - disabled until we can have finer control over alias matching
|
||||
// for _, a := range p.Aliases.List() {
|
||||
// ret = append(ret, tagger{
|
||||
// ID: p.ID,
|
||||
// Type: "performer",
|
||||
// Name: a,
|
||||
// cache: cache,
|
||||
// })
|
||||
// }
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
// PerformerScenes searches for scenes whose path matches the provided performer name and tags the scene with the performer.
|
||||
// Performer aliases must be loaded.
|
||||
func (tagger *Tagger) PerformerScenes(ctx context.Context, p *models.Performer, paths []string, rw SceneQueryPerformerUpdater) error {
|
||||
t := getPerformerTagger(p, tagger.Cache)
|
||||
t := getPerformerTaggers(p, tagger.Cache)
|
||||
|
||||
return t.tagScenes(ctx, paths, rw, func(o *models.Scene) (bool, error) {
|
||||
if err := o.LoadPerformerIDs(ctx, rw); err != nil {
|
||||
return false, err
|
||||
}
|
||||
existing := o.PerformerIDs.List()
|
||||
for _, tt := range t {
|
||||
if err := tt.tagScenes(ctx, paths, rw, func(o *models.Scene) (bool, error) {
|
||||
if err := o.LoadPerformerIDs(ctx, rw); err != nil {
|
||||
return false, err
|
||||
}
|
||||
existing := o.PerformerIDs.List()
|
||||
|
||||
if intslice.IntInclude(existing, p.ID) {
|
||||
return false, nil
|
||||
}
|
||||
if intslice.IntInclude(existing, p.ID) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
if err := txn.WithTxn(ctx, tagger.TxnManager, func(ctx context.Context) error {
|
||||
return scene.AddPerformer(ctx, rw, o, p.ID)
|
||||
if err := txn.WithTxn(ctx, tagger.TxnManager, func(ctx context.Context) error {
|
||||
return scene.AddPerformer(ctx, rw, o, p.ID)
|
||||
}); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}); err != nil {
|
||||
return false, err
|
||||
return err
|
||||
}
|
||||
|
||||
return true, nil
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// PerformerImages searches for images whose path matches the provided performer name and tags the image with the performer.
|
||||
func (tagger *Tagger) PerformerImages(ctx context.Context, p *models.Performer, paths []string, rw ImageQueryPerformerUpdater) error {
|
||||
t := getPerformerTagger(p, tagger.Cache)
|
||||
t := getPerformerTaggers(p, tagger.Cache)
|
||||
|
||||
return t.tagImages(ctx, paths, rw, func(o *models.Image) (bool, error) {
|
||||
if err := o.LoadPerformerIDs(ctx, rw); err != nil {
|
||||
return false, err
|
||||
}
|
||||
existing := o.PerformerIDs.List()
|
||||
for _, tt := range t {
|
||||
if err := tt.tagImages(ctx, paths, rw, func(o *models.Image) (bool, error) {
|
||||
if err := o.LoadPerformerIDs(ctx, rw); err != nil {
|
||||
return false, err
|
||||
}
|
||||
existing := o.PerformerIDs.List()
|
||||
|
||||
if intslice.IntInclude(existing, p.ID) {
|
||||
return false, nil
|
||||
}
|
||||
if intslice.IntInclude(existing, p.ID) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
if err := txn.WithTxn(ctx, tagger.TxnManager, func(ctx context.Context) error {
|
||||
return image.AddPerformer(ctx, rw, o, p.ID)
|
||||
if err := txn.WithTxn(ctx, tagger.TxnManager, func(ctx context.Context) error {
|
||||
return image.AddPerformer(ctx, rw, o, p.ID)
|
||||
}); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}); err != nil {
|
||||
return false, err
|
||||
return err
|
||||
}
|
||||
|
||||
return true, nil
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// PerformerGalleries searches for galleries whose path matches the provided performer name and tags the gallery with the performer.
|
||||
func (tagger *Tagger) PerformerGalleries(ctx context.Context, p *models.Performer, paths []string, rw GalleryQueryPerformerUpdater) error {
|
||||
t := getPerformerTagger(p, tagger.Cache)
|
||||
t := getPerformerTaggers(p, tagger.Cache)
|
||||
|
||||
return t.tagGalleries(ctx, paths, rw, func(o *models.Gallery) (bool, error) {
|
||||
if err := o.LoadPerformerIDs(ctx, rw); err != nil {
|
||||
return false, err
|
||||
}
|
||||
existing := o.PerformerIDs.List()
|
||||
for _, tt := range t {
|
||||
if err := tt.tagGalleries(ctx, paths, rw, func(o *models.Gallery) (bool, error) {
|
||||
if err := o.LoadPerformerIDs(ctx, rw); err != nil {
|
||||
return false, err
|
||||
}
|
||||
existing := o.PerformerIDs.List()
|
||||
|
||||
if intslice.IntInclude(existing, p.ID) {
|
||||
return false, nil
|
||||
}
|
||||
if intslice.IntInclude(existing, p.ID) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
if err := txn.WithTxn(ctx, tagger.TxnManager, func(ctx context.Context) error {
|
||||
return gallery.AddPerformer(ctx, rw, o, p.ID)
|
||||
if err := txn.WithTxn(ctx, tagger.TxnManager, func(ctx context.Context) error {
|
||||
return gallery.AddPerformer(ctx, rw, o, p.ID)
|
||||
}); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}); err != nil {
|
||||
return false, err
|
||||
return err
|
||||
}
|
||||
|
||||
return true, nil
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -60,8 +60,9 @@ func testPerformerScenes(t *testing.T, performerName, expectedRegex string) {
|
|||
}
|
||||
|
||||
performer := models.Performer{
|
||||
ID: performerID,
|
||||
Name: performerName,
|
||||
ID: performerID,
|
||||
Name: performerName,
|
||||
Aliases: models.NewRelatedStrings([]string{}),
|
||||
}
|
||||
|
||||
organized := false
|
||||
|
|
@ -148,8 +149,9 @@ func testPerformerImages(t *testing.T, performerName, expectedRegex string) {
|
|||
}
|
||||
|
||||
performer := models.Performer{
|
||||
ID: performerID,
|
||||
Name: performerName,
|
||||
ID: performerID,
|
||||
Name: performerName,
|
||||
Aliases: models.NewRelatedStrings([]string{}),
|
||||
}
|
||||
|
||||
organized := false
|
||||
|
|
@ -237,8 +239,9 @@ func testPerformerGalleries(t *testing.T, performerName, expectedRegex string) {
|
|||
}
|
||||
|
||||
performer := models.Performer{
|
||||
ID: performerID,
|
||||
Name: performerName,
|
||||
ID: performerID,
|
||||
Name: performerName,
|
||||
Aliases: models.NewRelatedStrings([]string{}),
|
||||
}
|
||||
|
||||
organized := false
|
||||
|
|
|
|||
|
|
@ -151,15 +151,17 @@ func TestScenePerformers(t *testing.T) {
|
|||
const performerName = "performer name"
|
||||
const performerID = 2
|
||||
performer := models.Performer{
|
||||
ID: performerID,
|
||||
Name: performerName,
|
||||
ID: performerID,
|
||||
Name: performerName,
|
||||
Aliases: models.NewRelatedStrings([]string{}),
|
||||
}
|
||||
|
||||
const reversedPerformerName = "name performer"
|
||||
const reversedPerformerID = 3
|
||||
reversedPerformer := models.Performer{
|
||||
ID: reversedPerformerID,
|
||||
Name: reversedPerformerName,
|
||||
ID: reversedPerformerID,
|
||||
Name: reversedPerformerName,
|
||||
Aliases: models.NewRelatedStrings([]string{}),
|
||||
}
|
||||
|
||||
testTables := generateTestTable(performerName, sceneExt)
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ import (
|
|||
"github.com/kermieisinthehouse/systray"
|
||||
"github.com/stashapp/stash/internal/manager/config"
|
||||
"github.com/stashapp/stash/pkg/logger"
|
||||
"golang.org/x/text/cases"
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
// MUST be run on the main goroutine or will have no effect on macOS
|
||||
|
|
@ -55,7 +57,8 @@ func systrayInitialize(shutdownHandler ShutdownHandler, faviconProvider FaviconP
|
|||
if !c.IsNewSystem() {
|
||||
menuItems = c.GetMenuItems()
|
||||
for _, item := range menuItems {
|
||||
titleCaseItem := strings.Title(strings.ToLower(item))
|
||||
c := cases.Title(language.Und)
|
||||
titleCaseItem := c.String(strings.ToLower(item))
|
||||
curr := systray.AddMenuItem(titleCaseItem, "Open to "+titleCaseItem)
|
||||
go func(item string) {
|
||||
for {
|
||||
|
|
|
|||
|
|
@ -35,7 +35,6 @@ type SceneIdentifier struct {
|
|||
|
||||
DefaultOptions *MetadataOptions
|
||||
Sources []ScraperSource
|
||||
ScreenshotSetter scene.ScreenshotSetter
|
||||
SceneUpdatePostHookExecutor SceneUpdatePostHookExecutor
|
||||
}
|
||||
|
||||
|
|
@ -216,7 +215,7 @@ func (t *SceneIdentifier) modifyScene(ctx context.Context, txnManager txn.Manage
|
|||
return nil
|
||||
}
|
||||
|
||||
if _, err := updater.Update(ctx, t.SceneReaderUpdater, t.ScreenshotSetter); err != nil {
|
||||
if _, err := updater.Update(ctx, t.SceneReaderUpdater); err != nil {
|
||||
return fmt.Errorf("error updating scene: %w", err)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,13 +6,12 @@ import (
|
|||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/stashapp/stash/pkg/hash/md5"
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stashapp/stash/pkg/sliceutil/stringslice"
|
||||
)
|
||||
|
||||
type PerformerCreator interface {
|
||||
Create(ctx context.Context, newPerformer *models.Performer) error
|
||||
UpdateStashIDs(ctx context.Context, performerID int, stashIDs []models.StashID) error
|
||||
}
|
||||
|
||||
func getPerformerID(ctx context.Context, endpoint string, w PerformerCreator, p *models.ScrapedPerformer, createMissing bool) (*int, error) {
|
||||
|
|
@ -33,20 +32,18 @@ func getPerformerID(ctx context.Context, endpoint string, w PerformerCreator, p
|
|||
|
||||
func createMissingPerformer(ctx context.Context, endpoint string, w PerformerCreator, p *models.ScrapedPerformer) (*int, error) {
|
||||
performerInput := scrapedToPerformerInput(p)
|
||||
err := w.Create(ctx, &performerInput)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error creating performer: %w", err)
|
||||
}
|
||||
|
||||
if endpoint != "" && p.RemoteSiteID != nil {
|
||||
if err := w.UpdateStashIDs(ctx, performerInput.ID, []models.StashID{
|
||||
performerInput.StashIDs = models.NewRelatedStashIDs([]models.StashID{
|
||||
{
|
||||
Endpoint: endpoint,
|
||||
StashID: *p.RemoteSiteID,
|
||||
},
|
||||
}); err != nil {
|
||||
return nil, fmt.Errorf("error setting performer stash id: %w", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
err := w.Create(ctx, &performerInput)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error creating performer: %w", err)
|
||||
}
|
||||
|
||||
return &performerInput.ID, nil
|
||||
|
|
@ -56,7 +53,6 @@ func scrapedToPerformerInput(performer *models.ScrapedPerformer) models.Performe
|
|||
currentTime := time.Now()
|
||||
ret := models.Performer{
|
||||
Name: *performer.Name,
|
||||
Checksum: md5.FromString(*performer.Name),
|
||||
CreatedAt: currentTime,
|
||||
UpdatedAt: currentTime,
|
||||
}
|
||||
|
|
@ -111,7 +107,7 @@ func scrapedToPerformerInput(performer *models.ScrapedPerformer) models.Performe
|
|||
ret.Piercings = *performer.Piercings
|
||||
}
|
||||
if performer.Aliases != nil {
|
||||
ret.Aliases = *performer.Aliases
|
||||
ret.Aliases = models.NewRelatedStrings(stringslice.FromString(*performer.Aliases, ","))
|
||||
}
|
||||
if performer.Twitter != nil {
|
||||
ret.Twitter = *performer.Twitter
|
||||
|
|
|
|||
|
|
@ -127,7 +127,6 @@ func Test_getPerformerID(t *testing.T) {
|
|||
func Test_createMissingPerformer(t *testing.T) {
|
||||
emptyEndpoint := ""
|
||||
validEndpoint := "validEndpoint"
|
||||
invalidEndpoint := "invalidEndpoint"
|
||||
remoteSiteID := "remoteSiteID"
|
||||
validName := "validName"
|
||||
invalidName := "invalidName"
|
||||
|
|
@ -145,19 +144,6 @@ func Test_createMissingPerformer(t *testing.T) {
|
|||
return p.Name == invalidName
|
||||
})).Return(errors.New("error creating performer"))
|
||||
|
||||
mockPerformerReaderWriter.On("UpdateStashIDs", testCtx, performerID, []models.StashID{
|
||||
{
|
||||
Endpoint: invalidEndpoint,
|
||||
StashID: remoteSiteID,
|
||||
},
|
||||
}).Return(errors.New("error updating stash ids"))
|
||||
mockPerformerReaderWriter.On("UpdateStashIDs", testCtx, performerID, []models.StashID{
|
||||
{
|
||||
Endpoint: validEndpoint,
|
||||
StashID: remoteSiteID,
|
||||
},
|
||||
}).Return(nil)
|
||||
|
||||
type args struct {
|
||||
endpoint string
|
||||
p *models.ScrapedPerformer
|
||||
|
|
@ -202,18 +188,6 @@ func Test_createMissingPerformer(t *testing.T) {
|
|||
&performerID,
|
||||
false,
|
||||
},
|
||||
{
|
||||
"invalid stash id",
|
||||
args{
|
||||
invalidEndpoint,
|
||||
&models.ScrapedPerformer{
|
||||
Name: &validName,
|
||||
RemoteSiteID: &remoteSiteID,
|
||||
},
|
||||
},
|
||||
nil,
|
||||
true,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
|
|
@ -231,7 +205,6 @@ func Test_createMissingPerformer(t *testing.T) {
|
|||
|
||||
func Test_scrapedToPerformerInput(t *testing.T) {
|
||||
name := "name"
|
||||
md5 := "b068931cc450442b63f5b3d276ea4297"
|
||||
|
||||
var stringValues []string
|
||||
for i := 0; i < 17; i++ {
|
||||
|
|
@ -284,7 +257,6 @@ func Test_scrapedToPerformerInput(t *testing.T) {
|
|||
},
|
||||
models.Performer{
|
||||
Name: name,
|
||||
Checksum: md5,
|
||||
Birthdate: dateToDatePtr(models.NewDate(*nextVal())),
|
||||
DeathDate: dateToDatePtr(models.NewDate(*nextVal())),
|
||||
Gender: models.GenderEnum(*nextVal()),
|
||||
|
|
@ -299,7 +271,7 @@ func Test_scrapedToPerformerInput(t *testing.T) {
|
|||
CareerLength: *nextVal(),
|
||||
Tattoos: *nextVal(),
|
||||
Piercings: *nextVal(),
|
||||
Aliases: *nextVal(),
|
||||
Aliases: models.NewRelatedStrings([]string{*nextVal()}),
|
||||
Twitter: *nextVal(),
|
||||
Instagram: *nextVal(),
|
||||
},
|
||||
|
|
@ -310,8 +282,7 @@ func Test_scrapedToPerformerInput(t *testing.T) {
|
|||
Name: &name,
|
||||
},
|
||||
models.Performer{
|
||||
Name: name,
|
||||
Checksum: md5,
|
||||
Name: name,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import (
|
|||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/stashapp/stash/pkg/logger"
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stashapp/stash/pkg/scene"
|
||||
"github.com/stashapp/stash/pkg/sliceutil"
|
||||
|
|
@ -234,7 +235,7 @@ func (g sceneRelationships) cover(ctx context.Context) ([]byte, error) {
|
|||
// always overwrite if present
|
||||
existingCover, err := g.sceneReader.GetCover(ctx, g.scene.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error getting scene cover: %w", err)
|
||||
logger.Errorf("Error getting scene cover: %v", err)
|
||||
}
|
||||
|
||||
data, err := utils.ProcessImageInput(ctx, *scraped)
|
||||
|
|
|
|||
|
|
@ -742,8 +742,8 @@ func Test_sceneRelationships_cover(t *testing.T) {
|
|||
"error getting scene cover",
|
||||
errSceneID,
|
||||
&newDataEncoded,
|
||||
nil,
|
||||
true,
|
||||
newData,
|
||||
false,
|
||||
},
|
||||
{
|
||||
"invalid data",
|
||||
|
|
|
|||
|
|
@ -31,12 +31,15 @@ const (
|
|||
BackupDirectoryPath = "backup_directory_path"
|
||||
Generated = "generated"
|
||||
Metadata = "metadata"
|
||||
BlobsPath = "blobs_path"
|
||||
Downloads = "downloads"
|
||||
ApiKey = "api_key"
|
||||
Username = "username"
|
||||
Password = "password"
|
||||
MaxSessionAge = "max_session_age"
|
||||
|
||||
BlobsStorage = "blobs_storage"
|
||||
|
||||
DefaultMaxSessionAge = 60 * 60 * 1 // 1 hours
|
||||
|
||||
Database = "database"
|
||||
|
|
@ -60,10 +63,20 @@ const (
|
|||
MaxTranscodeSize = "max_transcode_size"
|
||||
MaxStreamingTranscodeSize = "max_streaming_transcode_size"
|
||||
|
||||
// ffmpeg extra args options
|
||||
TranscodeInputArgs = "ffmpeg.transcode.input_args"
|
||||
TranscodeOutputArgs = "ffmpeg.transcode.output_args"
|
||||
LiveTranscodeInputArgs = "ffmpeg.live_transcode.input_args"
|
||||
LiveTranscodeOutputArgs = "ffmpeg.live_transcode.output_args"
|
||||
|
||||
ParallelTasks = "parallel_tasks"
|
||||
parallelTasksDefault = 1
|
||||
|
||||
PreviewPreset = "preview_preset"
|
||||
PreviewPreset = "preview_preset"
|
||||
TranscodeHardwareAcceleration = "ffmpeg.hardware_acceleration"
|
||||
|
||||
SequentialScanning = "sequential_scanning"
|
||||
SequentialScanningDefault = false
|
||||
|
||||
PreviewAudio = "preview_audio"
|
||||
previewAudioDefault = true
|
||||
|
|
@ -91,6 +104,13 @@ const (
|
|||
|
||||
ExternalHost = "external_host"
|
||||
|
||||
// http proxy url if required
|
||||
Proxy = "proxy"
|
||||
|
||||
// urls or IPs that should not use the proxy
|
||||
NoProxy = "no_proxy"
|
||||
noProxyDefault = "localhost,127.0.0.1,192.168.0.0/16,10.0.0.0/8,172.16.0.0/12"
|
||||
|
||||
// key used to sign JWT tokens
|
||||
JWTSignKey = "jwt_secret_key"
|
||||
|
||||
|
|
@ -123,6 +143,10 @@ const (
|
|||
// rather than use the embedded UI.
|
||||
CustomUILocation = "custom_ui_location"
|
||||
|
||||
// Gallery Cover Regex
|
||||
GalleryCoverRegex = "gallery_cover_regex"
|
||||
galleryCoverRegexDefault = `(poster|cover|folder|board)\.[^\.]+$`
|
||||
|
||||
// Interface options
|
||||
MenuItems = "menu_items"
|
||||
|
||||
|
|
@ -168,6 +192,9 @@ const (
|
|||
HandyKey = "handy_key"
|
||||
FunscriptOffset = "funscript_offset"
|
||||
|
||||
DrawFunscriptHeatmapRange = "draw_funscript_heatmap_range"
|
||||
drawFunscriptHeatmapRangeDefault = true
|
||||
|
||||
ThemeColor = "theme_color"
|
||||
DefaultThemeColor = "#202b33"
|
||||
|
||||
|
|
@ -477,27 +504,14 @@ func (i *Instance) getStringMapString(key string) map[string]string {
|
|||
return ret
|
||||
}
|
||||
|
||||
type StashConfig struct {
|
||||
Path string `json:"path"`
|
||||
ExcludeVideo bool `json:"excludeVideo"`
|
||||
ExcludeImage bool `json:"excludeImage"`
|
||||
}
|
||||
|
||||
// Stash configuration details
|
||||
type StashConfigInput struct {
|
||||
Path string `json:"path"`
|
||||
ExcludeVideo bool `json:"excludeVideo"`
|
||||
ExcludeImage bool `json:"excludeImage"`
|
||||
}
|
||||
|
||||
// GetStathPaths returns the configured stash library paths.
|
||||
// Works opposite to the usual case - it will return the override
|
||||
// value only if the main value is not set.
|
||||
func (i *Instance) GetStashPaths() []*StashConfig {
|
||||
func (i *Instance) GetStashPaths() StashConfigs {
|
||||
i.RLock()
|
||||
defer i.RUnlock()
|
||||
|
||||
var ret []*StashConfig
|
||||
var ret StashConfigs
|
||||
|
||||
v := i.main
|
||||
if !v.IsSet(Stash) {
|
||||
|
|
@ -527,6 +541,22 @@ func (i *Instance) GetGeneratedPath() string {
|
|||
return i.getString(Generated)
|
||||
}
|
||||
|
||||
func (i *Instance) GetBlobsPath() string {
|
||||
return i.getString(BlobsPath)
|
||||
}
|
||||
|
||||
func (i *Instance) GetBlobsStorage() BlobsStorageType {
|
||||
ret := BlobsStorageType(i.getString(BlobsStorage))
|
||||
|
||||
if !ret.IsValid() {
|
||||
// default to database storage
|
||||
// for legacy systems this is probably the safer option
|
||||
ret = BlobStorageTypeDatabase
|
||||
}
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
func (i *Instance) GetMetadataPath() string {
|
||||
return i.getString(Metadata)
|
||||
}
|
||||
|
|
@ -629,6 +659,22 @@ func (i *Instance) GetVideoFileNamingAlgorithm() models.HashAlgorithm {
|
|||
return models.HashAlgorithm(ret)
|
||||
}
|
||||
|
||||
func (i *Instance) GetSequentialScanning() bool {
|
||||
return i.getBool(SequentialScanning)
|
||||
}
|
||||
|
||||
func (i *Instance) GetGalleryCoverRegex() string {
|
||||
var regexString = i.getString(GalleryCoverRegex)
|
||||
|
||||
_, err := regexp.Compile(regexString)
|
||||
if err != nil {
|
||||
logger.Warnf("Gallery cover regex '%v' invalid, reverting to default.", regexString)
|
||||
return galleryCoverRegexDefault
|
||||
}
|
||||
|
||||
return regexString
|
||||
}
|
||||
|
||||
func (i *Instance) GetScrapersPath() string {
|
||||
return i.getString(ScrapersPath)
|
||||
}
|
||||
|
|
@ -764,6 +810,10 @@ func (i *Instance) GetPreviewPreset() models.PreviewPreset {
|
|||
return models.PreviewPreset(ret)
|
||||
}
|
||||
|
||||
func (i *Instance) GetTranscodeHardwareAcceleration() bool {
|
||||
return i.getBool(TranscodeHardwareAcceleration)
|
||||
}
|
||||
|
||||
func (i *Instance) GetMaxTranscodeSize() models.StreamingResolutionEnum {
|
||||
ret := i.getString(MaxTranscodeSize)
|
||||
|
||||
|
|
@ -786,6 +836,26 @@ func (i *Instance) GetMaxStreamingTranscodeSize() models.StreamingResolutionEnum
|
|||
return models.StreamingResolutionEnum(ret)
|
||||
}
|
||||
|
||||
func (i *Instance) GetTranscodeInputArgs() []string {
|
||||
return i.getStringSlice(TranscodeInputArgs)
|
||||
}
|
||||
|
||||
func (i *Instance) GetTranscodeOutputArgs() []string {
|
||||
return i.getStringSlice(TranscodeOutputArgs)
|
||||
}
|
||||
|
||||
func (i *Instance) GetLiveTranscodeInputArgs() []string {
|
||||
return i.getStringSlice(LiveTranscodeInputArgs)
|
||||
}
|
||||
|
||||
func (i *Instance) GetLiveTranscodeOutputArgs() []string {
|
||||
return i.getStringSlice(LiveTranscodeOutputArgs)
|
||||
}
|
||||
|
||||
func (i *Instance) GetDrawFunscriptHeatmapRange() bool {
|
||||
return i.getBoolDefault(DrawFunscriptHeatmapRange, drawFunscriptHeatmapRangeDefault)
|
||||
}
|
||||
|
||||
// IsWriteImageThumbnails returns true if image thumbnails should be written
|
||||
// to disk after generating on the fly.
|
||||
func (i *Instance) IsWriteImageThumbnails() bool {
|
||||
|
|
@ -1265,7 +1335,7 @@ func (i *Instance) GetDefaultGenerateSettings() *models.GenerateMetadataOptions
|
|||
}
|
||||
|
||||
// GetDangerousAllowPublicWithoutAuth determines if the security feature is enabled.
|
||||
// See https://github.com/stashapp/stash/wiki/Authentication-Required-When-Accessing-Stash-From-the-Internet
|
||||
// See https://docs.stashapp.cc/networking/authentication-required-when-accessing-stash-from-the-internet
|
||||
func (i *Instance) GetDangerousAllowPublicWithoutAuth() bool {
|
||||
return i.getBool(dangerousAllowPublicWithoutAuth)
|
||||
}
|
||||
|
|
@ -1343,6 +1413,27 @@ func (i *Instance) GetMaxUploadSize() int64 {
|
|||
return ret << 20
|
||||
}
|
||||
|
||||
// GetProxy returns the url of a http proxy to be used for all outgoing http calls.
|
||||
func (i *Instance) GetProxy() string {
|
||||
// Validate format
|
||||
reg := regexp.MustCompile(`^((?:socks5h?|https?):\/\/)(([\P{Cc}]+):([\P{Cc}]+)@)?(([a-zA-Z0-9][a-zA-Z0-9.-]*)(:[0-9]{1,5})?)`)
|
||||
proxy := i.getString(Proxy)
|
||||
if proxy != "" && reg.MatchString(proxy) {
|
||||
logger.Debug("Proxy is valid, using it")
|
||||
return proxy
|
||||
} else if proxy != "" {
|
||||
logger.Error("Proxy is invalid, please review your configuration")
|
||||
return ""
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// GetProxy returns the url of a http proxy to be used for all outgoing http calls.
|
||||
func (i *Instance) GetNoProxy() string {
|
||||
// NoProxy does not require validation, it is validated by the native Go library sufficiently
|
||||
return i.getString(NoProxy)
|
||||
}
|
||||
|
||||
// ActivatePublicAccessTripwire sets the security_tripwire_accessed_from_public_internet
|
||||
// config field to the provided IP address to indicate that stash has been accessed
|
||||
// from this public IP without authentication.
|
||||
|
|
@ -1373,6 +1464,12 @@ func (i *Instance) Validate() error {
|
|||
}
|
||||
}
|
||||
|
||||
if i.GetBlobsStorage() == BlobStorageTypeFilesystem && i.viper(BlobsPath).GetString(BlobsPath) == "" {
|
||||
return MissingConfigError{
|
||||
missingFields: []string{BlobsPath},
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -1391,6 +1488,7 @@ func (i *Instance) setDefaultValues(write bool) error {
|
|||
i.main.SetDefault(Port, portDefault)
|
||||
|
||||
i.main.SetDefault(ParallelTasks, parallelTasksDefault)
|
||||
i.main.SetDefault(SequentialScanning, SequentialScanningDefault)
|
||||
i.main.SetDefault(PreviewSegmentDuration, previewSegmentDurationDefault)
|
||||
i.main.SetDefault(PreviewSegments, previewSegmentsDefault)
|
||||
i.main.SetDefault(PreviewExcludeStart, previewExcludeStartDefault)
|
||||
|
|
@ -1418,6 +1516,12 @@ func (i *Instance) setDefaultValues(write bool) error {
|
|||
i.main.SetDefault(ScrapersPath, defaultScrapersPath)
|
||||
i.main.SetDefault(PluginsPath, defaultPluginsPath)
|
||||
|
||||
// Set default gallery cover regex
|
||||
i.main.SetDefault(GalleryCoverRegex, galleryCoverRegexDefault)
|
||||
|
||||
// Set NoProxy default
|
||||
i.main.SetDefault(NoProxy, noProxyDefault)
|
||||
|
||||
if write {
|
||||
return i.main.WriteConfig()
|
||||
}
|
||||
|
|
|
|||
50
internal/manager/config/enums.go
Normal file
50
internal/manager/config/enums.go
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
type BlobsStorageType string
|
||||
|
||||
const (
|
||||
// Database
|
||||
BlobStorageTypeDatabase BlobsStorageType = "DATABASE"
|
||||
// Filesystem
|
||||
BlobStorageTypeFilesystem BlobsStorageType = "FILESYSTEM"
|
||||
)
|
||||
|
||||
var AllBlobStorageType = []BlobsStorageType{
|
||||
BlobStorageTypeDatabase,
|
||||
BlobStorageTypeFilesystem,
|
||||
}
|
||||
|
||||
func (e BlobsStorageType) IsValid() bool {
|
||||
switch e {
|
||||
case BlobStorageTypeDatabase, BlobStorageTypeFilesystem:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (e BlobsStorageType) String() string {
|
||||
return string(e)
|
||||
}
|
||||
|
||||
func (e *BlobsStorageType) UnmarshalGQL(v interface{}) error {
|
||||
str, ok := v.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("enums must be strings")
|
||||
}
|
||||
|
||||
*e = BlobsStorageType(str)
|
||||
if !e.IsValid() {
|
||||
return fmt.Errorf("%s is not a valid BlobStorageType", str)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e BlobsStorageType) MarshalGQL(w io.Writer) {
|
||||
fmt.Fprint(w, strconv.Quote(e.String()))
|
||||
}
|
||||
40
internal/manager/config/stash_config.go
Normal file
40
internal/manager/config/stash_config.go
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
|
||||
"github.com/stashapp/stash/pkg/fsutil"
|
||||
)
|
||||
|
||||
// Stash configuration details
|
||||
type StashConfigInput struct {
|
||||
Path string `json:"path"`
|
||||
ExcludeVideo bool `json:"excludeVideo"`
|
||||
ExcludeImage bool `json:"excludeImage"`
|
||||
}
|
||||
|
||||
type StashConfig struct {
|
||||
Path string `json:"path"`
|
||||
ExcludeVideo bool `json:"excludeVideo"`
|
||||
ExcludeImage bool `json:"excludeImage"`
|
||||
}
|
||||
|
||||
type StashConfigs []*StashConfig
|
||||
|
||||
func (s StashConfigs) GetStashFromPath(path string) *StashConfig {
|
||||
for _, f := range s {
|
||||
if fsutil.IsPathInDir(f.Path, filepath.Dir(path)) {
|
||||
return f
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s StashConfigs) GetStashFromDirPath(dirPath string) *StashConfig {
|
||||
for _, f := range s {
|
||||
if fsutil.IsPathInDir(f.Path, dirPath) {
|
||||
return f
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
@ -7,6 +7,8 @@ type ScanMetadataOptions struct {
|
|||
// Strip file extension from title
|
||||
// Deprecated: not implemented
|
||||
StripFileExtension bool `json:"stripFileExtension"`
|
||||
// Generate scene covers during scan
|
||||
ScanGenerateCovers bool `json:"scanGenerateCovers"`
|
||||
// Generate previews during scan
|
||||
ScanGeneratePreviews bool `json:"scanGeneratePreviews"`
|
||||
// Generate image previews during scan
|
||||
|
|
|
|||
|
|
@ -15,14 +15,13 @@ import (
|
|||
)
|
||||
|
||||
type InteractiveHeatmapSpeedGenerator struct {
|
||||
sceneDurationMilli int64
|
||||
InteractiveSpeed int
|
||||
Funscript Script
|
||||
FunscriptPath string
|
||||
HeatmapPath string
|
||||
Width int
|
||||
Height int
|
||||
NumSegments int
|
||||
InteractiveSpeed int
|
||||
Funscript Script
|
||||
Width int
|
||||
Height int
|
||||
NumSegments int
|
||||
|
||||
DrawRange bool
|
||||
}
|
||||
|
||||
type Script struct {
|
||||
|
|
@ -33,8 +32,7 @@ type Script struct {
|
|||
// Range is the percentage of a full stroke to use.
|
||||
Range int `json:"range,omitempty"`
|
||||
// Actions are the timed moves.
|
||||
Actions []Action `json:"actions"`
|
||||
AvarageSpeed int64
|
||||
Actions []Action `json:"actions"`
|
||||
}
|
||||
|
||||
// Action is a move at a specific time.
|
||||
|
|
@ -50,23 +48,22 @@ type Action struct {
|
|||
}
|
||||
|
||||
type GradientTable []struct {
|
||||
Col colorful.Color
|
||||
Pos float64
|
||||
Col colorful.Color
|
||||
Pos float64
|
||||
YRange [2]float64
|
||||
}
|
||||
|
||||
func NewInteractiveHeatmapSpeedGenerator(funscriptPath string, heatmapPath string, sceneDuration float64) *InteractiveHeatmapSpeedGenerator {
|
||||
func NewInteractiveHeatmapSpeedGenerator(drawRange bool) *InteractiveHeatmapSpeedGenerator {
|
||||
return &InteractiveHeatmapSpeedGenerator{
|
||||
sceneDurationMilli: int64(sceneDuration * 1000),
|
||||
FunscriptPath: funscriptPath,
|
||||
HeatmapPath: heatmapPath,
|
||||
Width: 320,
|
||||
Height: 15,
|
||||
NumSegments: 150,
|
||||
Width: 1280,
|
||||
Height: 60,
|
||||
NumSegments: 600,
|
||||
DrawRange: drawRange,
|
||||
}
|
||||
}
|
||||
|
||||
func (g *InteractiveHeatmapSpeedGenerator) Generate() error {
|
||||
funscript, err := g.LoadFunscriptData(g.FunscriptPath)
|
||||
func (g *InteractiveHeatmapSpeedGenerator) Generate(funscriptPath string, heatmapPath string, sceneDuration float64) error {
|
||||
funscript, err := g.LoadFunscriptData(funscriptPath, sceneDuration)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
@ -79,7 +76,7 @@ func (g *InteractiveHeatmapSpeedGenerator) Generate() error {
|
|||
g.Funscript = funscript
|
||||
g.Funscript.UpdateIntensityAndSpeed()
|
||||
|
||||
err = g.RenderHeatmap()
|
||||
err = g.RenderHeatmap(heatmapPath)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
@ -90,7 +87,7 @@ func (g *InteractiveHeatmapSpeedGenerator) Generate() error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func (g *InteractiveHeatmapSpeedGenerator) LoadFunscriptData(path string) (Script, error) {
|
||||
func (g *InteractiveHeatmapSpeedGenerator) LoadFunscriptData(path string, sceneDuration float64) (Script, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return Script{}, err
|
||||
|
|
@ -111,8 +108,9 @@ func (g *InteractiveHeatmapSpeedGenerator) LoadFunscriptData(path string) (Scrip
|
|||
// trim actions with negative timestamps to avoid index range errors when generating heatmap
|
||||
// #3181 - also trim actions that occur after the scene duration
|
||||
loggedBadTimestamp := false
|
||||
sceneDurationMilli := int64(sceneDuration * 1000)
|
||||
isValid := func(x int64) bool {
|
||||
return x >= 0 && x < g.sceneDurationMilli
|
||||
return x >= 0 && x < sceneDurationMilli
|
||||
}
|
||||
|
||||
i := 0
|
||||
|
|
@ -157,14 +155,27 @@ func (funscript *Script) UpdateIntensityAndSpeed() {
|
|||
}
|
||||
|
||||
// funscript needs to have intensity updated first
|
||||
func (g *InteractiveHeatmapSpeedGenerator) RenderHeatmap() error {
|
||||
|
||||
func (g *InteractiveHeatmapSpeedGenerator) RenderHeatmap(heatmapPath string) error {
|
||||
gradient := g.Funscript.getGradientTable(g.NumSegments)
|
||||
|
||||
img := image.NewRGBA(image.Rect(0, 0, g.Width, g.Height))
|
||||
for x := 0; x < g.Width; x++ {
|
||||
c := gradient.GetInterpolatedColorFor(float64(x) / float64(g.Width))
|
||||
draw.Draw(img, image.Rect(x, 0, x+1, g.Height), &image.Uniform{c}, image.Point{}, draw.Src)
|
||||
xPos := float64(x) / float64(g.Width)
|
||||
c := gradient.GetInterpolatedColorFor(xPos)
|
||||
|
||||
y0 := 0
|
||||
y1 := g.Height
|
||||
|
||||
if g.DrawRange {
|
||||
yRange := gradient.GetYRange(xPos)
|
||||
top := int(yRange[0] / 100.0 * float64(g.Height))
|
||||
bottom := int(yRange[1] / 100.0 * float64(g.Height))
|
||||
|
||||
y0 = g.Height - top
|
||||
y1 = g.Height - bottom
|
||||
}
|
||||
|
||||
draw.Draw(img, image.Rect(x, y0, x+1, y1), &image.Uniform{c}, image.Point{}, draw.Src)
|
||||
}
|
||||
|
||||
// add 10 minute marks
|
||||
|
|
@ -178,7 +189,7 @@ func (g *InteractiveHeatmapSpeedGenerator) RenderHeatmap() error {
|
|||
ts += tick
|
||||
}
|
||||
|
||||
outpng, err := os.Create(g.HeatmapPath)
|
||||
outpng, err := os.Create(heatmapPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -217,27 +228,96 @@ func (gt GradientTable) GetInterpolatedColorFor(t float64) colorful.Color {
|
|||
return gt[len(gt)-1].Col
|
||||
}
|
||||
|
||||
func (gt GradientTable) GetYRange(t float64) [2]float64 {
|
||||
for i := 0; i < len(gt)-1; i++ {
|
||||
c1 := gt[i]
|
||||
c2 := gt[i+1]
|
||||
if c1.Pos <= t && t <= c2.Pos {
|
||||
// TODO: We are in between c1 and c2. Go blend them!
|
||||
return c1.YRange
|
||||
}
|
||||
}
|
||||
|
||||
// Nothing found? Means we're at (or past) the last gradient keypoint.
|
||||
return gt[len(gt)-1].YRange
|
||||
}
|
||||
|
||||
func (funscript Script) getGradientTable(numSegments int) GradientTable {
|
||||
const windowSize = 15
|
||||
const backfillThreshold = 500
|
||||
|
||||
segments := make([]struct {
|
||||
count int
|
||||
intensity int
|
||||
yRange [2]float64
|
||||
at int64
|
||||
}, numSegments)
|
||||
gradient := make(GradientTable, numSegments)
|
||||
posList := []int{}
|
||||
|
||||
maxts := funscript.Actions[len(funscript.Actions)-1].At
|
||||
|
||||
for _, a := range funscript.Actions {
|
||||
posList = append(posList, a.Pos)
|
||||
|
||||
if len(posList) > windowSize {
|
||||
posList = posList[1:]
|
||||
}
|
||||
|
||||
sortedPos := make([]int, len(posList))
|
||||
copy(sortedPos, posList)
|
||||
sort.Ints(sortedPos)
|
||||
|
||||
topHalf := sortedPos[len(sortedPos)/2:]
|
||||
bottomHalf := sortedPos[0 : len(sortedPos)/2]
|
||||
|
||||
var totalBottom int
|
||||
var totalTop int
|
||||
|
||||
for _, value := range bottomHalf {
|
||||
totalBottom += value
|
||||
}
|
||||
for _, value := range topHalf {
|
||||
totalTop += value
|
||||
}
|
||||
|
||||
averageBottom := float64(totalBottom) / float64(len(bottomHalf))
|
||||
averageTop := float64(totalTop) / float64(len(topHalf))
|
||||
|
||||
segment := int(float64(a.At) / float64(maxts+1) * float64(numSegments))
|
||||
// #3181 - sanity check. Clamp segment to numSegments-1
|
||||
if segment >= numSegments {
|
||||
segment = numSegments - 1
|
||||
}
|
||||
segments[segment].at = a.At
|
||||
segments[segment].count++
|
||||
segments[segment].intensity += int(a.Intensity)
|
||||
segments[segment].yRange[0] = averageTop
|
||||
segments[segment].yRange[1] = averageBottom
|
||||
}
|
||||
|
||||
lastSegment := segments[0]
|
||||
|
||||
// Fill in gaps in segments
|
||||
for i := 0; i < numSegments; i++ {
|
||||
segmentTS := int64(float64(i) / float64(numSegments))
|
||||
|
||||
// Empty segment - fill it with the previous up to backfillThreshold ms
|
||||
if segments[i].count == 0 {
|
||||
if segmentTS-lastSegment.at < backfillThreshold {
|
||||
segments[i].count = lastSegment.count
|
||||
segments[i].intensity = lastSegment.intensity
|
||||
segments[i].yRange[0] = lastSegment.yRange[0]
|
||||
segments[i].yRange[1] = lastSegment.yRange[1]
|
||||
}
|
||||
} else {
|
||||
lastSegment = segments[i]
|
||||
}
|
||||
}
|
||||
|
||||
for i := 0; i < numSegments; i++ {
|
||||
gradient[i].Pos = float64(i) / float64(numSegments-1)
|
||||
gradient[i].YRange = segments[i].yRange
|
||||
if segments[i].count > 0 {
|
||||
gradient[i].Col = getSegmentColor(float64(segments[i].intensity) / float64(segments[i].count))
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -75,9 +75,10 @@ func NewSpriteGenerator(videoFile ffmpeg.VideoFile, videoChecksum string, imageO
|
|||
SlowSeek: slowSeek,
|
||||
Columns: cols,
|
||||
g: &generate.Generator{
|
||||
Encoder: instance.FFMPEG,
|
||||
LockManager: instance.ReadLockManager,
|
||||
ScenePaths: instance.Paths.Scene,
|
||||
Encoder: instance.FFMPEG,
|
||||
FFMpegConfig: instance.Config,
|
||||
LockManager: instance.ReadLockManager,
|
||||
ScenePaths: instance.Paths.Scene,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,11 +26,9 @@ import (
|
|||
"github.com/stashapp/stash/pkg/image"
|
||||
"github.com/stashapp/stash/pkg/job"
|
||||
"github.com/stashapp/stash/pkg/logger"
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stashapp/stash/pkg/models/paths"
|
||||
"github.com/stashapp/stash/pkg/plugin"
|
||||
"github.com/stashapp/stash/pkg/scene"
|
||||
"github.com/stashapp/stash/pkg/scene/generate"
|
||||
"github.com/stashapp/stash/pkg/scraper"
|
||||
"github.com/stashapp/stash/pkg/session"
|
||||
"github.com/stashapp/stash/pkg/sqlite"
|
||||
|
|
@ -100,6 +98,10 @@ type SetupInput struct {
|
|||
DatabaseFile string `json:"databaseFile"`
|
||||
// Empty to indicate default
|
||||
GeneratedLocation string `json:"generatedLocation"`
|
||||
// Empty to indicate default
|
||||
CacheLocation string `json:"cacheLocation"`
|
||||
// Empty to indicate database storage for blobs
|
||||
BlobsLocation string `json:"blobsLocation"`
|
||||
}
|
||||
|
||||
type Manager struct {
|
||||
|
|
@ -108,8 +110,9 @@ type Manager struct {
|
|||
|
||||
Paths *paths.Paths
|
||||
|
||||
FFMPEG ffmpeg.FFMpeg
|
||||
FFProbe ffmpeg.FFProbe
|
||||
FFMPEG *ffmpeg.FFMpeg
|
||||
FFProbe ffmpeg.FFProbe
|
||||
StreamManager *ffmpeg.StreamManager
|
||||
|
||||
ReadLockManager *fsutil.ReadLockManager
|
||||
|
||||
|
|
@ -287,19 +290,6 @@ func galleryFileFilter(ctx context.Context, f file.File) bool {
|
|||
return isZip(f.Base().Basename)
|
||||
}
|
||||
|
||||
type coverGenerator struct {
|
||||
}
|
||||
|
||||
func (g *coverGenerator) GenerateCover(ctx context.Context, scene *models.Scene, f *file.VideoFile) error {
|
||||
gg := generate.Generator{
|
||||
Encoder: instance.FFMPEG,
|
||||
LockManager: instance.ReadLockManager,
|
||||
ScenePaths: instance.Paths.Scene,
|
||||
}
|
||||
|
||||
return gg.Screenshot(ctx, f.Path, scene.GetHash(instance.Config.GetVideoFileNamingAlgorithm()), f.Width, f.Duration, generate.ScreenshotOptions{})
|
||||
}
|
||||
|
||||
func makeScanner(db *sqlite.Database, pluginCache *plugin.Cache) *file.Scanner {
|
||||
return &file.Scanner{
|
||||
Repository: file.Repository{
|
||||
|
|
@ -427,8 +417,11 @@ func initFFMPEG(ctx context.Context) error {
|
|||
}
|
||||
}
|
||||
|
||||
instance.FFMPEG = ffmpeg.FFMpeg(ffmpegPath)
|
||||
instance.FFMPEG = ffmpeg.NewEncoder(ffmpegPath)
|
||||
instance.FFProbe = ffmpeg.FFProbe(ffprobePath)
|
||||
|
||||
instance.FFMPEG.InitHWSupport(ctx)
|
||||
instance.RefreshStreamManager()
|
||||
}
|
||||
|
||||
return nil
|
||||
|
|
@ -451,7 +444,7 @@ func (s *Manager) PostInit(ctx context.Context) error {
|
|||
logger.Warnf("could not set initial configuration: %v", err)
|
||||
}
|
||||
|
||||
*s.Paths = paths.NewPaths(s.Config.GetGeneratedPath())
|
||||
*s.Paths = paths.NewPaths(s.Config.GetGeneratedPath(), s.Config.GetBlobsPath())
|
||||
s.RefreshConfig()
|
||||
s.SessionStore = session.NewStore(s.Config)
|
||||
s.PluginCache.RegisterSessionStore(s.SessionStore)
|
||||
|
|
@ -460,6 +453,8 @@ func (s *Manager) PostInit(ctx context.Context) error {
|
|||
logger.Errorf("Error reading plugin configs: %s", err.Error())
|
||||
}
|
||||
|
||||
s.SetBlobStoreOptions()
|
||||
|
||||
s.ScraperCache = instance.initScraperCache()
|
||||
writeStashIcon()
|
||||
|
||||
|
|
@ -491,9 +486,28 @@ func (s *Manager) PostInit(ctx context.Context) error {
|
|||
return err
|
||||
}
|
||||
|
||||
// Set the proxy if defined in config
|
||||
if s.Config.GetProxy() != "" {
|
||||
os.Setenv("HTTP_PROXY", s.Config.GetProxy())
|
||||
os.Setenv("HTTPS_PROXY", s.Config.GetProxy())
|
||||
os.Setenv("NO_PROXY", s.Config.GetNoProxy())
|
||||
logger.Info("Using HTTP Proxy")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Manager) SetBlobStoreOptions() {
|
||||
storageType := s.Config.GetBlobsStorage()
|
||||
blobsPath := s.Config.GetBlobsPath()
|
||||
|
||||
s.Database.SetBlobStoreOptions(sqlite.BlobStoreOptions{
|
||||
UseFilesystem: storageType == config.BlobStorageTypeFilesystem,
|
||||
UseDatabase: storageType == config.BlobStorageTypeDatabase,
|
||||
Path: blobsPath,
|
||||
})
|
||||
}
|
||||
|
||||
func writeStashIcon() {
|
||||
p := FaviconProvider{
|
||||
UIBox: ui.UIBox,
|
||||
|
|
@ -525,7 +539,7 @@ func (s *Manager) initScraperCache() *scraper.Cache {
|
|||
}
|
||||
|
||||
func (s *Manager) RefreshConfig() {
|
||||
*s.Paths = paths.NewPaths(s.Config.GetGeneratedPath())
|
||||
*s.Paths = paths.NewPaths(s.Config.GetGeneratedPath(), s.Config.GetBlobsPath())
|
||||
config := s.Config
|
||||
if config.Validate() == nil {
|
||||
if err := fsutil.EnsureDir(s.Paths.Generated.Screenshots); err != nil {
|
||||
|
|
@ -555,6 +569,19 @@ func (s *Manager) RefreshScraperCache() {
|
|||
s.ScraperCache = s.initScraperCache()
|
||||
}
|
||||
|
||||
// RefreshStreamManager refreshes the stream manager. Call this when cache directory
|
||||
// changes.
|
||||
func (s *Manager) RefreshStreamManager() {
|
||||
// shutdown existing manager if needed
|
||||
if s.StreamManager != nil {
|
||||
s.StreamManager.Shutdown()
|
||||
s.StreamManager = nil
|
||||
}
|
||||
|
||||
cacheDir := s.Config.GetCachePath()
|
||||
s.StreamManager = ffmpeg.NewStreamManager(cacheDir, s.FFMPEG, s.FFProbe, s.Config, s.ReadLockManager)
|
||||
}
|
||||
|
||||
func setSetupDefaults(input *SetupInput) {
|
||||
if input.ConfigLocation == "" {
|
||||
input.ConfigLocation = filepath.Join(fsutil.GetHomeDirectory(), ".stash", "config.yml")
|
||||
|
|
@ -564,6 +591,9 @@ func setSetupDefaults(input *SetupInput) {
|
|||
if input.GeneratedLocation == "" {
|
||||
input.GeneratedLocation = filepath.Join(configDir, "generated")
|
||||
}
|
||||
if input.CacheLocation == "" {
|
||||
input.CacheLocation = filepath.Join(configDir, "cache")
|
||||
}
|
||||
|
||||
if input.DatabaseFile == "" {
|
||||
input.DatabaseFile = filepath.Join(configDir, "stash-go.sqlite")
|
||||
|
|
@ -577,24 +607,31 @@ func (s *Manager) Setup(ctx context.Context, input SetupInput) error {
|
|||
// create the config directory if it does not exist
|
||||
// don't do anything if config is already set in the environment
|
||||
if !config.FileEnvSet() {
|
||||
configDir := filepath.Dir(input.ConfigLocation)
|
||||
// #3304 - if config path is relative, it breaks the ffmpeg/ffprobe
|
||||
// paths since they must not be relative. The config file property is
|
||||
// resolved to an absolute path when stash is run normally, so convert
|
||||
// relative paths to absolute paths during setup.
|
||||
configFile, _ := filepath.Abs(input.ConfigLocation)
|
||||
|
||||
configDir := filepath.Dir(configFile)
|
||||
|
||||
if exists, _ := fsutil.DirExists(configDir); !exists {
|
||||
if err := os.Mkdir(configDir, 0755); err != nil {
|
||||
if err := os.MkdirAll(configDir, 0755); err != nil {
|
||||
return fmt.Errorf("error creating config directory: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := fsutil.Touch(input.ConfigLocation); err != nil {
|
||||
if err := fsutil.Touch(configFile); err != nil {
|
||||
return fmt.Errorf("error creating config file: %v", err)
|
||||
}
|
||||
|
||||
s.Config.SetConfigFile(input.ConfigLocation)
|
||||
s.Config.SetConfigFile(configFile)
|
||||
}
|
||||
|
||||
// create the generated directory if it does not exist
|
||||
if !c.HasOverride(config.Generated) {
|
||||
if exists, _ := fsutil.DirExists(input.GeneratedLocation); !exists {
|
||||
if err := os.Mkdir(input.GeneratedLocation, 0755); err != nil {
|
||||
if err := os.MkdirAll(input.GeneratedLocation, 0755); err != nil {
|
||||
return fmt.Errorf("error creating generated directory: %v", err)
|
||||
}
|
||||
}
|
||||
|
|
@ -602,6 +639,33 @@ func (s *Manager) Setup(ctx context.Context, input SetupInput) error {
|
|||
s.Config.Set(config.Generated, input.GeneratedLocation)
|
||||
}
|
||||
|
||||
// create the cache directory if it does not exist
|
||||
if !c.HasOverride(config.Cache) {
|
||||
if exists, _ := fsutil.DirExists(input.CacheLocation); !exists {
|
||||
if err := os.MkdirAll(input.CacheLocation, 0755); err != nil {
|
||||
return fmt.Errorf("error creating cache directory: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
s.Config.Set(config.Cache, input.CacheLocation)
|
||||
}
|
||||
|
||||
// if blobs path was provided then use filesystem based blob storage
|
||||
if input.BlobsLocation != "" {
|
||||
if !c.HasOverride(config.BlobsPath) {
|
||||
if exists, _ := fsutil.DirExists(input.BlobsLocation); !exists {
|
||||
if err := os.MkdirAll(input.BlobsLocation, 0755); err != nil {
|
||||
return fmt.Errorf("error creating blobs directory: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
s.Config.Set(config.BlobsPath, input.BlobsLocation)
|
||||
s.Config.Set(config.BlobsStorage, config.BlobStorageTypeFilesystem)
|
||||
} else {
|
||||
s.Config.Set(config.BlobsStorage, config.BlobStorageTypeDatabase)
|
||||
}
|
||||
|
||||
// set the configuration
|
||||
if !c.HasOverride(config.Database) {
|
||||
s.Config.Set(config.Database, input.DatabaseFile)
|
||||
|
|
@ -634,7 +698,7 @@ func (s *Manager) Setup(ctx context.Context, input SetupInput) error {
|
|||
}
|
||||
|
||||
func (s *Manager) validateFFMPEG() error {
|
||||
if s.FFMPEG == "" || s.FFProbe == "" {
|
||||
if s.FFMPEG == nil || s.FFProbe == "" {
|
||||
return errors.New("missing ffmpeg and/or ffprobe")
|
||||
}
|
||||
|
||||
|
|
@ -719,6 +783,11 @@ func (s *Manager) Shutdown(code int) {
|
|||
// stop any profiling at exit
|
||||
pprof.StopCPUProfile()
|
||||
|
||||
if s.StreamManager != nil {
|
||||
s.StreamManager.Shutdown()
|
||||
s.StreamManager = nil
|
||||
}
|
||||
|
||||
// TODO: Each part of the manager needs to gracefully stop at some point
|
||||
// for now, we just close the database.
|
||||
err := s.Database.Close()
|
||||
|
|
|
|||
|
|
@ -37,9 +37,9 @@ func getScanPaths(inputPaths []string) []*config.StashConfig {
|
|||
return stashPaths
|
||||
}
|
||||
|
||||
var ret []*config.StashConfig
|
||||
var ret config.StashConfigs
|
||||
for _, p := range inputPaths {
|
||||
s := getStashFromDirPath(stashPaths, p)
|
||||
s := stashPaths.GetStashFromDirPath(p)
|
||||
if s == nil {
|
||||
logger.Warnf("%s is not in the configured stash paths", p)
|
||||
continue
|
||||
|
|
@ -194,11 +194,11 @@ func (s *Manager) generateScreenshot(ctx context.Context, sceneId string, at *fl
|
|||
return
|
||||
}
|
||||
|
||||
task := GenerateScreenshotTask{
|
||||
txnManager: s.Repository,
|
||||
Scene: *scene,
|
||||
ScreenshotAt: at,
|
||||
fileNamingAlgorithm: config.GetInstance().GetVideoFileNamingAlgorithm(),
|
||||
task := GenerateCoverTask{
|
||||
txnManager: s.Repository,
|
||||
Scene: *scene,
|
||||
ScreenshotAt: at,
|
||||
Overwrite: true,
|
||||
}
|
||||
|
||||
task.Start(ctx)
|
||||
|
|
@ -336,6 +336,10 @@ func (s *Manager) StashBoxBatchPerformerTag(ctx context.Context, input StashBoxB
|
|||
for _, performerID := range input.PerformerIds {
|
||||
if id, err := strconv.Atoi(performerID); err == nil {
|
||||
performer, err := performerQuery.Find(ctx, id)
|
||||
if err == nil {
|
||||
err = performer.LoadStashIDs(ctx, performerQuery)
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
tasks = append(tasks, StashBoxPerformerTagTask{
|
||||
performer: performer,
|
||||
|
|
@ -382,6 +386,10 @@ func (s *Manager) StashBoxBatchPerformerTag(ctx context.Context, input StashBoxB
|
|||
}
|
||||
|
||||
for _, performer := range performers {
|
||||
if err := performer.LoadStashIDs(ctx, performerQuery); err != nil {
|
||||
return fmt.Errorf("error loading stash ids for performer %s: %v", performer.Name, err)
|
||||
}
|
||||
|
||||
tasks = append(tasks, StashBoxPerformerTagTask{
|
||||
performer: performer,
|
||||
refresh: input.Refresh,
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue