Merge pull request #3667 from stashapp/develop

Merge develop to master for 0.20.2 release
This commit is contained in:
WithoutPants 2023-04-17 15:13:41 +10:00 committed by GitHub
commit 22dc0bbf77
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
1011 changed files with 59046 additions and 30123 deletions

4
.gitignore vendored
View file

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

View file

@ -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 .

View file

@ -3,8 +3,11 @@ https://stashapp.cc
[![Build](https://github.com/stashapp/stash/actions/workflows/build.yml/badge.svg?branch=develop&event=push)](https://github.com/stashapp/stash/actions/workflows/build.yml)
[![Docker pulls](https://img.shields.io/docker/pulls/stashapp/stash.svg)](https://hub.docker.com/r/stashapp/stash 'DockerHub')
[![Open Collective backers](https://img.shields.io/opencollective/backers/stashapp?logo=opencollective)](https://opencollective.com/stashapp)
[![Go Report Card](https://goreportcard.com/badge/github.com/stashapp/stash)](https://goreportcard.com/report/github.com/stashapp/stash)
[![Discord](https://img.shields.io/discord/559159668438728723.svg?logo=discord)](https://discord.gg/2TsNFKt)
[![GitHub release (latest by date)](https://img.shields.io/github/v/release/stashapp/stash?logo=github)](https://github.com/stashapp/stash/releases/latest)
[![GitHub issues by-label](https://img.shields.io/github/issues-raw/stashapp/stash/bounty)](https://github.com/stashapp/stash/labels/bounty)
### **Stash is a self-hosted webapp written in Go which organizes and serves your porn.**
![demo image](docs/readme_assets/demo_image.png)
@ -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
[![Translate](https://translate.stashapp.cc/widgets/stash/-/stash-desktop-client/svg-badge.svg)](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

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

View file

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

View file

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

View file

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

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

@ -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=

View file

@ -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:

View file

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

View file

@ -0,0 +1,9 @@
fragment GalleryChapterData on GalleryChapter {
id
title
image_index
gallery {
id
}
}

View file

@ -22,6 +22,11 @@ fragment SlimGalleryData on Gallery {
thumbnail
}
}
chapters {
id
title
image_index
}
studio {
id
name

View file

@ -16,6 +16,9 @@ fragment GalleryData on Gallery {
...FolderData
}
chapters {
...GalleryChapterData
}
cover {
...SlimImageData
}

View file

@ -1,6 +1,8 @@
fragment SlimImageData on Image {
id
title
date
url
rating100
organized
o_counter

View file

@ -2,6 +2,8 @@ fragment ImageData on Image {
id
title
rating100
date
url
organized
o_counter
created_at

View file

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

View file

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

View file

@ -46,6 +46,9 @@ fragment SlimSceneData on Scene {
files {
path
}
folder {
path
}
title
}

View file

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

View file

@ -19,6 +19,7 @@ fragment StudioData on Studio {
scene_count
image_count
gallery_count
performer_count
movie_count
stash_ids {
stash_id

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

View file

@ -41,3 +41,7 @@ mutation MigrateHashNaming {
mutation BackupDatabase($input: BackupDatabaseInput!) {
backupDatabase(input: $input)
}
mutation AnonymiseDatabase($input: AnonymiseDatabaseInput!) {
anonymiseDatabase(input: $input)
}

View file

@ -0,0 +1,7 @@
mutation MigrateSceneScreenshots($input: MigrateSceneScreenshotsInput!) {
migrateSceneScreenshots(input: $input)
}
mutation MigrateBlobs($input: MigrateBlobsInput!) {
migrateBlobs(input: $input)
}

View file

@ -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
}
}

View file

@ -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!

View file

@ -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"""

View file

@ -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
}

View file

@ -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"""

View 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!]!
}

View file

@ -19,6 +19,7 @@ type Gallery {
files: [GalleryFile!]!
folder: Folder
chapters: [GalleryChapter!]!
scenes: [Scene!]!
studio: Studio
image_count: Int!

View file

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

View file

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

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

View file

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

View file

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

View file

@ -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")

View file

@ -4,7 +4,9 @@ type Version {
build_time: String!
}
type ShortVersion {
type LatestVersion {
version: String!
shorthash: String!
release_date: String!
url: String!
}

View file

@ -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
}

View file

@ -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]
}

View file

@ -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,

View file

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

View file

@ -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.

View file

@ -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
}

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

View file

@ -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 {

View file

@ -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
}

View file

@ -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) {

View file

@ -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) {

View file

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

View file

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

View file

@ -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 {

View file

@ -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
}

View file

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

View file

@ -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
}

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

View file

@ -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
}
}

View file

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

View file

@ -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 {

View file

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

View file

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

View file

@ -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") {

View file

@ -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(),
}
}

View file

@ -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
}

View file

@ -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
}

View file

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

View file

@ -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
}

View file

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

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

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

View file

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

View file

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

View file

@ -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 {

View file

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

View file

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

View file

@ -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")

View file

@ -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 {

View file

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

View file

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

View file

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

View file

@ -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
}

View file

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

View file

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

View file

@ -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 {

View file

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

View file

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

View file

@ -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,
},
},
}

View file

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

View file

@ -742,8 +742,8 @@ func Test_sceneRelationships_cover(t *testing.T) {
"error getting scene cover",
errSceneID,
&newDataEncoded,
nil,
true,
newData,
false,
},
{
"invalid data",

View file

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

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

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

View file

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

View file

@ -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 {

View file

@ -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
}

View file

@ -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()

View file

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