Compare commits

..

2 Commits

Author SHA1 Message Date
AdultSun
ac5dfdece2 Expands quickstart language based on DogmaDragon's suggestions 2024-03-11 15:02:14 -07:00
AdultSun
eb658834c3 Update StashDB details in README.md
- Directs users to new guide in the StashDB docs instead of Discord
- No longer necessary to join Discord/Matrix for new users of StashDB now that invite codes are multi-use
- Updates formatting of the same "Quickstart Guide" section a little
2024-03-10 17:32:46 -07:00
1372 changed files with 51079 additions and 136376 deletions

12
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1,12 @@
# These are supported funding model platforms
github: stashapp
# patreon: # Replace with a single Patreon username
open_collective: stashapp
# ko_fi: # Replace with a single Ko-fi username
# tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
# community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
# liberapay: StashApp
# issuehunt: # Replace with a single IssueHunt username
# otechie: # Replace with a single Otechie username
# custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']

40
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@@ -0,0 +1,40 @@
---
name: Bug report
about: Create a report to help us improve
title: "[Bug Report] Short Form Subject (50 Chars or less)"
labels: bug report
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem please ensure that your screenshots are SFW or at least appropriately censored.
**Stash Version: (from Settings -> About):**
**Desktop (please complete the following information):**
- OS: [e.g. iOS]
- Browser [e.g. chrome, safari]
- Version [e.g. 22]
**Smartphone (please complete the following information):**
- Device: [e.g. iPhone6]
- OS: [e.g. iOS8.1]
- Browser [e.g. stock browser, safari]
- Version [e.g. 22]
**Additional context**
Add any other context about the problem here.

View File

@@ -1,73 +0,0 @@
name: Bug Report
description: Create a report to help us fix the bug
labels: ["bug report"]
body:
- type: markdown
attributes:
value: Thanks for taking the time to fill out this bug report! Make sure to read [Contributing](https://github.com/stashapp/stash/blob/develop/docs/CONTRIBUTING.md) document before submitting.
- type: checkboxes
id: confirm-troubleshooting
attributes:
label: Have you enabled troubleshooting mode?
description: |
To ensure the bug is not caused by custom modifications or plugins make sure to enable troubleshooting mode before filing the report. In Stash go to Settings and click **Troubleshooting mode** and then retest to see if the bug still occurs.
It's important to note that troubleshooting mode only affects UI modifications and plugins.
options:
- label: I confirm that the troubleshooting mode is enabled.
required: true
- type: textarea
id: description
attributes:
label: Describe the bug
description: Provide a clear and concise description of what the bug is.
validations:
required: true
- type: textarea
id: reproduction
attributes:
label: Steps to reproduce
description: Detail the steps that would replicate this issue.
placeholder: |
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected behaviour
description: Provide clear and concise description of what you expected to happen.
validations:
required: true
- type: textarea
id: context
attributes:
label: Screenshots or additional context
description: Provide any additional context and SFW screenshots here to help us solve this issue.
validations:
required: false
- type: input
id: stashversion
attributes:
label: Stash version
description: This can be found in Settings > About.
placeholder: (e.g. v0.28.1)
validations:
required: true
- type: textarea
id: devicedetails
attributes:
label: Device details
description: |
Please provide details about the device you are using, including the operating system and browser (if applicable).
placeholder: Firefox 97 (64-bit) on Windows 11
validations:
required: false
- type: textarea
id: logs
attributes:
label: Relevant log output
description: Please copy and paste any relevant log output from Settings > Logs. This will be automatically formatted into code, so no need for backticks.
render: shell

View File

@@ -1,11 +0,0 @@
blank_issues_enabled: false
contact_links:
- name: Community forum
url: https://discourse.stashapp.cc
about: Start a discussion on the community forum.
- name: Community Discord
url: https://discord.gg/Y8MNsvQBvZ
about: Chat with the community on Discord.
- name: Documentation
url: https://docs.stashapp.cc
about: Check out documentation for help and information.

View File

@@ -0,0 +1,24 @@
---
name: Discussion / Request for Commentary [RFC]
about: This is for issues that will be discussed and won't necessarily result directly
in commits or pull requests.
title: "[RFC] Short Form Title"
labels: help wanted
assignees: ''
---
<!-- Update or delete the title if you need to delegate your title gore to something
# Title
*### Scope*
<!-- describe the scope of your topic and your goals ideally within a single paragraph or TL;DR kind of summary so its easier for people to determine if they can contribute at a glance. -->
## Long Form
<!-- Only required if your scope and titles can't cover everything. -->
## Examples
<!-- if you can show a picture or video examples post them here, please ensure that you respect people's time and attention and understand that people are volunteering their time, so concision is ideal and considerate. -->
## Reference Reading
<!-- if there is any reference reading or documentation, please refer to it here. -->

View File

@@ -0,0 +1,20 @@
---
name: Feature request
about: Suggest an idea for this project
title: "[Feature] Short Form Title (50 chars or less.)"
labels: feature request
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.

View File

@@ -1,47 +0,0 @@
name: Feature Request
description: Request a new feature or idea to be added to Stash
labels: ["feature request"]
body:
- type: markdown
attributes:
value: Thank you for taking the time to submit a feature request! Make sure to read [Contributing](https://github.com/stashapp/stash/blob/develop/docs/CONTRIBUTING.md) document before submitting.
- type: textarea
id: description
attributes:
label: Describe the feature you'd like
description: Provide a clear description of the feature you'd like implemented
validations:
required: true
- type: textarea
id: benefits
attributes:
label: Describe the benefits this would bring to existing users
description: |
Explain the measurable benefits this feature would achieve for existing users.
The benefits should be described in terms of outcomes for users, not specific implementations.
validations:
required: true
- type: textarea
id: already_possible
attributes:
label: Is there an existing way to achieve this goal?
description: |
Yes/No. If Yes, describe how your proposed feature differs from or improves upon the current method
validations:
required: true
- type: checkboxes
id: confirm-search
attributes:
label: Have you searched for an existing open/closed issue?
description: |
To help us keep these issues under control, please ensure you have first [searched our issue list](https://github.com/stashapp/stash/issues?q=is%3Aissue) for any existing issues that cover the core request or benefit of your proposal.
options:
- label: I have searched for existing issues and none cover the core request of my proposal
required: true
- type: textarea
id: context
attributes:
label: Additional context
description: Add any other context or screenshots about the feature request here.
validations:
required: false

18
.github/PULL_REQUEST_TEMPLATE/BugFix.md vendored Normal file
View File

@@ -0,0 +1,18 @@
---
name: Bug Fix
about: Add a bug fix this project!
title: "[Bug Fix] Short Form Title (50 chars or less.)"
labels: bug
assignees: 'WithoutPants, bnkai, Leopere'
---
<!-- Please make sure to read https://github.com/stashapp/stash/docs/CONTRIBUTING.md and check that you understand and have followed it as best as possible -->
<!-- Explain what your bugfix seeks to remedy in a short paragraph. -->
# Scope
<!-- Declare any issues by typing `fixes #1` or `closes #1` for example so that the automation can kick in when this is merged -->
## Closes/Fixes Issues
<!-- What have you tested specifically and what possible impacts/areas there are that may need retesting by others. -->
## Other testing QA Notes

View File

@@ -0,0 +1,17 @@
---
name: Feature Addition
about: Add a feature to this project!
title: "[Feature] Short Form Title (50 chars or less.)"
labels: enhancement
assignees: 'WithoutPants, bnkai, Leopere'
---
<!-- Please make sure to read https://github.com/stashapp/stash/docs/CONTRIBUTING.md and check that you understand and have followed it as best as possible
Explain what your feature does in a short paragraph. -->
# Scope
<!-- Declare any issues by typing `fixes #1` or `closes #1` for example so that the automation can kick in when this is merged -->
## Closes/Fixes Issues
<!-- What have you tested specifically and what possible impacts/areas there are that may need retesting by others. -->
## Other testing QA Notes

View File

@@ -1,39 +0,0 @@
<!-- Thank you for submitting a pull request! Make sure to follow the contributing guidelines and this template. -->
## Description
<!-- Please write a clear and concise description of what the pull request does. -->
## Related Issue
<!-- Please link the issue your pull request is referring to. -->
## Testing
<!-- Describe the testing steps you have performed. -->
## Screenshots
<!-- For visual changes, please add before and after screenshots. -->
## Checklist
<!-- Mark [x] to indicate completion. -->
- [ ] I have read and understood the [Contributing](https://github.com/stashapp/stash/blob/develop/docs/CONTRIBUTING.md) document.
- [ ] I have read and understood the [AI Usage Policy](https://github.com/stashapp/stash/blob/develop/docs/AI_POLICY.md) document.
- [ ] I have made corresponding changes to the documentation (if applicable).
## AI Usage Disclosure
<!-- Mark [x] to indicate completion. -->
- [ ] I have used AI tools to assist with this pull request, and I have disclosed the tools and how I used them below.
<!-- If you used AI to assist with this pull request, please disclose what tools you used and how you used them. -->
## Additional Context
<!-- Add any other context about the pull request here. -->

View File

@@ -1,28 +0,0 @@
name: Compiler Build
on:
workflow_dispatch:
env:
COMPILER_IMAGE: ghcr.io/stashapp/compiler:14
jobs:
build-compiler:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v6
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v6
with:
push: true
context: "{{defaultContext}}:docker/compiler"
tags: |
${{ env.COMPILER_IMAGE }}
ghcr.io/stashapp/compiler:latest
cache-from: type=gha,scope=all,mode=max
cache-to: type=gha,scope=all,mode=max

View File

@@ -2,10 +2,7 @@ name: Build
on:
push:
branches:
- develop
- master
- 'releases/**'
branches: [ develop, master ]
pull_request:
release:
types: [ published ]
@@ -15,175 +12,45 @@ concurrency:
cancel-in-progress: true
env:
COMPILER_IMAGE: ghcr.io/stashapp/compiler:14
COMPILER_IMAGE: stashapp/compiler:8
jobs:
# Job 1: Generate code and build UI
# Runs natively (no Docker) — go generate/gqlgen and node don't need cross-compilers.
# Produces artifacts (generated Go files + UI build) consumed by test and build jobs.
generate:
runs-on: ubuntu-24.04
build:
runs-on: ubuntu-20.04
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v2
- name: Checkout
run: git fetch --prune --unshallow --tags
- name: Pull compiler image
run: docker pull $COMPILER_IMAGE
- name: Cache node modules
uses: actions/cache@v3
env:
cache-name: cache-node_modules
with:
fetch-depth: 0
fetch-tags: true
- name: Setup Go
uses: actions/setup-go@v6
with:
go-version-file: 'go.mod'
# pnpm version is read from the packageManager field in package.json
# very broken (4.3, 4.4)
- name: Install pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061
with:
package_json_file: ui/v2.5/package.json
# ensure pnpm store path exists to fix post setup node.js error
# https://github.com/actions/setup-node/issues/1137#issuecomment-2508963254
- name: Ensure pnpm store path exists
run: |
PNPM_STORE_PATH="$( pnpm store path --silent )"
if [ ! -d "$PNPM_STORE_PATH" ]; then
echo "PNPM store directory does not exist, creating it."
mkdir -p "$PNPM_STORE_PATH"
fi
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: '20'
cache: 'pnpm'
cache-dependency-path: ui/v2.5/pnpm-lock.yaml
- name: Install UI dependencies
run: make pre-ui
- name: Generate
run: make generate
path: ui/v2.5/node_modules
key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('ui/v2.5/yarn.lock') }}
- name: Cache UI build
uses: actions/cache@v5
uses: actions/cache@v3
id: cache-ui
env:
cache-name: cache-ui
with:
path: ui/v2.5/build
key: ${{ runner.os }}-ui-build-${{ hashFiles('ui/v2.5/pnpm-lock.yaml', 'ui/v2.5/public/**', 'ui/v2.5/src/**', 'graphql/**/*.graphql') }}
key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('ui/v2.5/yarn.lock', 'ui/v2.5/public/**', 'ui/v2.5/src/**', 'graphql/**/*.graphql') }}
- name: Validate UI
# skip UI validation for pull requests if UI is unchanged
if: ${{ github.event_name != 'pull_request' || steps.cache-ui.outputs.cache-hit != 'true' }}
run: make validate-ui
- name: Build UI
# skip UI build for pull requests if UI is unchanged (UI was cached)
if: ${{ github.event_name != 'pull_request' || steps.cache-ui.outputs.cache-hit != 'true' }}
run: make ui
# Bundle generated Go files + UI build for downstream jobs (test + build)
- name: Upload generated artifacts
uses: actions/upload-artifact@v7
with:
name: generated
retention-days: 1
path: |
internal/api/generated_exec.go
internal/api/generated_models.go
ui/v2.5/build/
ui/login/locales/
# Job 2: Integration tests
# Runs natively (no Docker) — only needs Go + GCC (for CGO/SQLite), both on ubuntu-22.04.
# Runs in parallel with the build matrix jobs.
test:
needs: generate
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v6
- name: Setup Go
uses: actions/setup-go@v6
with:
go-version-file: 'go.mod'
# Places generated Go files + UI build into the working tree so the build compiles
- name: Download generated artifacts
uses: actions/download-artifact@v8
with:
name: generated
- name: Test Backend
run: make it
# Job 3: Cross-compile for all platforms
# Each platform gets its own runner and Docker container (ghcr.io/stashapp/compiler:13).
# Each build-cc-* make target is self-contained (sets its own GOOS/GOARCH/CC),
# so running them in separate containers is functionally identical to one container.
# Runs in parallel with the test job.
build:
needs: generate
runs-on: ubuntu-24.04
strategy:
fail-fast: false
matrix:
include:
- platform: windows
make-target: build-cc-windows
artifact-paths: |
dist/stash-win.exe
tag: win
- platform: macos
make-target: build-cc-macos
artifact-paths: |
dist/stash-macos
dist/Stash.app.zip
tag: osx
- platform: linux
make-target: build-cc-linux
artifact-paths: |
dist/stash-linux
tag: linux
- platform: linux-arm64v8
make-target: build-cc-linux-arm64v8
artifact-paths: |
dist/stash-linux-arm64v8
tag: arm
- platform: linux-arm32v7
make-target: build-cc-linux-arm32v7
artifact-paths: |
dist/stash-linux-arm32v7
tag: arm
- platform: linux-arm32v6
make-target: build-cc-linux-arm32v6
artifact-paths: |
dist/stash-linux-arm32v6
tag: arm
- platform: freebsd
make-target: build-cc-freebsd
artifact-paths: |
dist/stash-freebsd
tag: freebsd
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
fetch-tags: true
- name: Download generated artifacts
uses: actions/download-artifact@v8
with:
name: generated
- name: Cache Go build
uses: actions/cache@v5
- name: Cache go build
uses: actions/cache@v3
env:
# increment the number suffix to bump the cache
cache-name: cache-go-cache-1
with:
path: .go-cache
key: ${{ runner.os }}-go-cache-${{ matrix.platform }}-${{ hashFiles('go.mod', '**/go.sum') }}
# kept separate to test timings
- name: pull compiler image
run: docker pull $COMPILER_IMAGE
key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('go.mod', '**/go.sum') }}
- name: Start build container
env:
@@ -192,61 +59,53 @@ jobs:
mkdir -p .go-cache
docker run -d --name build --mount type=bind,source="$(pwd)",target=/stash,consistency=delegated --mount type=bind,source="$(pwd)/.go-cache",target=/root/.cache/go-build,consistency=delegated --env OFFICIAL_BUILD=${{ env.official-build }} -w /stash $COMPILER_IMAGE tail -f /dev/null
- name: Build (${{ matrix.platform }})
run: docker exec -t build /bin/bash -c "make ${{ matrix.make-target }}"
- name: Pre-install
run: docker exec -t build /bin/bash -c "make pre-ui"
- name: Generate
run: docker exec -t build /bin/bash -c "make generate"
- name: Validate UI
# skip UI validation for pull requests if UI is unchanged
if: ${{ github.event_name != 'pull_request' || steps.cache-ui.outputs.cache-hit != 'true' }}
run: docker exec -t build /bin/bash -c "make validate-ui"
# Static validation happens in the linter workflow in parallel to this workflow
# Run Dynamic validation here, to make sure we pass all the projects integration tests
- name: Test Backend
run: docker exec -t build /bin/bash -c "make it"
- name: Build UI
# skip UI build for pull requests if UI is unchanged (UI was cached)
# this means that the build version/time may be incorrect if the UI is
# not changed in a pull request
if: ${{ github.event_name != 'pull_request' || steps.cache-ui.outputs.cache-hit != 'true' }}
run: docker exec -t build /bin/bash -c "make ui"
- name: Compile for all supported platforms
run: |
docker exec -t build /bin/bash -c "make build-cc-windows"
docker exec -t build /bin/bash -c "make build-cc-macos"
docker exec -t build /bin/bash -c "make build-cc-linux"
docker exec -t build /bin/bash -c "make build-cc-linux-arm64v8"
docker exec -t build /bin/bash -c "make build-cc-linux-arm32v7"
docker exec -t build /bin/bash -c "make build-cc-linux-arm32v6"
docker exec -t build /bin/bash -c "make build-cc-freebsd"
- name: Cleanup build container
run: docker rm -f -v build
- name: Upload build artifact
uses: actions/upload-artifact@v7
with:
name: build-${{ matrix.platform }}
retention-days: 1
path: ${{ matrix.artifact-paths }}
# Job 4: Release
# Waits for both test and build to pass, then collects all platform artifacts
# into dist/ for checksums, GitHub releases, and multi-arch Docker push.
release:
needs: [test, build]
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
fetch-tags: true
# Downloads all artifacts (generated + 7 platform builds) into artifacts/ subdirectories
- name: Download all build artifacts
uses: actions/download-artifact@v8
with:
path: artifacts
# Reassemble platform binaries from matrix job artifacts into a single dist/ directory
# make sure that artifacts have executable bit set
# upload-artifact@v4 strips the common path prefix (dist/), so files are at the artifact root
- name: Collect binaries
run: |
mkdir -p dist
cp artifacts/build-*/* dist/
chmod +x dist/*
- name: Zip UI
run: |
cd artifacts/generated/ui/v2.5/build && zip -r ../../../../../dist/stash-ui.zip .
- name: Generate checksums
run: |
git describe --tags --exclude latest_develop | tee CHECKSUMS_SHA1
sha1sum dist/Stash.app.zip dist/stash-* dist/stash-ui.zip | sed 's/dist\///g' | tee -a CHECKSUMS_SHA1
sha1sum dist/Stash.app.zip dist/stash-* | sed 's/dist\///g' | tee -a CHECKSUMS_SHA1
echo "STASH_VERSION=$(git describe --tags --exclude latest_develop)" >> $GITHUB_ENV
echo "RELEASE_DATE=$(date +'%Y-%m-%d %H:%M:%S %Z')" >> $GITHUB_ENV
- name: Upload Windows binary
# only upload binaries for pull requests
if: ${{ github.event_name == 'pull_request' && github.base_ref != 'refs/heads/develop' && github.base_ref != 'refs/heads/master'}}
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v2
with:
name: stash-win.exe
path: dist/stash-win.exe
@@ -254,38 +113,22 @@ jobs:
- name: Upload macOS binary
# only upload binaries for pull requests
if: ${{ github.event_name == 'pull_request' && github.base_ref != 'refs/heads/develop' && github.base_ref != 'refs/heads/master'}}
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v2
with:
name: stash-macos
path: dist/stash-macos
- name: Upload macOS bundle
# only upload binaries for pull requests
if: ${{ github.event_name == 'pull_request' && github.base_ref != 'refs/heads/develop' && github.base_ref != 'refs/heads/master'}}
uses: actions/upload-artifact@v7
with:
name: Stash.app.zip
path: dist/Stash.app.zip
- name: Upload Linux binary
# only upload binaries for pull requests
if: ${{ github.event_name == 'pull_request' && github.base_ref != 'refs/heads/develop' && github.base_ref != 'refs/heads/master'}}
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v2
with:
name: stash-linux
path: dist/stash-linux
- name: Upload UI
# only upload for pull requests
if: ${{ github.event_name == 'pull_request' && github.base_ref != 'refs/heads/develop' && github.base_ref != 'refs/heads/master'}}
uses: actions/upload-artifact@v7
with:
name: stash-ui.zip
path: dist/stash-ui.zip
- name: Update latest_develop tag
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/develop' }}
run: git tag -f latest_develop; git push -f --tags
run : git tag -f latest_develop; git push -f --tags
- name: Development Release
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/develop' }}
@@ -304,7 +147,6 @@ jobs:
dist/stash-linux-arm32v7
dist/stash-linux-arm32v6
dist/stash-freebsd
dist/stash-ui.zip
CHECKSUMS_SHA1
- name: Master release
@@ -324,7 +166,6 @@ jobs:
dist/stash-linux-arm32v7
dist/stash-linux-arm32v6
dist/stash-freebsd
dist/stash-ui.zip
CHECKSUMS_SHA1
gzip: false
@@ -335,7 +176,7 @@ jobs:
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
run: |
docker run --rm --privileged tonistiigi/binfmt
docker run --rm --privileged docker/binfmt:a7996909642ee92942dcd6cff44b9b95f08dad64
docker info
docker buildx create --name builder --use
docker buildx inspect --bootstrap
@@ -351,7 +192,7 @@ jobs:
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
run: |
docker run --rm --privileged tonistiigi/binfmt
docker run --rm --privileged docker/binfmt:a7996909642ee92942dcd6cff44b9b95f08dad64
docker info
docker buildx create --name builder --use
docker buildx inspect --bootstrap

View File

@@ -6,27 +6,62 @@ on:
branches:
- master
- develop
- 'releases/**'
pull_request:
env:
COMPILER_IMAGE: stashapp/compiler:8
jobs:
golangci:
name: lint
runs-on: ubuntu-latest
steps:
# no tags or depth needed for lint
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version-file: 'go.mod'
- uses: actions/checkout@v2
- name: Checkout
run: git fetch --prune --unshallow --tags
- name: Pull compiler image
run: docker pull $COMPILER_IMAGE
- name: Start build container
run: |
mkdir -p .go-cache
docker run -d --name build --mount type=bind,source="$(pwd)",target=/stash,consistency=delegated --mount type=bind,source="$(pwd)/.go-cache",target=/root/.cache/go-build,consistency=delegated -w /stash $COMPILER_IMAGE tail -f /dev/null
# generate-backend runs natively (just go generate + touch-ui) — no Docker needed
- name: Generate Backend
run: make generate-backend
run: docker exec -t build /bin/bash -c "make generate-backend"
## WARN
## using v1, update in a later PR
- name: Run golangci-lint
uses: golangci/golangci-lint-action@v8
uses: golangci/golangci-lint-action@v3
with:
version: v2.11.4
# Optional: version of golangci-lint to use in form of v1.2 or v1.2.3 or `latest` to use the latest version
version: latest
# Optional: working directory, useful for monorepos
# working-directory: somedir
# Optional: golangci-lint command line arguments.
#
# Note: By default, the `.golangci.yml` file should be at the root of the repository.
# The location of the configuration file can be changed by using `--config=`
args: --timeout=5m
# Optional: show only new issues if it's a pull request. The default value is `false`.
# only-new-issues: true
# Optional: if set to true, then all caching functionality will be completely disabled,
# takes precedence over all other caching options.
# skip-cache: true
# Optional: if set to true, then the action won't cache or restore ~/go/pkg.
# skip-pkg-cache: true
# Optional: if set to true, then the action won't cache or restore ~/.cache/go-build.
# skip-build-cache: true
# Optional: The mode to install golangci-lint. It can be 'binary' or 'goinstall'.
# install-mode: "goinstall"
- name: Cleanup build container
run: docker rm -f -v build

5
.gitignore vendored
View File

@@ -21,9 +21,6 @@ vendor
# GraphQL generated output
internal/api/generated_*.go
# Generated locale files
ui/login/locales/*
####
# Visual Studio
####
@@ -63,4 +60,4 @@ node_modules
/phasher
dist
.DS_Store
/.local*
/.local*

View File

@@ -1,100 +1,89 @@
version: "2"
# options for analysis running
run:
timeout: 5m
linters:
default: none
disable-all: true
enable:
- copyloopvar
- dogsled
# Default set of linters from golangci-lint
- errcheck
- errchkjson
- errorlint
- gocritic
- gosimple
- govet
- ineffassign
- staticcheck
- typecheck
- unused
# Linters added by the stash project.
# - contextcheck
- dogsled
- errchkjson
- errorlint
# - exhaustive
- exportloopref
- gocritic
# - goerr113
- gofmt
# - gomnd
# - ifshort
- misspell
# TODO - fix these in a later PR
# - noctx
# - nakedret
- noctx
- revive
- rowserrcheck
- sqlclosecheck
- staticcheck
- unused
settings:
staticcheck:
checks:
- all
# we specify (unnecessary) embedded fields for clarity in many places
- -QF1008
# there's lots of misnamed (eg intId instead of intID) fields in the code.
# it's not exactly world-ending, so I'm deferring fixing these for now
- -ST1003
errorlint:
errorf: false
asserts: true
comparison: true
revive:
confidence: 0.8
severity: error
rules:
- name: blank-imports
disabled: true
- name: context-as-argument
- name: context-keys-type
- name: dot-imports
- name: error-return
- name: error-strings
- name: error-naming
- name: exported
disabled: true
- name: if-return
disabled: true
- name: increment-decrement
- name: var-naming
disabled: true
- name: var-declaration
- name: package-comments
- name: range
- name: receiver-naming
- name: time-naming
- name: unexported-return
disabled: true
- name: indent-error-flow
disabled: true
- name: errorf
- name: empty-block
disabled: true
- name: superfluous-else
- name: unused-parameter
disabled: true
- name: unreachable-code
- name: redefines-builtin-id
rowserrcheck:
packages:
- github.com/jmoiron/sqlx
exclusions:
generated: lax
presets:
- comments
- common-false-positives
- legacy
- std-error-handling
paths:
- third_party$
- builtin$
- examples$
formatters:
enable:
- gofmt
settings:
gofmt:
simplify: false
exclusions:
generated: lax
paths:
- third_party$
- builtin$
- examples$
# Project-specific linter overrides
linters-settings:
gofmt:
simplify: false
errorlint:
# Disable errorf because there are false positives, where you don't want to wrap
# an error.
errorf: false
asserts: true
comparison: true
revive:
ignore-generated-header: true
severity: error
confidence: 0.8
error-code: 1
warning-code: 1
rules:
- name: blank-imports
disabled: true
- name: context-as-argument
- name: context-keys-type
- name: dot-imports
- name: error-return
- name: error-strings
- name: error-naming
- name: exported
disabled: true
- name: if-return
disabled: true
- name: increment-decrement
- name: var-naming
disabled: true
- name: var-declaration
- name: package-comments
- name: range
- name: receiver-naming
- name: time-naming
- name: unexported-return
disabled: true
- name: indent-error-flow
disabled: true
- name: errorf
- name: empty-block
disabled: true
- name: superfluous-else
- name: unused-parameter
disabled: true
- name: unreachable-code
- name: redefines-builtin-id
rowserrcheck:
packages:
- github.com/jmoiron/sqlx

View File

@@ -1,12 +1,14 @@
model:
package: graphql
filename: ./pkg/stashbox/graphql/generated_models.go
filename: ./pkg/scraper/stashbox/graphql/generated_models.go
client:
package: graphql
filename: ./pkg/stashbox/graphql/generated_client.go
filename: ./pkg/scraper/stashbox/graphql/generated_client.go
models:
Date:
model: github.com/99designs/gqlgen/graphql.String
SceneDraftInput:
model: github.com/stashapp/stash/pkg/scraper/stashbox/graphql.SceneDraftInput
endpoint:
# This points to stashdb.org currently, but can be directed at any stash-box
# instance. It is used for generation only.

3
.idea/go.iml generated
View File

@@ -1,6 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="Go" enabled="true" />
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/certs" />
@@ -11,4 +10,4 @@
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>
</module>

103
Makefile
View File

@@ -10,12 +10,10 @@ ifdef IS_WIN_SHELL
RM := del /s /q
RMDIR := rmdir /s /q
NOOP := @@
PREFIX := $(USERPROFILE)\\bin
else
RM := rm -f
RMDIR := rm -rf
NOOP := @:
PREFIX := $(HOME)/.local
endif
# set LDFLAGS environment variable to any extra ldflags required
@@ -42,6 +40,9 @@ GO_BUILD_FLAGS := $(GO_BUILD_FLAGS)
GO_BUILD_TAGS := $(GO_BUILD_TAGS)
GO_BUILD_TAGS += sqlite_stat4 sqlite_math_functions
# set STASH_NOLEGACY environment variable or uncomment to disable legacy browser support
# STASH_NOLEGACY := true
# set STASH_SOURCEMAPS environment variable or uncomment to enable UI sourcemaps
# STASH_SOURCEMAPS := true
@@ -49,17 +50,11 @@ export CGO_ENABLED := 1
# define COMPILER_IMAGE for cross-compilation docker container
ifndef COMPILER_IMAGE
COMPILER_IMAGE := ghcr.io/stashapp/compiler:latest
COMPILER_IMAGE := stashapp/compiler:latest
endif
# cannot really parallelise the release target
# generate requires pre-ui, ui requires generate and build-release requires ui, so they must be run sequentially
.PHONY: release
release:
$(MAKE) pre-ui
$(MAKE) generate
$(MAKE) ui
$(MAKE) build-release
release: pre-ui generate ui build-release
# targets to set various build flags
# use combinations on the make command-line to configure a build, e.g.:
@@ -103,7 +98,7 @@ flags-static-windows:
.PHONY: build-info
build-info:
ifndef BUILD_DATE
$(eval BUILD_DATE := $(shell GOOS=$$(go env GOHOSTOS) GOARCH=$$(go env GOHOSTARCH) go run scripts/getDate.go))
$(eval BUILD_DATE := $(shell go run scripts/getDate.go))
endif
ifndef GITHASH
$(eval GITHASH := $(shell git rev-parse --short HEAD))
@@ -134,7 +129,7 @@ phasher: build-flags
# builds dynamically-linked debug binaries
.PHONY: build
build: stash
build: stash phasher
# builds dynamically-linked PIE release binaries
.PHONY: build-release
@@ -192,6 +187,8 @@ build-cc-macos:
# Combine into universal binaries
lipo -create -output dist/stash-macos dist/stash-macos-intel dist/stash-macos-arm
rm dist/stash-macos-intel dist/stash-macos-arm
lipo -create -output dist/phasher-macos dist/phasher-macos-intel dist/phasher-macos-arm
rm dist/phasher-macos-intel dist/phasher-macos-arm
# Place into bundle and zip up
rm -rf dist/Stash.app
@@ -201,16 +198,6 @@ build-cc-macos:
cd dist && rm -f Stash.app.zip && zip -r Stash.app.zip Stash.app
rm -rf dist/Stash.app
.PHONY: build-cc-macos-phasher
build-cc-macos-phasher:
make build-cc-macos-arm
make build-cc-macos-intel
# Combine into universal binaries
lipo -create -output dist/phasher-macos dist/phasher-macos-intel dist/phasher-macos-arm
rm dist/phasher-macos-intel dist/phasher-macos-arm
# do not bundle phasher
.PHONY: build-cc-freebsd
build-cc-freebsd: export GOOS := freebsd
build-cc-freebsd: export GOARCH := amd64
@@ -288,16 +275,12 @@ generate: generate-backend generate-ui
.PHONY: generate-ui
generate-ui:
cd ui/v2.5 && pnpm run gqlgen
cd ui/v2.5 && yarn run gqlgen
.PHONY: generate-backend
generate-backend: touch-ui
go generate ./cmd/stash
.PHONY: generate-login-locale
generate-login-locale:
go generate ./ui
.PHONY: generate-dataloaders
generate-dataloaders:
go generate ./internal/api/loaders
@@ -324,8 +307,7 @@ test:
# runs all tests - including integration tests
.PHONY: it
it:
$(eval GO_BUILD_TAGS += integration)
go test -tags "$(GO_BUILD_TAGS)" ./...
go test -tags=integration ./...
# generates test mocks
.PHONY: generate-test-mocks
@@ -351,71 +333,38 @@ server-clean:
# installs UI dependencies. Run when first cloning repository, or if UI
# dependencies have changed
# If CI is set, configures pnpm to use a local store to avoid
# putting .pnpm-store in /stash
# NOTE: to run in the docker build container, using the existing
# node_modules folder, rename the .modules.yaml to .modules.yaml.bak
# and a new one will be generated. This will need to be reversed after
# building.
.PHONY: pre-ui
pre-ui:
ifdef CI
cd ui/v2.5 && pnpm config set store-dir ~/.pnpm-store && pnpm install --frozen-lockfile
else
cd ui/v2.5 && pnpm install --frozen-lockfile
endif
cd ui/v2.5 && yarn install --frozen-lockfile
.PHONY: ui-env
ui-env: build-info
$(eval export VITE_APP_DATE := $(BUILD_DATE))
$(eval export VITE_APP_GITHASH := $(GITHASH))
$(eval export VITE_APP_STASH_VERSION := $(STASH_VERSION))
ifdef STASH_NOLEGACY
$(eval export VITE_APP_NOLEGACY := true)
endif
ifdef STASH_SOURCEMAPS
$(eval export VITE_APP_SOURCEMAPS := true)
endif
.PHONY: ui
ui: ui-only generate-login-locale
.PHONY: ui-only
ui-only: ui-env
cd ui/v2.5 && pnpm run build
.PHONY: zip-ui
zip-ui:
rm -f dist/stash-ui.zip
cd ui/v2.5/build && zip -r ../../../dist/stash-ui.zip .
ui: ui-env
cd ui/v2.5 && yarn build
.PHONY: ui-start
ui-start: ui-env
cd ui/v2.5 && pnpm run start --host
cd ui/v2.5 && yarn start --host
.PHONY: fmt-ui
fmt-ui:
cd ui/v2.5 && pnpm run format
cd ui/v2.5 && yarn format
# runs all of the frontend PR-acceptance steps
.PHONY: validate-ui
validate-ui:
cd ui/v2.5 && pnpm run validate
# these targets run the same steps as fmt-ui and validate-ui, but only on files that have changed
fmt-ui-quick:
cd ui/v2.5 && \
files=$$(git diff --name-only --relative --diff-filter d . ../../graphql); \
if [ -n "$$files" ]; then \
pnpm exec biome format --write $$files; \
fi
# does not run tsc checks, as they are slow
validate-ui-quick:
cd ui/v2.5 && \
tsfiles=$$(git diff --name-only --relative --diff-filter d src | grep -e "\.tsx\?\$$"); \
scssfiles=$$(git diff --name-only --relative --diff-filter d src | grep "\.scss"); \
prettyfiles=$$(git diff --name-only --relative --diff-filter d . ../../graphql); \
if [ -n "$$tsfiles" ]; then pnpm exec biome check $$tsfiles; fi && \
if [ -n "$$scssfiles" ]; then pnpm exec stylelint $$scssfiles; fi && \
if [ -n "$$prettyfiles" ]; then pnpm exec biome format $$prettyfiles; fi
cd ui/v2.5 && yarn run validate
# runs all of the backend PR-acceptance steps
.PHONY: validate-backend
@@ -446,14 +395,4 @@ start-compiler-container:
.PHONY: remove-compiler-container
remove-compiler-container:
docker rm -f -v build
.PHONY: install
install:
ifdef IS_WIN_SHELL
@if not exist "$(PREFIX)" mkdir $(PREFIX)
@copy "dist\\stash-win.exe" "$(PREFIX)\\stash-win.exe"
else
@mkdir -p $(PREFIX)/bin
@install -m 755 $(STASH_OUTPUT) $(PREFIX)/bin/stash
endif
docker rm -f -v build

117
README.md
View File

@@ -1,119 +1,86 @@
# Stash
[![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?logo=docker)](https://hub.docker.com/r/stashapp/stash 'DockerHub')
[![Docker pulls](https://img.shields.io/docker/pulls/stashapp/stash.svg)](https://hub.docker.com/r/stashapp/stash 'DockerHub')
[![GitHub Sponsors](https://img.shields.io/github/sponsors/stashapp?logo=github)](https://github.com/sponsors/stashapp)
[![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)
[![Matrix](https://img.shields.io/matrix/stashapp:unredacted.org?logo=matrix&server_fqdn=matrix.org)](https://matrix.to/#/#stashapp:unredacted.org)
[![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)
[![Codeberg Translate](https://img.shields.io/weblate/progress/stash?server=https%3A%2F%2Ftranslate.codeberg.org&logo=weblate)](https://translate.codeberg.org/engage/stash/)
[![GitHub issues by-label](https://img.shields.io/github/issues-raw/stashapp/stash/bounty?logo=github)](https://github.com/stashapp/stash/labels/bounty)
[![GitHub issues by-label](https://img.shields.io/github/issues-raw/stashapp/stash/bounty)](https://github.com/stashapp/stash/labels/bounty)
<h3>Stash is a self-hosted webapp written in Go which organizes and serves your diverse content collection, catering to both your SFW and NSFW needs.</h3>
### **Stash is a self-hosted webapp written in Go which organizes and serves your porn.**
![demo image](docs/readme_assets/demo_image.png)
![Screenshot of Stash web application interface](docs/readme_assets/demo_image.png)
- Stash gathers information about videos in your collection from the internet, and is extensible through the use of community-built plugins for a large number of content producers and sites.
- Stash supports a wide variety of both video and image formats.
- You can tag videos and find them later.
- Stash provides statistics about performers, tags, studios and more.
* Stash gathers information about videos in your collection from the internet, and is extensible through the use of community-built plugins for a large number of content producers and sites.
* Stash supports a wide variety of both video and image formats.
* You can tag videos and find them later.
* Stash provides statistics about performers, tags, studios and more.
You can [watch a SFW demo video](https://vimeo.com/545323354) to see it in action.
For further information see [Support & Resources](#support--resources) section.
For further information you can consult the [documentation](https://docs.stashapp.cc) or [read the in-app manual](ui/v2.5/src/docs/en).
## Installing Stash
> [!tip]
Step-by-step instructions are available at [docs.stashapp.cc/installation](https://docs.stashapp.cc/installation/).
> [!important]
> **Windows Users**
>
> As of version 0.27.0, Stash no longer supports _Windows 7, 8, Server 2008 and Server 2012._
> At least Windows 10 or Server 2016 is required.
>
> **macOS Users**
>
> As of version 0.29.0, Stash requires _macOS 11 Big Sur_ or later.
> As of version 0.32.0, Stash requires _macOS 12 Monterey_ or later.
> Older versions can still be run through Docker.
# Installing Stash
<img src="docs/readme_assets/windows_logo.svg" width="100%" height="75"> Windows | <img src="docs/readme_assets/mac_logo.svg" width="100%" height="75"> macOS | <img src="docs/readme_assets/linux_logo.svg" width="100%" height="75"> Linux | <img src="docs/readme_assets/docker_logo.svg" width="100%" height="75"> Docker
:---:|:---:|:---:|:---:
[Latest Release](https://github.com/stashapp/stash/releases/latest/download/stash-win.exe) <br /> <sup><sub>[Development Preview](https://github.com/stashapp/stash/releases/download/latest_develop/stash-win.exe)</sub></sup> | [Latest Release](https://github.com/stashapp/stash/releases/latest/download/Stash.app.zip) <br /> <sup><sub>[Development Preview](https://github.com/stashapp/stash/releases/download/latest_develop/Stash.app.zip)</sub></sup> | [Latest Release (amd64)](https://github.com/stashapp/stash/releases/latest/download/stash-linux) <br /> <sup><sub>[Development Preview (amd64)](https://github.com/stashapp/stash/releases/download/latest_develop/stash-linux)</sub></sup> <br /> [More Architectures...](https://github.com/stashapp/stash/releases/latest) | [Instructions](docker/production/README.md) <br /> <sup><sub>[Sample docker-compose.yml](docker/production/docker-compose.yml)</sub></sup>
Download links for other platforms and architectures are available on the [Releases](https://github.com/stashapp/stash/releases) page.
Download links for other platforms and architectures are available on the [Releases page](https://github.com/stashapp/stash/releases).
### First Run
## First Run
#### Windows/macOS Users: Security Prompt
On Windows or macOS, running the app might present a security prompt since the application binary isn't yet signed.
On Windows or macOS, running the app might present a security prompt since the binary isn't yet signed.
- On Windows, bypass this by clicking "more info" and then the "run anyway" button.
- On macOS, Control+Click the app, click "Open", and then "Open" again.
On Windows, bypass this by clicking "more info" and then the "run anyway" button. On macOS, Control+Click the app, click "Open", and then "Open" again.
#### ffmpeg
#### FFmpeg
Stash requires FFmpeg. If you don't have it installed, Stash will download a copy for you. It is recommended that Linux users install `ffmpeg` from their distro's package manager.
Stash requires FFmpeg. If you don't have it installed, Stash will prompt you to download a copy during setup. It is recommended that Linux users install `ffmpeg` from their distro's package manager.
# Usage
## Usage
### Quickstart Guide
Stash is a web-based application. Once the application is running, the interface is available (by default) from `http://localhost:9999`.
## Quickstart Guide
Stash is a web-based application. Once the application is running, the interface is available (by default) from http://localhost:9999.
On first run, Stash will prompt you for some configuration options and media directories to index, called "Scanning" in Stash. After scanning, your media will be available for browsing, curating, editing, and tagging.
Stash can pull metadata (performers, tags, descriptions, studios, and more) directly from many sites through the use of [scrapers](https://github.com/stashapp/stash/blob/develop/ui/v2.5/src/docs/en/Manual/Scraping.md), which integrate directly into Stash. Identifying an entire collection will typically require a mix of multiple sources:
- The stashapp team maintains [StashDB](https://stashdb.org/), a crowd-sourced repository of scene, studio, and performer information. Connecting it to Stash will allow you to automatically identify much of a typical media collection. It runs on our stash-box software and is primarily focused on mainstream digital scenes and studios. Instructions, invite codes, and more can be found in this guide to [Accessing StashDB](https://guidelines.stashdb.org/docs/faq_getting-started/stashdb/accessing-stashdb/).
- Several community-managed stash-box databases can also be connected to Stash in a similar manner. Each one serves a slightly different niche and follows their own methodology. A rundown of each stash-box, their differences, and the information you need to sign up can be found in the [Metadata Sources](https://docs.stashapp.cc/metadata-sources/stash-box-instances/) section of the documentation.
- Many community-maintained scrapers can also be downloaded, installed, and updated from within Stash, allowing you to pull data from a wide range of other websites and databases. They can be found by navigating to `Settings Metadata Providers Available Scrapers Community (stable)`. These can be trickier to use than a stash-box because every scraper works a little differently. For more information, please visit the [CommunityScrapers repository](https://github.com/stashapp/CommunityScrapers).
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. Identifying an entire collection will typically require a mix of multiple sources:
- The project maintains [StashDB](https://stashdb.org/), a crowd-sourced repository of scene, studio, and performer information. Connecting it to Stash will allow you to automatically identify much of a typical media collection. It runs on our stash-box software and is primarily focused on mainstream digital scenes and studios. Instructions, invite codes, and more can be found in this guide to [Accessing StashDB](https://guidelines.stashdb.org/docs/faq_getting-started/stashdb/accessing-stashdb/).
- Several community-managed stash-box databases can also be connected to Stash in a similar manner. Each one serves a slightly different niche and follows their own methodology. A rundown of each stash-box, their differences, and the information you need to sign up can be found in this guide to [Accessing Stash-Boxes](https://guidelines.stashdb.org/docs/faq_getting-started/stashdb/accessing-stash-boxes/).
- Many community-maintained scrapers can also be downloaded, installed, and updated from within Stash, allowing you to pull data from a wide range of other websites and databases. They can be found by navigating to Settings -> Metadata Providers -> Available Scrapers -> Community (stable). These can be trickier to use than a stash-box because every scraper works a little differently. For more information, please visit the [CommunityScrapers repository](https://github.com/stashapp/CommunityScrapers).
- All of the above methods of scraping data into Stash are also covered in more detail in our [Guide to Scraping](https://docs.stashapp.cc/beginner-guides/guide-to-scraping/).
<sub>[StashDB](http://stashdb.org) is the canonical instance of our open source metadata API, [stash-box](https://github.com/stashapp/stash-box).</sub>
## Support & Resources
# Translation
[![Translate](https://hosted.weblate.org/widget/stashapp/stash/svg-badge.svg)](https://hosted.weblate.org/engage/stashapp/)
🇧🇷 🇨🇳 🇩🇰 🇳🇱 🇬🇧 🇪🇪 🇫🇮 🇫🇷 🇩🇪 🇮🇹 🇯🇵 🇰🇷 🇵🇱 🇷🇺 🇪🇸 🇸🇪 🇹🇼 🇹🇷
Need help or want to get involved? Start with the documentation, then reach out to the community if you need further assistance.
Stash is available in 25 languages (so far!) and it could be in your language too. We use Weblate to coordinate community translations. If you want to help us translate Stash into your language, you can make an account at [Stash's Weblate](https://hosted.weblate.org/projects/stashapp/stash/) to get started contributing new languages or improving existing ones. Thanks!
### Documentation
# Support (FAQ)
- [Official documentation](https://docs.stashapp.cc) - official guides guides and troubleshooting.
- [In-app manual](https://docs.stashapp.cc/in-app-manual) press <kbd>Shift</kbd> + <kbd>?</kbd> in the app or view the manual online.
- [FAQ](https://discourse.stashapp.cc/c/support/faq/28) - common questions and answers.
- [Community wiki](https://discourse.stashapp.cc/tags/c/community-wiki/22/stash) - guides, how-tos and tips.
### Community & Discussion
Check out our documentation on [Stash-Docs](https://docs.stashapp.cc) for information about the software, questions, guides, add-ons and more.
- [Community forum](https://discourse.stashapp.cc) - community support, feature requests and discussions.
- [Discord](https://discord.gg/2TsNFKt) - real-time chat and community support.
- [GitHub discussions](https://github.com/stashapp/stash/discussions) - community support and feature discussions.
- [Lemmy community](https://discuss.online/c/stashapp) - board-style community space.
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 [Matrix space](https://matrix.to/#/#stashapp:unredacted.org)
* 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)
### Community Scrapers & Plugins
# Customization
- [Metadata sources](https://docs.stashapp.cc/metadata-sources/)
- [Plugins](https://docs.stashapp.cc/plugins/)
- [Themes](https://docs.stashapp.cc/themes/)
- [Other projects](https://docs.stashapp.cc/other-projects/)
## Themes and CSS Customization
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.
## Architecture
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).
You can find an overview of Stash's architecture in the [ARCHITECTURE.md](docs/ARCHITECTURE.md) document.
# For Developers
## Contributing
Pull requests are welcome!
We welcome contributions and help from all humans who want to improve the project.
Before contributing, please read the [Contributing](docs/CONTRIBUTING.md) document to understand our guidelines and processes for contributing to the project.
You can learn about setting up a local development environment in the [Development](docs/DEVELOPMENT.md) document.
## Translation
The widget below shows the current translation status of Stash across all supported languages. If you want to help us translate Stash, you can make an account at [Codeberg Translate](https://translate.codeberg.org/projects/stash/stash/) to contribute to new or existing languages. Thanks!
[![Translation status](https://translate.codeberg.org/widget/stash/stash/multi-auto.svg)](https://translate.codeberg.org/engage/stash/)
See [Development](docs/DEVELOPMENT.md) and [Contributing](docs/CONTRIBUTING.md) for information on working with the codebase, getting a local development setup, and contributing changes.

View File

@@ -4,40 +4,20 @@ package main
import (
"fmt"
"os"
"os/exec"
"path/filepath"
flag "github.com/spf13/pflag"
"github.com/stashapp/stash/pkg/ffmpeg"
"github.com/stashapp/stash/pkg/hash/imagephash"
"github.com/stashapp/stash/pkg/hash/videophash"
"github.com/stashapp/stash/pkg/models"
)
func customUsage() {
fmt.Fprintf(os.Stderr, "Usage:\n")
fmt.Fprintf(os.Stderr, "%s [OPTIONS] FILE...\n\nOptions:\n", os.Args[0])
fmt.Fprintf(os.Stderr, "%s [OPTIONS] VIDEOFILE...\n\nOptions:\n", os.Args[0])
flag.PrintDefaults()
}
func printPhash(ff *ffmpeg.FFMpeg, ffp *ffmpeg.FFProbe, inputfile string, quiet *bool) error {
// Determine if this is a video or image file based on extension
ext := filepath.Ext(inputfile)
ext = ext[1:] // remove the leading dot
// Common image extensions
imageExts := map[string]bool{
"jpg": true, "jpeg": true, "png": true, "gif": true, "webp": true, "bmp": true, "avif": true,
}
if imageExts[ext] {
return printImagePhash(ff, inputfile, quiet)
}
return printVideoPhash(ff, ffp, inputfile, quiet)
}
func printVideoPhash(ff *ffmpeg.FFMpeg, ffp *ffmpeg.FFProbe, inputfile string, quiet *bool) error {
func printPhash(ff *ffmpeg.FFMpeg, ffp ffmpeg.FFProbe, inputfile string, quiet *bool) error {
ffvideoFile, err := ffp.NewVideoFile(inputfile)
if err != nil {
return err
@@ -65,31 +45,6 @@ func printVideoPhash(ff *ffmpeg.FFMpeg, ffp *ffmpeg.FFProbe, inputfile string, q
return nil
}
func printImagePhash(ff *ffmpeg.FFMpeg, inputfile string, quiet *bool) error {
imgFile := &models.ImageFile{
BaseFile: &models.BaseFile{Path: inputfile},
}
phash, err := imagephash.Generate(ff, imgFile)
if err != nil {
return err
}
if *quiet {
fmt.Printf("%x\n", *phash)
} else {
fmt.Printf("%x %v\n", *phash, imgFile.Path)
}
return nil
}
func getPaths() (string, string) {
ffmpegPath, _ := exec.LookPath("ffmpeg")
ffprobePath, _ := exec.LookPath("ffprobe")
return ffmpegPath, ffprobePath
}
func main() {
flag.Usage = customUsage
quiet := flag.BoolP("quiet", "q", false, "print only the phash")
@@ -104,7 +59,7 @@ func main() {
args := flag.Args()
if len(args) < 1 {
fmt.Fprintf(os.Stderr, "Missing FILE argument.\n")
fmt.Fprintf(os.Stderr, "Missing VIDEOFILE argument.\n")
flag.Usage()
os.Exit(2)
}
@@ -114,15 +69,14 @@ func main() {
fmt.Fprintf(os.Stderr, "Example: parallel %v ::: *.mp4\n", os.Args[0])
}
ffmpegPath, ffprobePath := getPaths()
ffmpegPath, ffprobePath := ffmpeg.GetPaths(nil)
encoder := ffmpeg.NewEncoder(ffmpegPath)
// don't need to InitHWSupport, phashing doesn't use hw acceleration
ffprobe := ffmpeg.NewFFProbe(ffprobePath)
ffprobe := ffmpeg.FFProbe(ffprobePath)
for _, item := range args {
if err := printPhash(encoder, ffprobe, item, quiet); err != nil {
fmt.Fprintln(os.Stderr, err)
}
}
}

View File

@@ -37,8 +37,6 @@ func main() {
defer recoverPanic()
initLogTemp()
helpFlag := false
pflag.BoolVarP(&helpFlag, "help", "h", false, "show this help text and exit")
@@ -76,10 +74,6 @@ func main() {
defer pprof.StopCPUProfile()
}
// initialise desktop.IsDesktop here so that it doesn't get affected by
// ffmpeg hardware checks later on
desktop.InitIsDesktop()
mgr, err := manager.Initialize(cfg, l)
if err != nil {
exitError(fmt.Errorf("manager initialization error: %w", err))
@@ -110,19 +104,9 @@ func main() {
exitCode = <-exit
}
// initLogTemp initializes a temporary logger for use before the config is loaded.
// Logs only error level message to stderr.
func initLogTemp() *log.Logger {
l := log.NewLogger()
l.Init("", true, "Error", 0)
logger.Logger = l
return l
}
func initLog(cfg *config.Config) *log.Logger {
l := log.NewLogger()
l.Init(cfg.GetLogFile(), cfg.GetLogOut(), cfg.GetLogLevel(), cfg.GetLogFileMaxSize())
l.Init(cfg.GetLogFile(), cfg.GetLogOut(), cfg.GetLogLevel())
logger.Logger = l
return l
@@ -148,7 +132,7 @@ func recoverPanic() {
exitCode = 1
logger.Errorf("panic: %v\n%s", err, debug.Stack())
if desktop.IsDesktop() {
desktop.FatalError(fmt.Errorf("panic: %v", err))
desktop.FatalError(fmt.Errorf("Panic: %v", err))
}
}
}
@@ -156,9 +140,6 @@ func recoverPanic() {
func exitError(err error) {
exitCode = 1
logger.Error(err)
// #5784 - log to stdout as well as the logger
// this does mean that it will log twice if the logger is set to stdout
fmt.Println(err)
if desktop.IsDesktop() {
desktop.FatalError(err)
}

View File

@@ -1,36 +1,31 @@
# This dockerfile should be built with `make docker-build` from the stash root.
# Build Frontend
FROM node:24-alpine AS frontend
FROM node:alpine as frontend
RUN apk add --no-cache make git
## cache node_modules separately
COPY ./ui/v2.5/package.json ./ui/v2.5/pnpm-lock.yaml /stash/ui/v2.5/
COPY ./ui/v2.5/package.json ./ui/v2.5/yarn.lock /stash/ui/v2.5/
WORKDIR /stash
COPY Makefile /stash/
COPY ./graphql /stash/graphql/
COPY ./ui /stash/ui/
# pnpm install with npm
RUN npm install -g pnpm
RUN make pre-ui
RUN make generate-ui
ARG GITHASH
ARG STASH_VERSION
RUN BUILD_DATE=$(date +"%Y-%m-%d %H:%M:%S") make ui-only
RUN BUILD_DATE=$(date +"%Y-%m-%d %H:%M:%S") make ui
# Build Backend
FROM golang:1.25.9-alpine AS backend
FROM golang:1.19-alpine as backend
RUN apk add --no-cache make alpine-sdk
WORKDIR /stash
COPY ./go* ./*.go Makefile gqlgen.yml .gqlgenc.yml /stash/
COPY ./graphql /stash/graphql/
COPY ./scripts /stash/scripts/
COPY ./pkg /stash/pkg/
COPY ./cmd /stash/cmd/
COPY ./internal /stash/internal/
# needed for generate-login-locale
COPY ./ui /stash/ui/
RUN make generate-backend generate-login-locale
COPY ./cmd /stash/cmd
COPY ./internal /stash/internal
COPY --from=frontend /stash /stash/
RUN make generate-backend
ARG GITHASH
ARG STASH_VERSION
RUN make flags-release flags-pie stash

View File

@@ -1,62 +1,49 @@
# This dockerfile should be built with `make docker-cuda-build` from the stash root.
ARG CUDA_VERSION=13.2.1
# Build Frontend
FROM node:24-alpine AS frontend
FROM node:alpine as frontend
RUN apk add --no-cache make git
## cache node_modules separately
COPY ./ui/v2.5/package.json ./ui/v2.5/pnpm-lock.yaml /stash/ui/v2.5/
COPY ./ui/v2.5/package.json ./ui/v2.5/yarn.lock /stash/ui/v2.5/
WORKDIR /stash
COPY Makefile /stash/
COPY ./graphql /stash/graphql/
COPY ./ui /stash/ui/
# pnpm install with npm
RUN npm install -g pnpm
RUN make pre-ui
RUN make generate-ui
ARG GITHASH
ARG STASH_VERSION
RUN BUILD_DATE=$(date +"%Y-%m-%d %H:%M:%S") make ui-only
RUN BUILD_DATE=$(date +"%Y-%m-%d %H:%M:%S") make ui
# Build Backend
FROM golang:1.25.9-bullseye AS 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 ./graphql /stash/graphql/
COPY ./scripts /stash/scripts/
COPY ./pkg /stash/pkg/
COPY ./cmd /stash/cmd
COPY ./internal /stash/internal
# needed for generate-login-locale
COPY ./ui /stash/ui/
RUN make generate-backend generate-login-locale
COPY --from=frontend /stash /stash/
RUN make generate-backend
ARG GITHASH
ARG STASH_VERSION
RUN make flags-release flags-pie stash
# Final Runnable Image
FROM nvidia/cuda:${CUDA_VERSION}-base-ubuntu24.04
RUN apt update && apt upgrade -y && apt install -y \
# stash dependencies
ca-certificates libvips-tools ffmpeg \
# intel dependencies
intel-media-va-driver-non-free vainfo \
# python tools
python3 python3-pip && \
# cleanup
apt autoremove -y && apt clean && \
rm -rf /var/lib/apt/lists/*
COPY --from=backend --chmod=555 /stash/stash /usr/bin/
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
ADD --chmod=555 https://raw.githubusercontent.com/keylase/nvidia-patch/master/patch.sh /usr/local/bin/patch.sh
ADD --chmod=555 https://raw.githubusercontent.com/keylase/nvidia-patch/master/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
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 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

View File

@@ -4,7 +4,7 @@ This dockerfile is used to build a stash docker container using the current sour
# Building the docker container
From the top-level directory (should contain `tools.go` file):
From the top-level directory (should contain `main.go` file):
```
make docker-build

View File

@@ -12,17 +12,16 @@ 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 tzdata vips vips-tools vips-heif \
&& pip install --break-system-packages mechanicalsoup cloudscraper stashapp-tools
ENV STASH_CONFIG_FILE=/root/.stash/config.yml
# vips version 8.15.0-r0 breaks thumbnail generation on arm32v6
# need to use 8.14.3-r0 from alpine 3.18 instead
# Basic build-time metadata as defined at https://github.com/opencontainers/image-spec/blob/main/annotations.md#pre-defined-annotation-keys
LABEL org.opencontainers.image.title="Stash" \
org.opencontainers.image.description="An organizer for your porn, written in Go." \
org.opencontainers.image.url="https://stashapp.cc" \
org.opencontainers.image.documentation="https://docs.stashapp.cc" \
org.opencontainers.image.source="https://github.com/stashapp/stash" \
org.opencontainers.image.licenses="AGPL-3.0"
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 ruby tzdata \
&& apk add --no-cache --repository http://dl-cdn.alpinelinux.org/alpine/v3.18/community vips=8.14.3-r0 vips-tools=8.14.3-r0 \
&& pip install --user --break-system-packages mechanicalsoup cloudscraper bencoder.pyx \
&& gem install faraday \
&& apk del .build-deps
ENV STASH_CONFIG_FILE=/root/.stash/config.yml
EXPOSE 9999
CMD ["stash"]

1
docker/compiler/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
*.sdk.tar.*

View File

@@ -1,86 +1,83 @@
### OSXCROSS
FROM debian:bookworm AS osxcross
# add osxcross
WORKDIR /tmp/osxcross
ARG OSXCROSS_REVISION=5e1b71fcceb23952f3229995edca1b6231525b5b
ADD --checksum=sha256:d3f771bbc20612fea577b18a71be3af2eb5ad2dd44624196cf55de866d008647 https://codeload.github.com/tpoechtrager/osxcross/tar.gz/${OSXCROSS_REVISION} /tmp/osxcross.tar.gz
FROM golang:1.19
ARG OSX_SDK_VERSION=12.3
ARG OSX_SDK_DOWNLOAD_FILE=MacOSX${OSX_SDK_VERSION}.sdk.tar.xz
ARG OSX_SDK_DOWNLOAD_URL=https://github.com/joseluisq/macosx-sdks/releases/download/${OSX_SDK_VERSION}/${OSX_SDK_DOWNLOAD_FILE}
ADD --checksum=sha256:3abd261ceb483c44295a6623fdffe5d44fc4ac2c872526576ec5ab5ad0f6e26c ${OSX_SDK_DOWNLOAD_URL} /tmp/osxcross/tarballs/${OSX_SDK_DOWNLOAD_FILE}
LABEL maintainer="https://discord.gg/2TsNFKt"
ENV UNATTENDED=yes \
SDK_VERSION=${OSX_SDK_VERSION} \
OSX_VERSION_MIN=12.0
RUN apt update && \
apt install -y --no-install-recommends \
bash ca-certificates clang cmake git patch libssl-dev bzip2 cpio libbz2-dev libxml2-dev make python3 xz-utils zlib1g-dev
# lzma-dev libxml2-dev xz
RUN tar --strip=1 -C /tmp/osxcross -xf /tmp/osxcross.tar.gz
RUN ./build.sh
RUN apt-get update && apt-get install -y apt-transport-https ca-certificates gnupg
### FREEBSD cross-compilation stage
# use alpine for cacheable image since apt is notorous for not caching
FROM alpine:3 AS freebsd
# match golang latest
# https://go.dev/wiki/FreeBSD
ARG FREEBSD_VERSION=12.4
ADD --checksum=sha256:581c7edacfd2fca2bdf5791f667402d22fccd8a5e184635e0cac075564d57aa8 \
http://ftp-archive.freebsd.org/mirror/FreeBSD-Archive/old-releases/amd64/${FREEBSD_VERSION}-RELEASE/base.txz \
/tmp/base.txz
RUN mkdir -p /etc/apt/keyrings
WORKDIR /opt/cross-freebsd
RUN apk add --no-cache tar xz
RUN tar -xf /tmp/base.txz --strip-components=1 ./usr/lib ./usr/include ./lib
RUN cd /opt/cross-freebsd/usr/lib && \
find . -type l -exec sh -c ' \
for link; do \
target=$(readlink "$link"); \
case "$target" in \
/lib/*) ln -sf "/opt/cross-freebsd$target" "$link";; \
esac; \
done \
' sh {} + && \
ADD https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key nodesource.gpg.key
RUN cat nodesource.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg && rm nodesource.gpg.key
RUN echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_20.x nodistro main" | tee /etc/apt/sources.list.d/nodesource.list
ADD https://dl.yarnpkg.com/debian/pubkey.gpg yarn.gpg
RUN cat yarn.gpg | gpg --dearmor -o /etc/apt/keyrings/yarn.gpg && rm yarn.gpg
RUN echo "deb [signed-by=/etc/apt/keyrings/yarn.gpg] https://dl.yarnpkg.com/debian/ stable main" | tee /etc/apt/sources.list.d/yarn.list
RUN apt-get update && \
apt-get install -y --no-install-recommends \
git make tar bash nodejs yarn zip \
clang llvm-dev cmake patch libxml2-dev uuid-dev libssl-dev xz-utils \
bzip2 gzip sed cpio libbz2-dev zlib1g-dev \
gcc-mingw-w64 \
gcc-arm-linux-gnueabi libc-dev-armel-cross linux-libc-dev-armel-cross \
gcc-aarch64-linux-gnu libc-dev-arm64-cross && \
rm -rf /var/lib/apt/lists/*;
# FreeBSD cross-compilation setup
# https://github.com/smartmontools/docker-build/blob/6b8c92560d17d325310ba02d9f5a4b250cb0764a/Dockerfile#L66
ENV FREEBSD_VERSION 12.4
ENV FREEBSD_DOWNLOAD_URL http://ftp.plusline.de/FreeBSD/releases/amd64/${FREEBSD_VERSION}-RELEASE/base.txz
ENV FREEBSD_SHA 581c7edacfd2fca2bdf5791f667402d22fccd8a5e184635e0cac075564d57aa8
RUN cd /tmp && \
curl -o base.txz $FREEBSD_DOWNLOAD_URL && \
echo "$FREEBSD_SHA base.txz" | sha256sum -c - && \
mkdir -p /opt/cross-freebsd && \
cd /opt/cross-freebsd && \
tar -xf /tmp/base.txz ./lib/ ./usr/lib/ ./usr/include/ && \
rm -f /tmp/base.txz && \
cd /opt/cross-freebsd/usr/lib && \
find . -xtype l | xargs ls -l | grep ' /lib/' | awk '{print "ln -sf /opt/cross-freebsd"$11 " " $9}' | /bin/sh && \
ln -s libc++.a libstdc++.a && \
ln -s libc++.so libstdc++.so
### BUILDER
FROM golang:1.25.9 AS builder
ENV PATH=/opt/osx-ndk-x86/bin:$PATH
# macOS cross-compilation setup
ENV OSX_SDK_VERSION 11.3
ENV OSX_SDK_DOWNLOAD_FILE MacOSX${OSX_SDK_VERSION}.sdk.tar.xz
ENV OSX_SDK_DOWNLOAD_URL https://github.com/phracker/MacOSX-SDKs/releases/download/${OSX_SDK_VERSION}/${OSX_SDK_DOWNLOAD_FILE}
ENV OSX_SDK_SHA cd4f08a75577145b8f05245a2975f7c81401d75e9535dcffbb879ee1deefcbf4
ENV OSXCROSS_REVISION 5e1b71fcceb23952f3229995edca1b6231525b5b
ENV OSXCROSS_DOWNLOAD_URL https://codeload.github.com/tpoechtrager/osxcross/tar.gz/${OSXCROSS_REVISION}
ENV OSXCROSS_SHA d3f771bbc20612fea577b18a71be3af2eb5ad2dd44624196cf55de866d008647
# copy in nodejs instead of using nodesource :thumbsup:
COPY --from=docker.io/library/node:24-bookworm /usr/local /usr/local
# copy in osxcross
COPY --from=osxcross /tmp/osxcross/target/lib /usr/lib
COPY --from=osxcross /tmp/osxcross/target /opt/osx-ndk-x86
# copy in cross-freebsd
COPY --from=freebsd /opt/cross-freebsd /opt/cross-freebsd
RUN cd /tmp && \
curl -o osxcross.tar.gz $OSXCROSS_DOWNLOAD_URL && \
echo "$OSXCROSS_SHA osxcross.tar.gz" | sha256sum -c - && \
mkdir osxcross && \
tar --strip=1 -C osxcross -xf osxcross.tar.gz && \
rm -f osxcross.tar.gz && \
curl -Lo $OSX_SDK_DOWNLOAD_FILE $OSX_SDK_DOWNLOAD_URL && \
echo "$OSX_SDK_SHA $OSX_SDK_DOWNLOAD_FILE" | sha256sum -c - && \
mv $OSX_SDK_DOWNLOAD_FILE osxcross/tarballs/ && \
UNATTENDED=yes SDK_VERSION=$OSX_SDK_VERSION OSX_VERSION_MIN=10.10 osxcross/build.sh && \
cp osxcross/target/lib/* /usr/lib/ && \
mv osxcross/target /opt/osx-ndk-x86 && \
rm -rf /tmp/osxcross
# pnpm install with npm
RUN npm install -g pnpm
ENV PATH /opt/osx-ndk-x86/bin:$PATH
# git for getting hash
# make and bash for building
RUN mkdir -p /root/.ssh && \
chmod 0700 /root/.ssh && \
ssh-keyscan github.com > /root/.ssh/known_hosts
# clang for macos
# zip for stashapp.zip
# gcc-extensions for cross-arch build
# we still target arm soft float?
RUN apt-get update && \
apt-get install -y --no-install-recommends \
git make bash \
clang zip \
gcc-mingw-w64 \
gcc-arm-linux-gnueabi \
libc-dev-armel-cross linux-libc-dev-armel-cross \
gcc-aarch64-linux-gnu libc-dev-arm64-cross && \
rm -rf /var/lib/apt/lists/*;
# ignore "dubious ownership" errors
RUN git config --global safe.directory '*'
# To test locally:
# make generate
# make ui
# cd docker/compiler
# docker build . -t ghcr.io/stashapp/compiler:latest
# docker run --rm -v /PATH_TO_STASH:/stash -w /stash -i -t ghcr.io/stashapp/compiler:latest make build-cc-all
# # binaries will show up in /dist
# make build
# docker run --rm -v /PATH_TO_STASH:/stash -w /stash -i -t stashapp/compiler:latest make build-cc-all
# # binaries will show up in /dist

View File

@@ -1,22 +1,16 @@
host=ghcr.io
user=stashapp
repo=compiler
version=14
VERSION_IMAGE = ${host}/${user}/${repo}:${version}
LATEST_IMAGE = ${host}/${user}/${repo}:latest
version=8
latest:
docker build -t ${LATEST_IMAGE} .
docker build -t ${user}/${repo}:latest .
build:
docker build -t ${VERSION_IMAGE} -t ${LATEST_IMAGE} .
docker build -t ${user}/${repo}:${version} -t ${user}/${repo}:latest .
build-no-cache:
docker build --no-cache -t ${VERSION_IMAGE} -t ${LATEST_IMAGE} .
docker build --no-cache -t ${user}/${repo}:${version} -t ${user}/${repo}:latest .
# requires docker login ghcr.io
# echo $CR_PAT | docker login ghcr.io -u USERNAME --password-stdin
push:
docker push ${VERSION_IMAGE}
docker push ${LATEST_IMAGE}
install: build
docker push ${user}/${repo}:${version}
docker push ${user}/${repo}:latest

View File

@@ -1,3 +1,3 @@
Modified from https://github.com/bep/dockerfiles/tree/master/ci-goreleaser
When the Dockerfile is changed, the version number should be incremented in [.github/workflows/build-compiler.yml](../../.github/workflows/build-compiler.yml) and the workflow [manually ran](). `env: COMPILER_IMAGE` in [.github/workflows/build.yml](../../.github/workflows/build.yml) also needs to be updated to pull the correct image tag.
When the Dockerfile is changed, the version number should be incremented in the Makefile and the new version tag should be pushed to Docker Hub. The GitHub workflow files also need to be updated to pull the correct image tag.

View File

@@ -1,14 +1,12 @@
# Docker Installation (for most 64-bit GNU/Linux systems)
StashApp is supported on most systems that support Docker. Your OS likely ships with or makes available the necessary packages.
StashApp is supported on most systems that support Docker and docker-compose. Your OS likely ships with or makes available the necessary packages.
## Dependencies
Only `docker` is required. For the most part your understanding of the technologies can be superficial. So long as you can follow commands and are open to reading a bit, you should be fine.
Only `docker` and `docker-compose` are required. For the most part your understanding of the technologies can be superficial. So long as you can follow commands and are open to reading a bit, you should be fine.
Installation instructions are available below, and if your distributions's repository ships a current version of docker, you may use that.
Installation instructions are available below, and if your distrobution's repository ships a current version of docker, you may use that.
https://docs.docker.com/engine/install/
On some distributions, `docker compose` is shipped separately, usually as `docker-cli-compose`. docker-compose is not recommended.
### Get the docker-compose.yml file
Now you can either navigate to the [docker-compose.yml](https://raw.githubusercontent.com/stashapp/stash/develop/docker/production/docker-compose.yml) in the repository, or if you have curl, you can make your Linux console do it for you:
@@ -21,7 +19,7 @@ curl -o docker-compose.yml https://raw.githubusercontent.com/stashapp/stash/deve
Once you have that file where you want it, modify the settings as you please, and then run:
```
docker compose up -d
docker-compose up -d
```
Installing StashApp this way will by default bind stash to port 9999. This is available in your web browser locally at http://localhost:9999 or on your network as http://YOUR-LOCAL-IP:9999
@@ -31,9 +29,9 @@ Good luck and have fun!
### Docker
Docker is effectively a cross-platform software package repository. It allows you to ship an entire environment in what's referred to as a container. Containers are intended to hold everything that is needed to run an application from one place to another, making it easy for everyone along the way to reproduce the environment.
The StashApp docker container ships with everything you need to automatically run stash, including ffmpeg.
The StashApp docker container ships with everything you need to automatically build and run stash, including ffmpeg.
### docker compose
Docker Compose lets you specify how and where to run your containers, and to manage their environment. The docker-compose.yml file in this folder gets you a fully working instance of StashApp exactly as you would need it to have a reasonable instance for testing / developing on. If you are deploying a live instance for production, a [reverse proxy](https://docs.stashapp.cc/guides/reverse-proxy/) (such as NGINX or Traefik) is recommended, but not required.
### docker-compose
Docker Compose lets you specify how and where to run your containers, and to manage their environment. The docker-compose.yml file in this folder gets you a fully working instance of StashApp exactly as you would need it to have a reasonable instance for testing / developing on. If you are deploying a live instance for production, a reverse proxy (such as NGINX or Traefik) is recommended, but not required.
The latest version is always recommended.

View File

@@ -1,5 +1,6 @@
# APPNICENAME=Stash
# APPDESCRIPTION=An organizer for your porn, written in Go
version: '3.4'
services:
stash:
image: stashapp/stash:latest
@@ -26,12 +27,10 @@ services:
- /etc/localtime:/etc/localtime:ro
## Adjust below paths (the left part) to your liking.
## E.g. you can change ./config:/root/.stash to ./stash:/root/.stash
## The left part is the path on your host, the right part is the path in the stash container.
## Keep configs, scrapers, and plugins here.
- ./config:/root/.stash
## Point this at your collection.
## The left side is where your collection is on your host, the right side is where it will be in stash.
- ./data:/data
## This is where your stash's metadata lives
- ./metadata:/metadata

View File

@@ -1,22 +0,0 @@
# AI Usage Policy
- AI agents are not welcome to contribute to this project.
- All issues, pull request descriptions and comments must be written by humans.
- Fully AI-generated contributions will be closed without comment.
## AI-Assisted Code Contributions
AI-assisted code contributions generated with the use of LLMs are permitted under the following conditions:
- AI usage and scope must be openly disclosed in the PR description.
- You must be able to explain any line of code and design decision during the review process.
- You must perform manual testing and describe the steps taken to sufficiently verify the changes.
- You must take full responsibility for the code, including license compliance.
We are not accepting large, complex features generated by LLMs by outside contributors at this time.
## Respect Maintainers Time
Reviewing PRs takes a significant amount of time. Anyone with zero effort can generate code with an LLM, but it takes a human to understand it, test it, and ensure it fits with the overall design of the project.
We ask that contributors respect this by ensuring their PRs meet these standards before submitting them for review.

View File

@@ -1,415 +0,0 @@
# Architecture
This document provides an overview of the Stash codebase architecture for new contributors.
## Project Overview
Stash is a self-hosted web application written in Go that organizes and serves diverse media collections, catering to both SFW and NSFW needs. It gathers information about videos and images from the internet through extensible community-built plugins and scrapers, supports a wide variety of formats, enables tagging and filtering, and provides statistics about performers, tags, studios, and more.
**Core purpose**: Manage local media libraries with automatic metadata scraping, tagging, and organization.
**Key design philosophy**:
- Backend: Go with GraphQL API and SQLite database
- Frontend: React/TypeScript with Apollo Client
- Extensibility: Plugin and scraper systems for community contributions
- Self-hosted: Single binary deployment with embedded frontend assets
## Repository Structure
```
stash/
├── cmd/ # Application entry points
│ ├── phasher/ # Perceptual hash utility
│ └── stash/ # Main application (cmd/stash/main.go)
├── docker/ # Docker configuration
│ ├── build/ # Build configurations
│ ├── ci/ # CI configurations
│ ├── compiler/ # Compiler Docker setup
│ └── production/ # Production Docker setup
├── graphql/ # GraphQL schema definitions
│ ├── schema/ # Main schema files
│ │ └── types/ # GraphQL type definitions
│ └── stash-box/ # Stash-box integration schema
├── internal/ # Internal application code
│ ├── api/ # GraphQL API layer (resolvers, server)
│ ├── autotag/ # Auto-tagging functionality
│ ├── desktop/ # Desktop integration
│ ├── dlna/ # DLNA media server
│ ├── identify/ # Scene identification
│ ├── log/ # Implementation of log system
│ ├── manager/ # Core application manager and services
│ └── static/ # Static asset serving
├── pkg/ # Reusable Go packages
│ ├── ffmpeg/ # FFmpeg integration for media processing
│ ├── file/ # File system operations and scanning
│ ├── gallery/ # Gallery-specific business logic
│ ├── group/ # Group (movie) business logic
│ ├── hash/ # Hashing utilities (MD5, oshash, phash)
│ ├── image/ # Image-specific business logic
│ ├── job/ # Background job management
│ ├── logger/ # Logging utilities
│ ├── models/ # Interface definitions for data entities
│ ├── performer/ # Performer-specific business logic
│ ├── plugin/ # Plugin system
│ ├── scene/ # Scene-specific business logic
│ ├── scraper/ # Metadata scraping system
│ ├── sqlite/ # SQLite implementations of datalayer interfaces
│ ├── studio/ # Studio-specific business logic
│ ├── tag/ # Tag-specific business logic
│ └── ... # Other utility packages
├── ui/ # React/TypeScript frontend
│ ├── login/ # Login page
│ └── v2.5/ # Main frontend application
├── docs/ # Documentation
├── scripts/ # Utility scripts
├── go.mod # Go module definition
├── go.sum # Go dependency checksums
├── gqlgen.yml # GraphQL code generation config
└── Makefile # Build automation
```
## Backend Architecture
### Go Package Organization
The backend follows a layered architecture with clear separation of concerns:
**`pkg/models/` - Interface Layer**
- Defines interfaces for each entity (Scene, Image, Gallery, Performer, Studio, Tag, etc.)
- Each entity has `Reader`, `Writer`, and `ReaderWriter` interfaces
- Contains data model structs and query/filter types
- Example: `repository.go` defines the main `Repository` struct with all entity repositories
- Example: `repository_scene.go` defines `SceneReaderWriter` interface
**`pkg/sqlite/` - Implementation of datalayer interfaces**
- Implements the interfaces defined in `pkg/models/`
- Uses `goqu` for CRUD operations and standard queries, and a custom query builder for complex filtering/listing
- Contains all database access logic
- Example: `scene.go` implements `SceneStore` with CRUD operations
- Example: `scene_filter.go` implements filtering logic
- Handles transactions and connection pooling
**`internal/api/` - API Layer**
- GraphQL resolvers that implement the schema
- Each resolver method calls repository methods
- Handles authentication, authorization, and validation
- Example: `resolver_query_find_scene.go` implements scene query resolvers
- Example: `resolver_mutation_scene.go` implements scene mutation resolvers
- `server.go` sets up the HTTP server and GraphQL handler
### Layering Pattern
```
GraphQL Query/Mutation
Resolver (internal/api/resolver_*.go)
↓ (complex entities: Scene, Gallery, Image, Group)
Service Layer (pkg/scene/, pkg/gallery/, pkg/image/, pkg/group/)
↓ (simpler entities: Performer, Studio, Tag)
Validation (pkg/performer/, pkg/studio/, pkg/tag/)
Repository Interface (pkg/models/repository_*.go)
SQLite Implementation (pkg/sqlite/*.go)
SQLite Database
```
Note: `gqlgen.yml` maps GraphQL types to Go structs and controls code generation. Update it when adding new types or fields.
### GraphQL Request Lifecycle
1. **Request**: Frontend sends GraphQL query to `/graphql` endpoint
2. **Routing**: `internal/api/server.go` routes to GraphQL handler (gqlgen)
3. **Parsing**: gqlgen parses the query and validates against schema
4. **Resolver Execution**: Appropriate resolver method in `internal/api/` is called
5. **Transaction**: Resolver wraps operation in read or write transaction via `withReadTxn()` or `withTxn()`
6. **Business Logic** (mutations only):
- Complex entities (Scene, Gallery, Image, Group): resolver delegates to service layer (`pkg/scene/`, `pkg/gallery/`, etc.)
- Simpler entities (Performer, Studio, Tag): resolver calls validation functions (`pkg/performer/`, `pkg/studio/`, etc.) then proceeds directly to repository
- Queries and model field resolvers skip this step entirely
7. **Repository Call**: Resolver or service calls repository method (e.g., `r.repository.Scene.Find()`)
8. **SQL Execution**: SQLite implementation executes SQL query using a mix of goqu and a custom query builder
9. **Response**: Data flows back through layers to frontend as JSON
### Plugin System
**Location**: `pkg/plugin/`
- Defines the plugin spec for UI-based plugins (including JavaScript), and supports executing external scripts, commands, and binaries via raw or RPC interface
- Plugins are configured via YAML files in the plugins directory
- Support for hooks that trigger on events (e.g., `Scene.Create.Post`)
- Plugin cache in manager for performance
- RPC communication between Go and JavaScript plugins
- Example hooks: `Scene.Create.Post`, `Scene.Update.Post`, `Scan.Post`
Key files:
- `plugins.go` - Plugin loading and execution
- `hooks.go` - Hook system implementation
- `config.go` - Plugin configuration parsing
### Scraper System
**Location**: `pkg/scraper/`
- YAML-configured scrapers for fetching metadata from websites
- Supports multiple scraper types: XPath, JSON, GraphQL, script-based
- Scrapers can fetch performers, scenes, galleries, studios, tags
- Stash-box integration for crowd-sourced metadata
- Cache for scraper definitions
- Post-processing for transforming scraped data
Key files:
- `cache.go` - Scraper caching
- `definition.go` - Scraper configuration parsing
- `xpath.go` - XPath-based scraping
- `json.go` - JSON-based scraping
- `mapped.go` - Mapping scraped data to Stash models
### Task/Job System
**Location**: `pkg/job/`
- Background job management for long-running operations
- Progress reporting via GraphQL subscriptions
- Task queue with parallel execution
- Cancellation support
- Job types: Scan, Generate, Clean, Auto-tag, Identify, Export, Import
Key files:
- `manager.go` - Job manager implementation
- `job.go` - Job interface and progress tracking
- `subscribe.go` - Subscription support for job updates
Task implementations in `internal/manager/task/`:
- `task_scan.go` - File scanning
- `task_generate.go` - Thumbnail/sprite generation
- `task_clean.go` - Orphaned file cleanup
- `task_autotag.go` - Automatic tagging
- `task_identify.go` - Scene identification
## Frontend Architecture
### React/TypeScript Structure
**Location**: `ui/v2.5/`
```
ui/v2.5/
├── src/
│ ├── core/ # Core services and GraphQL client
│ │ ├── StashService.ts # Main GraphQL client
│ │ ├── generated-graphql.ts # Auto-generated TypeScript types
│ │ ├── createClient.ts # Apollo client setup
│ │ ├── config.ts # Configuration
│ │ ├── scenes.ts # Scene-specific queries
│ │ ├── performers.ts # Performer-specific queries
│ │ └── ...
│ ├── components/ # React components
│ │ ├── Scenes/ # Scene-related components
│ │ ├── Performers/ # Performer-related components
│ │ ├── Galleries/ # Gallery-related components
│ │ ├── Images/ # Image-related components
│ │ ├── Studios/ # Studio-related components
│ │ ├── Tags/ # Tag-related components
│ │ ├── Settings/ # Settings components
│ │ ├── Shared/ # Shared/reusable components
│ │ └── ...
│ ├── hooks/ # Custom React hooks
│ │ ├── data.ts # Data fetching hooks
│ │ ├── LocalForage.ts # Local storage hooks
│ │ ├── Toast.tsx # Toast notifications
│ │ └── ...
│ ├── models/ # Frontend data models
│ │ └── list-filter/ # Filter and list models
│ ├── locales/ # i18n translations
│ │ ├── en-GB.json # English
│ │ ├── de-DE.json # German
│ │ └── ...
│ ├── utils/ # Utility functions
│ ├── App.tsx # Main application component
│ └── index.tsx # Application entry point
├── graphql/ # GraphQL queries and fragments
├── public/ # Static assets
├── package.json # Dependencies and scripts
├── codegen.ts # GraphQL codegen configuration
└── vite.config.js # Vite build configuration
```
### Communication with Backend
**GraphQL via Apollo Client**:
- Frontend uses Apollo Client (`@apollo/client`) for GraphQL communication
- GraphQL queries defined in `graphql/` directory
- Code generation via `@graphql-codegen/cli` generates TypeScript types
- Generated types in `src/core/generated-graphql.ts`
- WebSocket subscriptions for real-time updates (job progress, logging)
**Service Layer** (`src/core/`):
- `StashService.ts` - Main GraphQL client with typed queries/mutations
- Domain-specific files (scenes.ts, performers.ts, etc.) - Organized queries
- `createClient.ts` - Apollo client setup with authentication and uploads
## Database Layer
### SQLite Usage
**Database**: Single SQLite database file (default: `stash-go.sqlite`)
- WAL (Write-Ahead Logging) mode for concurrency
- Connection pooling: 1 write connection, 10 read connections
- 30-second idle connection timeout
- Configurable cache size via `STASH_SQLITE_CACHE_SIZE` environment variable
**Blob Storage**:
- Configurable storage for cover images and other binary data
- Options: Database (BLOB columns) or Filesystem (separate directory)
- Managed via `BlobStore` in `pkg/sqlite/blob.go`
### Migration System
**Location**: `pkg/sqlite/migrations/`
**Migration Files**:
- Numbered `.up.sql` files (e.g., `32_files.up.sql`)
- Current schema version is defined in `pkg/sqlite/database.go`
- Migrations embedded via `//go:embed migrations/*.sql`
- Uses `golang-migrate/migrate` library
**Custom Migrations**:
- Pre-migration Go files (e.g., `32_premigrate.go`) - Run before SQL
- Post-migration Go files (e.g., `32_postmigrate.go`) - Run after SQL
- Used for data transformations that SQL cannot handle
**Migration Process** (`pkg/sqlite/migrate.go`):
- The migrator runs pre-migration Go code, executes the SQL migration, then runs post-migration Go code for each version increment.
**Key Migrations**:
- `32_files.up.sql` - Introduced file/folder abstraction
- `45_blobs.up.sql` - Blob storage system
- `71_custom_fields.up.sql` - Custom fields support
### Query Patterns
**Repository Pattern**:
- All database access goes through repository interfaces
- SQLite implementations use a mix of goqu and a custom query builder within the `sqlite` package
- Transactions managed via `txn.Manager`
**Example Query** (`pkg/sqlite/scene.go`):
```go
func (qb *SceneStore) Find(ctx context.Context, id int) (*models.Scene, error) {
var scene models.Scene
err := qb.repository.queryStruct(ctx, qb.sceneQuery(), []interface{}{id}, &scene)
if err != nil {
return nil, err
}
return &scene, nil
}
```
**Filtering**:
- Complex filtering via a custom query builder system (`query.go`, `filter.go`) that constructs raw SQL
- Criterion handlers in `criterion_handlers.go` dynamically build WHERE, HAVING, and WITH clauses
- Supports hierarchical filters (tags, studios) via recursive CTEs
- Simpler queries (CRUD, join-table lookups) use `goqu` via the `table` abstraction
## Key Data Flows
### Example 1: GraphQL Query (findScene)
**Flow**:
1. Frontend sends GraphQL query requesting scene data by ID
2. Request hits `internal/api/server.go` at `/graphql` endpoint
3. gqlgen routes to the appropriate resolver in `resolver_query_find_scene.go`
4. Resolver wraps the operation in a read transaction using `withReadTxn()` to ensure consistent database access
5. Repository calls the SQLite implementation in `pkg/sqlite/scene.go` to execute the query
6. SQLite generates and executes the SQL query (using goqu or the custom queryBuilder depending on operation) to fetch the scene record
7. Scene object flows back through layers: SQLite → Repository → Resolver → GraphQL → Frontend
8. Frontend receives JSON response with the requested scene data
### Example 2: Scanning a File
**Flow**:
1. User triggers scan via UI (Settings → Metadata → Scan)
2. Frontend sends GraphQL mutation to start the scan job
3. Mutation resolver in `internal/api/resolver_mutation_metadata.go` creates a background job
4. Job manager queues `ScanJob` from `internal/manager/task_scan.go`
5. `ScanJob.Execute()` runs the scan operation with progress tracking
6. Filesystem walk traverses configured paths using `file.SymWalk`, queues files for processing, and filters based on modification time and .stashignore
7. File handlers process each file type: videos become Scenes, images become Images, zip files become Galleries, and folders get Folder records
8. For each video file, the system calculates checksums (MD5, oshash, phash), extracts metadata via FFmpeg, creates File and Scene records, and generates thumbnails, sprites, previews, and interactive heatmaps
9. Progress updates flow via GraphQL subscription with real-time updates on files processed
10. Scan completes with updated statistics, subscription notifies completion, and UI refreshes with new content
**Key Files**:
- `internal/manager/task_scan.go` - Main scan logic
- `pkg/file/` - File system operations
- `pkg/scene/scan.go` - Scene-specific scan logic
- `pkg/image/scan.go` - Image-specific scan logic
- `pkg/gallery/scan.go` - Gallery-specific scan logic
## Development Workflow
### Adding a New GraphQL Field
1. Define field in `graphql/schema/schema.graphql`
2. Run `make generate-backend` to regenerate types
3. Implement resolver in `internal/api/resolver_*.go`
4. If query requires new repository method:
- Add interface to `pkg/models/repository_*.go`
- Implement in `pkg/sqlite/*.go`
5. Add frontend query in `ui/v2.5/graphql/`
6. Run `make generate-ui` to regenerate frontend types
- Frontend type checking runs in CI — you do not need to run `tsc` locally.
### Adding a Database Migration
1. Create new migration file: `pkg/sqlite/migrations/{version}_description.up.sql`
2. If needed, create `{version}_premigrate.go` for pre-migration logic
3. If needed, create `{version}_postmigrate.go` for post-migration logic
4. Update `appSchemaVersion` in `pkg/sqlite/database.go`
5. Test migration on development database
### Running Tests
```bash
# Backend test
make it
```
### Building
```bash
# Build frontend
make ui
# Develop frontend with hot-reload
make ui-start
# Build backend (requires frontend to be built first)
make build
```
## Additional Resources
- **Development Guide**: See `docs/DEVELOPMENT.md`
- **Contributing**: See `docs/CONTRIBUTING.md`
- **GraphQL Schema**: `graphql/schema/schema.graphql`
- **In-app Manual**: Available in-app via Shift+?
- **GraphQL Playground**: Available at `/playground`
- **Community**: [Discord](https://discord.gg/2TsNFKt) and [Discourse](https://discourse.stashapp.cc)

View File

@@ -1,48 +1,14 @@
# Contributing to Stash
## AI Usage Policy
## Technical Debt
Please be sure to consider how heavily your contribution impacts the maintainability of the project long term, sometimes less is more. We don't want to merge collossal pull requests with hundreds of dependencies by a driveby contributor.
Please see our [AI Usage Policy](/docs/AI_POLICY.md) for guidelines on the use of AI in contributions to this project.
## Contributor Checklist
Please make sure that you've considered the following before you submit your Pull Requests as ready for merging.
* I've run Code linters and [gofmt](https://golang.org/cmd/gofmt/) to make sure that my code is readable.
* I have read through formerly submitted [pull requests](https://github.com/stashapp/stash/pulls) and [git issues](https://github.com/stashapp/stash/issues) to make sure that this contribution is required and isn't a duplicate. Also, so that I can manage to close any git Issues needing closed relating to this feature submission.
* I commented adequately on my code with the expectation in mind that anyone else should be able to look at this code I've submitted and know exactly what's happening and what the expectations are.
## Issues
### Legal Agreements
* I acknowledge that if applicable to me, submitting and subsequent acceptance of this Pull Request I, the code contributor of this Pull Request, agree and acknowledge my understanding that the new code license has now been updated to [AGPL](/LICENSE.md). I agree that all code before this Pull Request, which I've previously submitted, is now to be re-licensed under the new license AGPL and no longer the former MIT license.
Bug reports and feature requests must use descriptive and concise titles and follow the provided templates. Please use the search function to make sure that you are not submitting duplicates, and that a similar report or request has not already been resolved or rejected.
All issues must be written by humans. Fully AI-generated issues will be closed without comment.
## Pull Requests
All pull requests must use descriptive and concise titles and follow the provided templates. In addition, they must follow the the following guidelines:
- You must link to an open issue that pull request addresses (see [GitHub documentation](https://docs.github.com/en/issues/tracking-your-work-with-issues/using-issues/linking-a-pull-request-to-an-issue) on how to do that).
- Pull requests must be focused on a single issue or feature. Large, multi-purpose pull requests will be rejected.
- Large features must be discussed with maintainers before submitting a pull request to ensure it fits with the overall design vision of the project. Failure to do so may result in the pull request being rejected.
- Pull requests must include code tests that sufficiently cover the changes made.
- You must detail the manual testing done and describe the steps taken to sufficiently verify the changes.
- You must be able to explain any line of code and design decision during the review process.
By submitting a pull request, you agree that you have read and understood and that you are in compliance with the guidelines outlined here, including the [AI Usage Policy](docs/AI_POLICY.md).
You also agree to license your contribution under the [AGPL](/LICENSE.md) license, and that all of your previous contributions to the project are also licensed under the AGPL.
## Goals and Design Vision
The goal of Stash is to be:
- An application for organising and viewing NSFW and SFW content - currently this is videos and images, in future this will be extended to include audio and text content
- Organising includes scraping of metadata from websites and metadata repositories
- Free and open-source
- Portable and offline - can be run on a USB stick without needing to install dependencies (with the exception of FFmpeg)
- Minimal, but highly extensible. The core feature set should be the minimum required to achieve the primary goal, while being extensible enough to extend via plugins
- Easy to learn and use, with minimal technical knowledge required
The core Stash system is not intended for:
- Managing downloading of content
- Managing content on external websites
- Publicly sharing content
Other requirements:
- Support as many video and image formats as possible
- Interfaces with external systems (for example stash-box) should be made as generic as possible.
Design considerations:
- Features are easy to add and difficult to remove. Large superfluous features should be scrutinised and avoided where possible (e.g. DLNA, filename parser). Such features should be considered for third-party plugins instead.
**In case you were unable to follow any of the above include an explanation as to why not in your Pull Request.**

View File

@@ -4,9 +4,8 @@
* [Go](https://golang.org/dl/)
* [GolangCI](https://golangci-lint.run/) - A meta-linter which runs several linters in parallel
* To install, follow the [local installation instructions](https://golangci-lint.run/welcome/install/#local-installation)
* [nodejs](https://nodejs.org/en/download) - nodejs runtime
* corepack/[pnpm](https://pnpm.io/installation) - nodejs package manager (included with nodejs)
* To install, follow the [local installation instructions](https://golangci-lint.run/usage/install/#local-installation)
* [Yarn](https://yarnpkg.com/en/docs/install) - Yarn package manager
## Environment
@@ -23,22 +22,32 @@ 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 gcc make node ffmpeg`
2. Install dependencies: `brew install go git yarn gcc make node ffmpeg`
### Linux
#### Arch Linux
1. Install dependencies: `sudo pacman -S go git gcc make nodejs ffmpeg --needed`
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`
1. Install dependencies: `sudo apt-get install golang git yarnpkg gcc nodejs ffmpeg -y`
### OpenBSD
1. Install dependencies `doas pkg_add gmake go git node cmake ffmpeg`
2. Follow the instructions below to build a release, but replace the final step `make build-release` with `gmake flags-release stash`, to [avoid the PIE buildmode](https://github.com/golang/go/issues/59866).
1. Install dependencies `doas pkg_add gmake go git yarn node cmake`
2. Compile a custom ffmpeg from ports. The default ffmpeg in OpenBSD's packages is not compiled with WebP support, which is required by Stash.
- If you've already installed ffmpeg, uninstall it: `doas pkg_delete ffmpeg`
- If you haven't already, [fetch the ports tree and verify](https://www.openbsd.org/faq/ports/ports.html#PortsFetch).
- Find the ffmpeg port in `/usr/ports/graphics/ffmpeg`, and patch the Makefile to include libwebp
- Add `webp` to `WANTLIB`
- Add `graphics/libwebp` to the list in `LIB_DEPENDS`
- Add `-lwebp -lwebpdecoder -lwebpdemux -lwebpmux` to `LIBavcodec_EXTRALIBS`
- Add `--enable-libweb` to the list in `CONFIGURE_ARGS`
- If you've already built ffmpeg from ports before, you may need to also increment `REVISION`
- Run `doas make install`
- Follow the instructions below to build a release, but replace the final step `make build-release` with `gmake flags-release stash`, to [avoid the PIE buildmode](https://github.com/golang/go/issues/59866).
NOTE: The `make` command in OpenBSD will be `gmake`. For example, `make pre-ui` will be `gmake pre-ui`.
@@ -60,9 +69,6 @@ NOTE: The `make` command in OpenBSD will be `gmake`. For example, `make pre-ui`
* `make it` - Runs all unit and integration tests
* `make fmt` - Formats the Go source code
* `make fmt-ui` - Formats the UI source code
* `make validate-ui` - Runs tests and checks for the UI only
* `make fmt-ui-quick` - (experimental) Formats only changed UI source code
* `make validate-ui-quick` - (experimental) Runs tests and checks of changed UI code
* `make server-start` - Runs a development stash 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 - the server URL can be changed from the default of `http://localhost:9999` using the environment variable `VITE_APP_PLATFORM_URL`, but keep in mind that authentication cannot be used since the session authorization cookie cannot be sent cross-origin. The UI runs on port `3000` or the next available port.
@@ -118,8 +124,8 @@ This project uses a modification of the [CI-GoReleaser](https://github.com/bep/d
To cross-compile the app yourself:
1. Run `make pre-ui`, `make generate` and `make ui` outside the container, to generate files and build the UI.
2. Pull the latest compiler image from GHCR: `docker pull ghcr.io/stashapp/compiler`
3. Run `docker run --rm --mount type=bind,source="$(pwd)",target=/stash -w /stash -it ghcr.io/stashapp/compiler /bin/bash` to open a shell inside the container.
2. Pull the latest compiler image from Docker Hub: `docker pull stashapp/compiler`
3. Run `docker run --rm --mount type=bind,source="$(pwd)",target=/stash -w /stash -it stashapp/compiler /bin/bash` to open a shell inside the container.
4. From inside the container, run `make build-cc-all` to build for all platforms, or run `make build-cc-{platform}` to build for a specific platform (have a look at the `Makefile` for the list of targets).
5. You will find the compiled binaries in `dist/`.

121
go.mod
View File

@@ -1,112 +1,90 @@
module github.com/stashapp/stash
go 1.25.0
go 1.19
require (
github.com/99designs/gqlgen v0.17.73
github.com/99designs/gqlgen v0.17.2
github.com/WithoutPants/sortorder v0.0.0-20230616003020-921c9ef69552
github.com/Yamashou/gqlgenc v0.32.1
github.com/Yamashou/gqlgenc v0.0.6
github.com/anacrolix/dms v1.2.2
github.com/antchfx/htmlquery v1.3.5
github.com/asticode/go-astisub v0.25.1
github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d
github.com/chromedp/chromedp v0.14.2
github.com/antchfx/htmlquery v1.3.0
github.com/asticode/go-astisub v0.26.0
github.com/chromedp/cdproto v0.0.0-20231007061347-18b01cd81617
github.com/chromedp/chromedp v0.9.2
github.com/corona10/goimagehash v1.1.0
github.com/disintegration/imaging v1.6.2
github.com/dop251/goja v0.0.0-20231027120936-b396bb4c349d
github.com/doug-martin/goqu/v9 v9.18.0
github.com/enetx/g v1.0.224
github.com/enetx/surf v1.0.199
github.com/feederbox826/gosx-notifier v0.2.2
github.com/go-chi/chi/v5 v5.2.2
github.com/go-chi/chi/v5 v5.0.10
github.com/go-chi/cors v1.2.1
github.com/go-chi/httplog v0.3.1
github.com/go-toast/toast v0.0.0-20190211030409-01e6764cf0a4
github.com/golang-jwt/jwt/v4 v4.5.2
github.com/gofrs/uuid/v5 v5.0.0
github.com/golang-jwt/jwt/v4 v4.5.0
github.com/golang-migrate/migrate/v4 v4.16.2
github.com/google/uuid v1.6.0
github.com/gorilla/securecookie v1.1.1
github.com/gorilla/sessions v1.2.1
github.com/gorilla/websocket v1.5.0
github.com/hashicorp/golang-lru/v2 v2.0.7
github.com/hasura/go-graphql-client v0.13.1
github.com/hashicorp/golang-lru/v2 v2.0.6
github.com/jinzhu/copier v0.4.0
github.com/jmoiron/sqlx v1.4.0
github.com/jmoiron/sqlx v1.3.5
github.com/json-iterator/go v1.1.12
github.com/kermieisinthehouse/gosx-notifier v0.1.2
github.com/kermieisinthehouse/systray v1.2.4
github.com/knadh/koanf/parsers/yaml v1.1.0
github.com/knadh/koanf/providers/env v1.1.0
github.com/knadh/koanf/providers/file v1.2.0
github.com/knadh/koanf/providers/posflag v1.0.1
github.com/knadh/koanf/v2 v2.2.1
github.com/lucasb-eyer/go-colorful v1.2.0
github.com/mattn/go-sqlite3 v1.14.22
github.com/mitchellh/mapstructure v1.5.0
github.com/mattn/go-sqlite3 v1.14.17
github.com/natefinch/pie v0.0.0-20170715172608-9a0d72014007
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8
github.com/remeh/sizedwaitgroup v1.0.0
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06
github.com/robertkrimen/otto v0.0.0-20200922221731-ef014fd054ac
github.com/shurcooL/graphql v0.0.0-20181231061246-d48a9a75455f
github.com/sirupsen/logrus v1.9.3
github.com/spf13/cast v1.6.0
github.com/spf13/pflag v1.0.6
github.com/stretchr/testify v1.11.1
github.com/spf13/cast v1.5.1
github.com/spf13/pflag v1.0.5
github.com/spf13/viper v1.16.0
github.com/stretchr/testify v1.8.4
github.com/tidwall/gjson v1.16.0
github.com/vearutop/statigz v1.4.0
github.com/vektah/dataloaden v0.3.0
github.com/vektah/gqlparser/v2 v2.5.27
github.com/vektah/gqlparser/v2 v2.4.2
github.com/vektra/mockery/v2 v2.10.0
github.com/xWTF/chardet v0.0.0-20230208095535-c780f2ac244e
github.com/zencoder/go-dash/v3 v3.0.2
golang.org/x/crypto v0.48.0
golang.org/x/image v0.38.0
golang.org/x/net v0.50.0
golang.org/x/sys v0.41.0
golang.org/x/term v0.40.0
golang.org/x/text v0.35.0
golang.org/x/time v0.10.0
golang.org/x/crypto v0.17.0
golang.org/x/image v0.12.0
golang.org/x/net v0.17.0
golang.org/x/sys v0.15.0
golang.org/x/term v0.15.0
golang.org/x/text v0.14.0
gopkg.in/guregu/null.v4 v4.0.0
gopkg.in/natefinch/lumberjack.v2 v2.2.1
gopkg.in/yaml.v2 v2.4.0
)
require (
github.com/agnivade/levenshtein v1.2.1 // indirect
github.com/andybalholm/brotli v1.2.1 // indirect
github.com/antchfx/xpath v1.3.6 // indirect
github.com/agnivade/levenshtein v1.1.1 // indirect
github.com/antchfx/xpath v1.2.3 // indirect
github.com/asticode/go-astikit v0.20.0 // indirect
github.com/asticode/go-astits v1.8.0 // indirect
github.com/chromedp/sysutil v1.1.0 // indirect
github.com/coder/websocket v1.8.12 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
github.com/chromedp/sysutil v1.0.0 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dlclark/regexp2 v1.7.0 // indirect
github.com/enetx/http v1.0.28 // indirect
github.com/enetx/http2 v1.0.26 // indirect
github.com/enetx/http3 v1.0.7 // indirect
github.com/enetx/iter v0.0.0-20250912135656-f1583323588f // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 // indirect
github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/fsnotify/fsnotify v1.6.0 // indirect
github.com/gobwas/httphead v0.1.0 // indirect
github.com/gobwas/pool v0.2.1 // indirect
github.com/gobwas/ws v1.4.0 // indirect
github.com/goccy/go-yaml v1.18.0 // indirect
github.com/gobwas/ws v1.3.0 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/google/pprof v0.0.0-20230207041349-798e818bf904 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/hashicorp/golang-lru v0.5.4 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/klauspost/compress v1.18.5 // indirect
github.com/knadh/koanf/maps v0.1.2 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/magiconair/properties v1.8.7 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mitchellh/copystructure v1.2.0 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/matryer/moq v0.2.3 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/mitchellh/reflectwalk v1.0.2 // 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
@@ -114,28 +92,21 @@ require (
github.com/pelletier/go-toml/v2 v2.1.0 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.0 // indirect
github.com/refraction-networking/utls v1.8.3-0.20260301010127-aa6edf4b11af // indirect
github.com/rs/zerolog v1.30.0 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/sosodev/duration v1.3.1 // indirect
github.com/spf13/afero v1.9.5 // indirect
github.com/spf13/cobra v1.7.0 // indirect
github.com/spf13/jwalterweatherman v1.1.0 // indirect
github.com/spf13/viper v1.16.0 // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/stretchr/objx v0.5.0 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/urfave/cli/v2 v2.27.6 // indirect
github.com/wzshiming/socks5 v0.7.0 // indirect
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // 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.11.0 // indirect
go.yaml.in/yaml/v3 v3.0.3 // indirect
golang.org/x/mod v0.33.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/tools v0.42.0 // indirect
golang.org/x/mod v0.12.0 // indirect
golang.org/x/tools v0.13.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/sourcemap.v1 v1.0.5 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

333
go.sum
View File

@@ -49,25 +49,23 @@ cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RX
cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
cloud.google.com/go/storage v1.14.0/go.mod h1:GrKmX003DSIwi9o29oFT7YDnHYwZoctc3fOKtUw0Xmo=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/99designs/gqlgen v0.17.73 h1:A3Ki+rHWqKbAOlg5fxiZBnz6OjW3nwupDHEG15gEsrg=
github.com/99designs/gqlgen v0.17.73/go.mod h1:2RyGWjy2k7W9jxrs8MOQthXGkD3L3oGr0jXW3Pu8lGg=
github.com/99designs/gqlgen v0.17.2 h1:yczvlwMsfcVu/JtejqfrLwXuSP0yZFhmcss3caEvHw8=
github.com/99designs/gqlgen v0.17.2/go.mod h1:K5fzLKwtph+FFgh9j7nFbRUdBKvTcGnsta51fsMTn3o=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60=
github.com/DATA-DOG/go-sqlmock v1.5.0/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM=
github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ=
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
github.com/PuerkitoBio/goquery v1.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiUkhzPo=
github.com/PuerkitoBio/goquery v1.10.3/go.mod h1:tMUX0zDMHXYlAQk6p35XxQMqMweEKB7iK7iLNd4RH4Y=
github.com/RoaringBitmap/roaring v0.4.7/go.mod h1:8khRDP4HmeXns4xIj9oGrKSz7XTQiJx2zgh7AcNke4w=
github.com/WithoutPants/sortorder v0.0.0-20230616003020-921c9ef69552 h1:eukVk+mGmbSZppLw8WJGpEUgMC570eb32y7FOsPW4Kc=
github.com/WithoutPants/sortorder v0.0.0-20230616003020-921c9ef69552/go.mod h1:LKbO1i6L1lSlwWx4NHWVECxubHNKFz2YQoEMGXAFVy8=
github.com/Yamashou/gqlgenc v0.32.1 h1:EHs9//xQxXlyltkSFXM+fhO2rTXcWNw6FPKRJ6t+iQQ=
github.com/Yamashou/gqlgenc v0.32.1/go.mod h1:o5SxKt9d3+oUZ2i0V3CW8lHFyunfLR+KcKHubS4zf5E=
github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM=
github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU=
github.com/Yamashou/gqlgenc v0.0.6 h1:wfMTtuVSrX2N1z5/ssecxx+E7l1fa0FOq5mwFW47oY4=
github.com/Yamashou/gqlgenc v0.0.6/go.mod h1:WOXjogecRGpD1WKgxnnyHJo0/Dxn44p/LNRoE6mtFQo=
github.com/agnivade/levenshtein v1.0.1/go.mod h1:CURSv5d9Uaml+FovSIICkLbAUZ9S4RqaHDIsdSBg7lM=
github.com/agnivade/levenshtein v1.1.0/go.mod h1:veldBMzWxcCG2ZvUTKD2kJNRdCk5hVbJomOvKkmgYbo=
github.com/agnivade/levenshtein v1.1.1 h1:QY8M92nrzkmr798gCo3kmMyqXFzdQVpxLlGPRBij0P8=
github.com/agnivade/levenshtein v1.1.1/go.mod h1:veldBMzWxcCG2ZvUTKD2kJNRdCk5hVbJomOvKkmgYbo=
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
@@ -81,15 +79,11 @@ github.com/anacrolix/missinggo v1.1.0/go.mod h1:MBJu3Sk/k3ZfGYcS7z18gwfu72Ey/xop
github.com/anacrolix/tagflag v0.0.0-20180109131632-2146c8d41bf0/go.mod h1:1m2U/K6ZT+JZG0+bdMK6qauP49QT4wE5pmhJXOKKCHw=
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ=
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8=
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
github.com/antchfx/htmlquery v1.3.5 h1:aYthDDClnG2a2xePf6tys/UyyM/kRcsFRm+ifhFKoU0=
github.com/antchfx/htmlquery v1.3.5/go.mod h1:5oyIPIa3ovYGtLqMPNjBF2Uf25NPCKsMjCnQ8lvjaoA=
github.com/antchfx/xpath v1.3.5/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs=
github.com/antchfx/xpath v1.3.6 h1:s0y+ElRRtTQdfHP609qFu0+c6bglDv20pqOViQjjdPI=
github.com/antchfx/xpath v1.3.6/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs=
github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/cCs=
github.com/antchfx/htmlquery v1.3.0 h1:5I5yNFOVI+egyia5F2s/5Do2nFWxJz41Tr3DyfKD25E=
github.com/antchfx/htmlquery v1.3.0/go.mod h1:zKPDVTMhfOmcwxheXUsx4rKJy8KEY/PU6eXr/2SebQ8=
github.com/antchfx/xpath v1.2.3 h1:CCZWOzv5bAqjVv0offZ2LVgVYFbeldKQVuLNbViZdes=
github.com/antchfx/xpath v1.2.3/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs=
github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q=
github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE=
@@ -100,8 +94,8 @@ github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj
github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
github.com/asticode/go-astikit v0.20.0 h1:+7N+J4E4lWx2QOkRdOf6DafWJMv6O4RRfgClwQokrH8=
github.com/asticode/go-astikit v0.20.0/go.mod h1:h4ly7idim1tNhaVkdVBeXQZEE3L0xblP7fCWbgwipF0=
github.com/asticode/go-astisub v0.25.1 h1:RZMGfZPp7CXOkI6g+zCU7DRLuciGPGup921uKZnMXPI=
github.com/asticode/go-astisub v0.25.1/go.mod h1:WTkuSzFB+Bp7wezuSf2Oxulj5A8zu2zLRVFf6bIFQK8=
github.com/asticode/go-astisub v0.26.0 h1:Ka1oUyWzo/lIx7RX97GI1QdbClqYVxI0ExKuZRN/cDk=
github.com/asticode/go-astisub v0.26.0/go.mod h1:WTkuSzFB+Bp7wezuSf2Oxulj5A8zu2zLRVFf6bIFQK8=
github.com/asticode/go-astits v1.8.0 h1:rf6aiiGn/QhlFjNON1n5plqF3Fs025XLUwiQ0NB6oZg=
github.com/asticode/go-astits v1.8.0/go.mod h1:DkOWmBNQpnr9mv24KfZjq4JawCFX1FCqjLVGvO0DygQ=
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
@@ -109,7 +103,6 @@ github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+Ce
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs=
github.com/bool64/dev v0.2.28 h1:6ayDfrB/jnNr2iQAZHI+uT3Qi6rErSbJYQs1y8rSrwM=
github.com/bool64/dev v0.2.28/go.mod h1:iJbh1y/HkunEPhgebWRNcs8wfGq7sjvJ6W5iabL8ACg=
github.com/bradfitz/iter v0.0.0-20140124041915-454541ec3da2/go.mod h1:PyRFw1Lt2wKX4ZVSQ2mk+PeDa1rxyObEDlApuIsUKuo=
github.com/bradfitz/iter v0.0.0-20190303215204-33e6a9893b0c/go.mod h1:PyRFw1Lt2wKX4ZVSQ2mk+PeDa1rxyObEDlApuIsUKuo=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
@@ -117,18 +110,16 @@ github.com/census-instrumentation/opencensus-proto v0.3.0/go.mod h1:f6KPmirojxKA
github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d h1:ZtA1sedVbEW7EW80Iz2GR3Ye6PwbJAJXjv7D74xG6HU=
github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d/go.mod h1:NItd7aLkcfOA/dcMXvl8p1u+lQqioRMq/SqDp71Pb/k=
github.com/chromedp/chromedp v0.14.2 h1:r3b/WtwM50RsBZHMUm9fsNhhzRStTHrKdr2zmwbZSzM=
github.com/chromedp/chromedp v0.14.2/go.mod h1:rHzAv60xDE7VNy/MYtTUrYreSc0ujt2O1/C3bzctYBo=
github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM=
github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8=
github.com/chromedp/cdproto v0.0.0-20230802225258-3cf4e6d46a89/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs=
github.com/chromedp/cdproto v0.0.0-20231007061347-18b01cd81617 h1:/5dwcyi5WOawM1Iz6MjrYqB90TRIdZv3O0fVHEJb86w=
github.com/chromedp/cdproto v0.0.0-20231007061347-18b01cd81617/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs=
github.com/chromedp/chromedp v0.9.2 h1:dKtNz4kApb06KuSXoTQIyUC2TrA0fhGDwNZf3bcgfKw=
github.com/chromedp/chromedp v0.9.2/go.mod h1:LkSXJKONWTCHAfQasKFUZI+mxqS4tZqhmtGzzhLsnLs=
github.com/chromedp/sysutil v1.0.0 h1:+ZxhTpfpZlmchB58ih/LBHX52ky7w2VhQVKQMucy3Ic=
github.com/chromedp/sysutil v1.0.0/go.mod h1:kgWmDdq8fTzXYcKIBqIYvRRTnYb9aNS9moAV0xufSww=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/logex v1.2.0/go.mod h1:9+9sk7u7pGNWYMkh0hdiL++6OeibzJccyQU4p4MedaY=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/readline v1.5.0/go.mod h1:x22KAscuvRqlLoK9CsoYsmxoXZMMFVyOl86cAH8qUic=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/chzyer/test v0.0.0-20210722231415-061457976a23/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6Dob7S7YxXgwXpfOuvO54S+tGdZdw9fuRZt25Ag=
github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
@@ -142,50 +133,27 @@ github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWH
github.com/cncf/xds/go v0.0.0-20211001041855-01bcc9b48dfe/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
github.com/cncf/xds/go v0.0.0-20211130200136-a8f946100490/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo=
github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/corona10/goimagehash v1.1.0 h1:teNMX/1e+Wn/AYSbLHX8mj+mF9r60R1kBeqE9MkoYwI=
github.com/corona10/goimagehash v1.1.0/go.mod h1:VkvE0mLn84L4aF8vCb6mafVajEb6QYMHl2ZJLn0mOGI=
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
github.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w=
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo=
github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/denisenkom/go-mssqldb v0.10.0/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU=
github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54 h1:SG7nF6SRlWhcT7cNTs5R6Hk4V2lcmLz2NsG2VnInyNo=
github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA=
github.com/dgryski/trifles v0.0.0-20200323201526-dd97f9abfb48 h1:fRzb/w+pyskVMQ+UbP35JkH8yB7MYb4q/qhBarqZE6g=
github.com/dgryski/trifles v0.0.0-20200323201526-dd97f9abfb48/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA=
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/dlclark/regexp2 v1.4.1-0.20201116162257-a2a8dda75c91/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
github.com/dlclark/regexp2 v1.7.0 h1:7lJfhqlPssTb1WQx4yvTHN0uElPEv52sbaECrAQxjAo=
github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3ebgob9U8Nd0kOddGdZWjyMGR8Wziv+TBNwSE=
github.com/dop251/goja v0.0.0-20211022113120-dc8c55024d06/go.mod h1:R9ET47fwRVRPZnOGvHxxhuZcbrMCuiqOz3Rlrh4KSnk=
github.com/dop251/goja v0.0.0-20231027120936-b396bb4c349d h1:wi6jN5LVt/ljaBG4ue79Ekzb12QfJ52L9Q98tl8SWhw=
github.com/dop251/goja v0.0.0-20231027120936-b396bb4c349d/go.mod h1:QMWlm50DNe14hD7t24KEqZuUdC9sOTy8W6XbCU1mlw4=
github.com/dop251/goja_nodejs v0.0.0-20210225215109-d91c329300e7/go.mod h1:hn7BA7c8pLvoGndExHudxTDKZ84Pyvv+90pbBjbTz0Y=
github.com/dop251/goja_nodejs v0.0.0-20211022123610-8dd9abb0616d/go.mod h1:DngW8aVqWbuLRMHItjPUyqdj+HWPvnQe8V8y1nDpIbM=
github.com/doug-martin/goqu/v9 v9.18.0 h1:/6bcuEtAe6nsSMVK/M+fOiXUNfyFF3yYtE07DBPFMYY=
github.com/doug-martin/goqu/v9 v9.18.0/go.mod h1:nf0Wc2/hV3gYK9LiyqIrzBEVGlI8qW3GuDCEobC4wBQ=
github.com/dustin/go-humanize v0.0.0-20180421182945-02af3965c54e/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
github.com/enetx/g v1.0.224 h1:H/uonguFE4qG8YCn5bSpZX5Wh+wTSb+jgf3I2ZM25XM=
github.com/enetx/g v1.0.224/go.mod h1:lxhby3LjP8jOTGbxJ/PCd+2Zq1gYiSBbtL/llPhAg5c=
github.com/enetx/http v1.0.28 h1:IaNSSDFlAVVdHnYhNIR9wAN7GY4TWL/kkvYC3jOaueY=
github.com/enetx/http v1.0.28/go.mod h1:1f4mytfF/SfjATEJnynpwGS6aa1ALjb8DtmYgFVblY0=
github.com/enetx/http2 v1.0.26 h1:wy3lYGVwnIUY4Q+gyPPQCJ1a+BMXD1B7Unpyc/Csrxc=
github.com/enetx/http2 v1.0.26/go.mod h1:t54ex5HIS8V1+2j6cvEOv6umlrHsbUPFKQ54nYB58Nk=
github.com/enetx/http3 v1.0.7 h1:daFhveKBtv8rRallCjaHErzzSHIrq07ovoSvVkvhcMM=
github.com/enetx/http3 v1.0.7/go.mod h1:sqpVGZ9F1/wCiW6sjBUS2errKAh3SUYn6VlWE7LL6KM=
github.com/enetx/iter v0.0.0-20250912135656-f1583323588f h1:GUW+4AWfECIEJ9oAxgEAVGCpaozMCjRiUYnuR6Q0bCQ=
github.com/enetx/iter v0.0.0-20250912135656-f1583323588f/go.mod h1:oMZN8hGLUpi7QBlMEUqailocNy0NFAO/7Lu+Nwh9HMM=
github.com/enetx/surf v1.0.199 h1:RtqcwlyLM8O4U+43laNnNJwx5hALkH5cJRxDX1F2VjM=
github.com/enetx/surf v1.0.199/go.mod h1:c6g53gi273RBiZFO4THWIqpn5n9RLC6vw5WpUwHrT4U=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
@@ -200,19 +168,16 @@ github.com/envoyproxy/protoc-gen-validate v0.6.2/go.mod h1:2t7qjJNvHPx8IjnBOzl9E
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU=
github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
github.com/feederbox826/gosx-notifier v0.2.2 h1:26NkaJZ8Wzptx82R46c9pkVAcFwGSU7kxWrOKmRWlC0=
github.com/feederbox826/gosx-notifier v0.2.2/go.mod h1:R6rqw7VuwuiCuvsr7EOONmWq++CRA5Ijmkmx75/C3Fs=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/frankban/quicktest v1.14.4 h1:g2rn0vABPOOXmZUj+vbmUp0lPoXEMuhTpIluN0XL9UY=
github.com/fsnotify/fsnotify v1.5.1/go.mod h1:T3375wBYaZdLLcVNkcVbzGHY7f1l/uK5T5Ai1i3InKU=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY=
github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw=
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
github.com/glycerine/go-unsnap-stream v0.0.0-20180323001048-9f0cb55181dd/go.mod h1:/20jfyN9Y5QPEAprSgKAUr+glWDY39ZiUEAYOEv5dsE=
github.com/glycerine/goconvey v0.0.0-20180728074245-46e3a41ad493/go.mod h1:Ogl1Tioa0aV7gstGFO7KhffUsb9M4ydbEbbxpcEDc24=
github.com/go-chi/chi/v5 v5.0.7/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/go-chi/chi/v5 v5.2.2 h1:CMwsvRVTbXVytCk1Wd72Zy1LAsAh9GxMmSNWLHCG618=
github.com/go-chi/chi/v5 v5.2.2/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/go-chi/chi/v5 v5.0.10 h1:rLz5avzKpjqxrYwXNfmjkrYYXOyLJd37pz53UFHC6vk=
github.com/go-chi/chi/v5 v5.0.10/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4=
github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
github.com/go-chi/httplog v0.3.1 h1:uC3IUWCZagtbCinb3ypFh36SEcgd6StWw2Bu0XSXRtg=
@@ -220,35 +185,29 @@ github.com/go-chi/httplog v0.3.1/go.mod h1:UoiQQ/MTZH5V6JbNB2FzF0DynTh5okpXxlhsy
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 h1:iizUGZ9pEquQS5jTGkh4AqeeHCMbfbjeb0zMt0aEFzs=
github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M=
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU=
github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE=
github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/go-toast/toast v0.0.0-20190211030409-01e6764cf0a4 h1:qZNfIGkIANxGv/OqtnntR4DfOY2+BgwR60cAcu/i3SE=
github.com/go-toast/toast v0.0.0-20190211030409-01e6764cf0a4/go.mod h1:kW3HQ4UdaAyrUCSSDR4xUzBKW6O2iA4uHhk7AtyYp10=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/gobwas/ws v1.2.1/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY=
github.com/gobwas/ws v1.3.0 h1:sbeU3Y4Qzlb+MOzIe6mQGf7QR4Hkv6ZD0qhGkBFL2O0=
github.com/gobwas/ws v1.3.0/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gofrs/uuid/v5 v5.0.0 h1:p544++a97kEL+svbcFbCQVM9KFu0Yo25UoISXGNNH9M=
github.com/gofrs/uuid/v5 v5.0.0/go.mod h1:CDOjlDMVAtN56jqyRUZh58JT31Tiw7/oQyEXZV+9bD8=
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg=
github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang-migrate/migrate/v4 v4.16.2 h1:8coYbMKUyInrFk1lfGfRovTLAW7PhWp8qQDT2iKfuoA=
github.com/golang-migrate/migrate/v4 v4.16.2/go.mod h1:pfcJX4nPHaVdc5nmdCikFBWtm+UBpiZjRNNsyBbp0/o=
github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
@@ -301,9 +260,7 @@ github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
@@ -324,12 +281,8 @@ github.com/google/pprof v0.0.0-20210226084205-cbba55b83ad5/go.mod h1:kpwsk12EmLe
github.com/google/pprof v0.0.0-20210601050228-01bbb1931b22/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20210609004039-a478d1d731e9/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20230207041349-798e818bf904 h1:4/hN5RUoecvl+RmJRE2YxKWtnnQls6rQjjW5oV7qg2U=
github.com/google/pprof v0.0.0-20230207041349-798e818bf904/go.mod h1:uglQLonpP8qtYCYyzA+8c/9qtqgA3qsXGYqCPKARAFg=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
github.com/googleapis/gax-go/v2 v2.1.0/go.mod h1:Q3nei7sK6ybPYH7twZdmQpAd1MKb7pfu6SK+H1/DsU0=
@@ -340,6 +293,7 @@ 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.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7FsgI=
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
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=
@@ -369,9 +323,10 @@ github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/b
github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc=
github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/hashicorp/golang-lru/v2 v2.0.6 h1:3xi/Cafd1NaoEnS/yDssIiuVeDVywU0QdFGl3aQaQHM=
github.com/hashicorp/golang-lru/v2 v2.0.6/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64=
@@ -381,20 +336,19 @@ github.com/hashicorp/memberlist v0.2.2/go.mod h1:MS2lj3INKhZjWNqd3N0m3J+Jxf3DAOn
github.com/hashicorp/memberlist v0.3.0/go.mod h1:MS2lj3INKhZjWNqd3N0m3J+Jxf3DAOnAH9VT3Sh9MUE=
github.com/hashicorp/serf v0.9.5/go.mod h1:UWDWwZeL5cuWDJdl0C6wrvrUwEqtQ4ZKBKKENpqIUyk=
github.com/hashicorp/serf v0.9.6/go.mod h1:TXZNMjZQijwlDvp+r0b63xZ45H7JmCmgg4gpTwn9UV4=
github.com/hasura/go-graphql-client v0.13.1 h1:kKbjhxhpwz58usVl+Xvgah/TDha5K2akNTRQdsEHN6U=
github.com/hasura/go-graphql-client v0.13.1/go.mod h1:k7FF7h53C+hSNFRG3++DdVZWIuHdCaTbI7siTJ//zGQ=
github.com/huandu/xstrings v1.0.0/go.mod h1:4qWG/gcEcfX4z/mBDHJ++3ReCw9ibxbsNJbcucJdbSo=
github.com/iancoleman/strcase v0.2.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho=
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/ianlancetaylor/demangle v0.0.0-20220319035150-800ac71e25c2/go.mod h1:aYm2/VgdVmcIU8iMfdMvDMsRAQjcfZSKFby6HOFvi/w=
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jinzhu/copier v0.4.0 h1:w3ciUoD19shMCRargcpm0cm91ytaBhDvuRpz1ODO/U8=
github.com/jinzhu/copier v0.4.0/go.mod h1:DfbEm0FYsaqBcKcFuvmOZb218JkPGtvSHsKg8S8hyyg=
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
github.com/jmoiron/sqlx v1.3.5 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g=
github.com/jmoiron/sqlx v1.3.5/go.mod h1:nRVWtLre0KfCLJvgxzCsLVMogSvQ1zNJtpYr2Ccp0mQ=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
@@ -404,56 +358,45 @@ github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1
github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
github.com/jtolds/gls v4.2.1+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
github.com/kermieisinthehouse/gosx-notifier v0.1.2 h1:KV0KBeKK2B24kIHY7iK0jgS64Q05f4oB+hUZmsPodxQ=
github.com/kermieisinthehouse/gosx-notifier v0.1.2/go.mod h1:xyWT07azFtUOcHl96qMVvKhvKzsMcS7rKTHQyv8WTho=
github.com/kermieisinthehouse/systray v1.2.4 h1:pdH5vnl+KKjRrVCRU4g/2W1/0HVzuuJ6WXHlPPHYY6s=
github.com/kermieisinthehouse/systray v1.2.4/go.mod h1:axh6C/jNuSyC0QGtidZJURc9h+h41HNoMySoLVrhVR4=
github.com/kevinmbeaulieu/eq-go v1.0.0/go.mod h1:G3S8ajA56gKBZm4UB9AOyoOS37JO3roToPzKNM8dtdM=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo=
github.com/knadh/koanf/maps v0.1.2/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI=
github.com/knadh/koanf/parsers/yaml v1.1.0 h1:3ltfm9ljprAHt4jxgeYLlFPmUaunuCgu1yILuTXRdM4=
github.com/knadh/koanf/parsers/yaml v1.1.0/go.mod h1:HHmcHXUrp9cOPcuC+2wrr44GTUB0EC+PyfN3HZD9tFg=
github.com/knadh/koanf/providers/env v1.1.0 h1:U2VXPY0f+CsNDkvdsG8GcsnK4ah85WwWyJgef9oQMSc=
github.com/knadh/koanf/providers/env v1.1.0/go.mod h1:QhHHHZ87h9JxJAn2czdEl6pdkNnDh/JS1Vtsyt65hTY=
github.com/knadh/koanf/providers/file v1.2.0 h1:hrUJ6Y9YOA49aNu/RSYzOTFlqzXSCpmYIDXI7OJU6+U=
github.com/knadh/koanf/providers/file v1.2.0/go.mod h1:bp1PM5f83Q+TOUu10J/0ApLBd9uIzg+n9UgthfY+nRA=
github.com/knadh/koanf/providers/posflag v1.0.1 h1:EnMxHSrPkYCFnKgBUl5KBgrjed8gVFrcXDzaW4l/C6Y=
github.com/knadh/koanf/providers/posflag v1.0.1/go.mod h1:3Wn3+YG3f4ljzRyCUgIwH7G0sZ1pMjCOsNBovrbKmAk=
github.com/knadh/koanf/v2 v2.2.1 h1:jaleChtw85y3UdBnI0wCqcg1sj1gPoz6D3caGNHtrNE=
github.com/knadh/koanf/v2 v2.2.1/go.mod h1:PSFru3ufQgTsI7IF+95rf9s8XA1+aHxKuO/W+dPoHEY=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lib/pq v1.10.1/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/lib/pq v1.10.2 h1:AqzbZs4ZoCBp+GtejcpCpcxM3zlSMx29dXbUSeVtJb8=
github.com/logrusorgru/aurora/v3 v3.0.0/go.mod h1:vsR12bk5grlLvLXAYrBsb5Oc/N+LxAlxggSjiwMnCUc=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/lyft/protoc-gen-star v0.5.3/go.mod h1:V0xaHgaf5oCCqmcxYcWiDfTiKsZsRc87/1qhoTACD8w=
github.com/magiconair/properties v1.8.5/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60=
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
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/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcMEpPG5Rm84=
@@ -461,29 +404,26 @@ github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOA
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
github.com/mattn/go-sqlite3 v1.14.7/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM=
github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg=
github.com/miekg/dns v1.1.26/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso=
github.com/miekg/dns v1.1.41/go.mod h1:p6aan82bvRIyn+zDIv9xYNUpwa73JcSh9BKwknJysuI=
github.com/mitchellh/cli v1.1.0/go.mod h1:xcISNoH86gajksDmfB23e/pu+B+GeFRMYmoHXxx3xhI=
github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI=
github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/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/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/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
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=
@@ -532,19 +472,13 @@ github.com/prometheus/common v0.9.1/go.mod h1:yhUN8i9wzaXS3w1O07YhxHEBxD+W35wd8b
github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/refraction-networking/utls v1.8.3-0.20260301010127-aa6edf4b11af h1:er2acxbi3N1nvEq6HXHUAR1nTWEJmQfqiGR8EVT9rfs=
github.com/refraction-networking/utls v1.8.3-0.20260301010127-aa6edf4b11af/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
github.com/remeh/sizedwaitgroup v1.0.0 h1:VNGGFwNo/R5+MJBf6yrsr110p0m4/OX4S3DCy7Kyl5E=
github.com/remeh/sizedwaitgroup v1.0.0/go.mod h1:3j2R4OIe/SeS6YDhICBy22RWjJC5eNCJ1V+9+NVNYlo=
github.com/robertkrimen/otto v0.0.0-20200922221731-ef014fd054ac h1:kYPjbEN6YPYWWHI6ky1J813KzIq/8+Wg4TO4xU7A/KU=
github.com/robertkrimen/otto v0.0.0-20200922221731-ef014fd054ac/go.mod h1:xvqspoSXJTIpemEonrMDFq6XzwHYYgToXWj5eRX1OtY=
github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rs/xid v1.3.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/rs/xid v1.4.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
@@ -552,27 +486,25 @@ github.com/rs/zerolog v1.26.1/go.mod h1:/wSSJWX7lVrsOwlbyTRSOJvqRlc+WjWlfes+CiJ+
github.com/rs/zerolog v1.29.1/go.mod h1:Le6ESbR7hc+DP6Lt1THiV8CQSdkkNrd3R0XbEgp3ZBU=
github.com/rs/zerolog v1.30.0 h1:SymVODrcRsaRaSInD9yQtKbtWqwsfoPcRff/oRXLj4c=
github.com/rs/zerolog v1.30.0/go.mod h1:/tk+P47gFdPXq4QYjvCmT5/Gsug2nagsFWBWhAiSi1w=
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd h1:CmH9+J6ZSsIjUK3dcGsnCnO41eRBOnY12zwkn5qVwgc=
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk=
github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46/go.mod h1:uAQ5PCi+MFsC7HjREoAz1BU+Mq60+05gifQSsHSDG/8=
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 h1:OkMGxebDjyw0ULyrTYWeN0UNCCkmCWfjPnIA2W6oviI=
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06/go.mod h1:+ePHsJ1keEjQtpvf9HHw0f4ZeJ0TLRsxhunSI2hYJSs=
github.com/sagikazarmark/crypt v0.3.0/go.mod h1:uD/D+6UF4SrIR1uGEv7bBNkNqLGqUr43MRiaGWX1Nig=
github.com/sagikazarmark/crypt v0.4.0/go.mod h1:ALv2SRj7GxYV4HO9elxH9nS6M9gW+xDNxqmyJ6RfDFM=
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
github.com/shurcooL/graphql v0.0.0-20181231061246-d48a9a75455f h1:tygelZueB1EtXkPI6mQ4o9DQ0+FKW41hTbunoXZCTqk=
github.com/shurcooL/graphql v0.0.0-20181231061246-d48a9a75455f/go.mod h1:AuYgA5Kyo4c7HfUmvRGs/6rGlMMV/6B1bVnB9JxJEEg=
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c/go.mod h1:XDJAKZRPZ1CvBcN2aX5YOUTYGHki24fSF0Iv48Ibg0s=
github.com/sosodev/duration v1.3.1 h1:qtHBDMQ6lvMQsL15g4aopM4HEfOaYuhWBw3NPTtlqq4=
github.com/sosodev/duration v1.3.1/go.mod h1:RQIBBX0+fMLc/D9+Jb/fwvVmo0eZvDDEERAikUR6SDg=
github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
github.com/spf13/afero v1.3.3/go.mod h1:5KUK8ByomD5Ti5Artl0RtHeI5pTF7MIDuXL3yY520V4=
github.com/spf13/afero v1.6.0/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I=
@@ -580,16 +512,15 @@ github.com/spf13/afero v1.8.0/go.mod h1:CtAatgMJh6bJEIs48Ay/FOnkljP3WeGUG0MC1RfA
github.com/spf13/afero v1.9.5 h1:stMpOSZFs//0Lv29HduCmli3GUfpFoF3Y1Q/aXj/wVM=
github.com/spf13/afero v1.9.5/go.mod h1:UBogFpq8E9Hx+xc5CNTTEpTnuHVmXDwZcZcE1eb/UhQ=
github.com/spf13/cast v1.4.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/cast v1.5.1 h1:R+kOtfhWQE6TVQzY+4D7wJLBgkdVasCEFxSUBYBYIlA=
github.com/spf13/cast v1.5.1/go.mod h1:b9PdjNptOpzXr7Rq1q9gJML/2cdGQAo69NKzQ10KN48=
github.com/spf13/cobra v1.3.0/go.mod h1:BrRVncBjOJa/eUcVVm9CE+oC6as8k+VYr4NY7WCi9V4=
github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I=
github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0=
github.com/spf13/jwalterweatherman v1.1.0 h1:ue6voC5bR5F8YxI5S67j9i582FU4Qvo2bmqnqMYADFk=
github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.10.0/go.mod h1:SoyBPwAtKDzypXNDFKN5kzH7ppppbGZtls1UpIy5AsM=
github.com/spf13/viper v1.10.1/go.mod h1:IGlFPqhNAPKRxohIzWpI5QEy4kuI7tcl5WvR+8qy1rU=
github.com/spf13/viper v1.16.0 h1:rGGH0XDZhdUOryiDWjmIvUSWpbNqisK8Wk0Vyefw8hc=
@@ -597,9 +528,8 @@ github.com/spf13/viper v1.16.0/go.mod h1:yg78JgCJcbrQOvV9YLXgkLaZqUidkY9K+Dd1Fof
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.2.1/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
@@ -609,9 +539,8 @@ github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
@@ -624,25 +553,24 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
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.27.6 h1:VdRdS98FNhKZ8/Az8B7MTyGQmpIr36O1EHybx/LaZ4g=
github.com/urfave/cli/v2 v2.27.6/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ=
github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI=
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.4.0 h1:RQL0KG3j/uyA/PFpHeZ/L6l2ta920/MxlOAIGEOuwmU=
github.com/vearutop/statigz v1.4.0/go.mod h1:LYTolBLiz9oJISwiVKnOQoIwhO1LWX1A7OECawGS8XE=
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.5.27 h1:RHPD3JOplpk5mP5JGX8RKZkt2/Vwj/PZv0HxTdwFp0s=
github.com/vektah/gqlparser/v2 v2.5.27/go.mod h1:D1/VCZtV3LPnQrcPBeR/q5jkSQIPti0uYCP/RI0gIeo=
github.com/vektah/gqlparser/v2 v2.4.0/go.mod h1:flJWIR04IMQPGz+BXLrORkrARBxv/rtyIAFvd/MceW0=
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/wzshiming/socks5 v0.7.0 h1:euJ+U48WrvVngi+opC8vAnpZ5sK12y1C2hPvb1f48Rg=
github.com/wzshiming/socks5 v0.7.0/go.mod h1:BvCAqlzocQN5xwLjBZDBbvWlrx8sCYSSbHEOf2wZgT0=
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/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4=
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
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/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
@@ -667,12 +595,8 @@ go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqe
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko=
go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o=
go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
go.uber.org/zap v1.17.0/go.mod h1:MXVU+bhUf/A7Xi2HNOnopQOrmycQ5Ih87HtOu4q5SSo=
go.yaml.in/yaml/v3 v3.0.3 h1:bXOww4E/J3f66rav3pX3m8w6jDE4knZjGOw8b5Y6iNE=
go.yaml.in/yaml/v3 v3.0.3/go.mod h1:tBHosrYAkRZjRAOREWbDnBXUf08JOwYq++0QNwQiWzI=
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
@@ -690,12 +614,8 @@ golang.org/x/crypto v0.0.0-20211108221036-ceb1ce70b4fa/go.mod h1:GvvjBRRGRdwPK5y
golang.org/x/crypto v0.0.0-20211215165025-cf75a172585e/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
golang.org/x/crypto v0.0.0-20220112180741-5e0467b6c7ce/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k=
golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
@@ -709,8 +629,8 @@ golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMk
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-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.38.0 h1:5l+q+Y9JDC7mBOMjo4/aPhMDcxEptsX+Tt3GgRQRPuE=
golang.org/x/image v0.38.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY=
golang.org/x/image v0.12.0 h1:w13vZbU4o5rKOFFR8y7M+c4A5jXDC0uXTdHYRP8X2DQ=
golang.org/x/image v0.12.0/go.mod h1:Lu90jvHG7GfemOIcldsh9A2hS01ocl6oNO7ype5mEnk=
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=
@@ -736,13 +656,11 @@ golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.5.0/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro=
golang.org/x/mod v0.5.1/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro=
golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0 h1:rmsUpXtvNzj340zd98LZ4KntptpfRHwpFOHG188oHXc=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -791,14 +709,10 @@ golang.org/x/net v0.0.0-20210813160813-60bc85c4be6d/go.mod h1:9nx3DQGgdP8bBQD5qx
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/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM=
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@@ -829,12 +743,7 @@ golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJ
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/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E=
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=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -908,32 +817,24 @@ golang.org/x/sys v0.0.0-20211124211545-fe61309f8881/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20211205182925-97ca703d548d/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
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-20220310020820-b874c991c1a5/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-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.4.0/go.mod h1:9P2UbLfCdcvo3p/nzKvsmas4TnlujnuoV9hGgYzW1lQ=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
golang.org/x/term v0.15.0 h1:y/Oo/a/q3IXu26lQgl04j/gjuBDOBlx7X6Om1j2CPW4=
golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0=
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=
@@ -943,20 +844,14 @@ 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/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
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=
golang.org/x/time v0.10.0 h1:3usCWA8tQn0L8+hFJQNgzpWbd89begxN66o1Ojdn5L4=
golang.org/x/time v0.10.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
@@ -999,6 +894,7 @@ golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roY
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200815165600-90abf76919f3/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200904185747-39188db58858/go.mod h1:Cj7w3i3Rnn0Xh82ur9kSqwfTHTeVxaDqrfMjpcNT6bE=
golang.org/x/tools v0.0.0-20201110124207-079ba7bd75cd/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
@@ -1015,12 +911,12 @@ golang.org/x/tools v0.1.4/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/tools v0.1.7/go.mod h1:LGqMHiF4EqQNHR1JncWGqT5BVaXmza+X+BDGol+dOxo=
golang.org/x/tools v0.1.8/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU=
golang.org/x/tools v0.1.9/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU=
golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0 h1:Iey4qkscZuv0VvIt8E0neZjtPVQFSc870HQ448QgEmQ=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@@ -1175,9 +1071,8 @@ google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQ
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/guregu/null.v4 v4.0.0 h1:1Wm3S1WEA2I26Kq+6vcW+w0gcDo44YKYD7YIEJNHDjg=
gopkg.in/guregu/null.v4 v4.0.0/go.mod h1:YoQhUrADuG3i9WqesrCmpNRwm1ypAgSHYqoOcTu/JrI=
@@ -1185,8 +1080,8 @@ gopkg.in/ini.v1 v1.66.2/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/ini.v1 v1.66.3/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
gopkg.in/sourcemap.v1 v1.0.5 h1:inv58fC9f9J3TK2Y2R1NPntXEn3/wjWHkonhIUODNTI=
gopkg.in/sourcemap.v1 v1.0.5/go.mod h1:2RlvNNSMglmRrcvhfuzp4hQHwOtjxlbjX7UPY/GXb78=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=

View File

@@ -7,6 +7,9 @@ exec:
filename: internal/api/generated_exec.go
model:
filename: internal/api/generated_models.go
resolver:
filename: internal/api/resolver.go
type: Resolver
struct_tag: gqlgen
@@ -17,7 +20,7 @@ autobind:
- github.com/stashapp/stash/pkg/scraper
- github.com/stashapp/stash/internal/identify
- github.com/stashapp/stash/internal/dlna
- github.com/stashapp/stash/pkg/stashbox
- github.com/stashapp/stash/pkg/scraper/stashbox
models:
# Scalars
@@ -35,8 +38,12 @@ models:
model: github.com/stashapp/stash/internal/api.BoolMap
PluginConfigMap:
model: github.com/stashapp/stash/internal/api.PluginConfigMap
File:
model: github.com/stashapp/stash/internal/api.File
# define to force resolvers
Image:
model: github.com/stashapp/stash/pkg/models.Image
fields:
title:
resolver: true
VideoFile:
fields:
# override float fields - #1572
@@ -44,11 +51,6 @@ models:
fieldName: DurationFinite
frame_rate:
fieldName: FrameRateFinite
# movie is group under the hood
Movie:
model: github.com/stashapp/stash/pkg/models.Group
MovieFilterType:
model: github.com/stashapp/stash/pkg/models.GroupFilterType
# autobind on config causes generation issues
BlobsStorageType:
model: github.com/stashapp/stash/internal/manager/config.BlobsStorageType
@@ -125,6 +127,9 @@ models:
model: github.com/stashapp/stash/internal/identify.FieldStrategy
ScraperSource:
model: github.com/stashapp/stash/pkg/scraper.Source
# rebind inputs to types
StashIDInput:
model: github.com/stashapp/stash/pkg/models.StashID
IdentifySourceInput:
model: github.com/stashapp/stash/internal/identify.Source
IdentifyFieldOptionsInput:
@@ -140,8 +145,4 @@ models:
fields:
plugins:
resolver: true
Performer:
fields:
career_length:
resolver: true

View File

@@ -4,27 +4,6 @@ type Query {
findSavedFilter(id: ID!): SavedFilter
findSavedFilters(mode: FilterMode): [SavedFilter!]!
findDefaultFilter(mode: FilterMode!): SavedFilter
@deprecated(reason: "default filter now stored in UI config")
"Find a file by its id or path"
findFile(id: ID, path: String): BaseFile!
"Queries for Files"
findFiles(
file_filter: FileFilterType
filter: FindFilterType
ids: [ID!]
): FindFilesResultType!
"Find a file by its id or path"
findFolder(id: ID, path: String): Folder!
"Queries for Files"
findFolders(
folder_filter: FolderFilterType
filter: FindFilterType
ids: [ID!]
): FindFoldersResultType!
"Find a scene by ID or Checksum"
findScene(id: ID, checksum: String): Scene
@@ -51,7 +30,6 @@ type Query {
Fractional seconds are ok: 0.5 will mean only files that have durations within 0.5 seconds between them will be matched based on PHash distance.
"""
duration_diff: Float
scene_filter: SceneFilterType
): [[Scene!]!]!
"Return valid stream paths"
@@ -66,7 +44,6 @@ type Query {
findSceneMarkers(
scene_marker_filter: SceneMarkerFilterType
filter: FindFilterType
ids: [ID!]
): FindSceneMarkersResultType!
findImage(id: ID, checksum: String): Image
@@ -99,22 +76,13 @@ type Query {
): FindStudiosResultType!
"Find a movie by ID"
findMovie(id: ID!): Movie @deprecated(reason: "Use findGroup instead")
findMovie(id: ID!): Movie
"A function which queries Movie objects"
findMovies(
movie_filter: MovieFilterType
filter: FindFilterType
ids: [ID!]
): FindMoviesResultType! @deprecated(reason: "Use findGroups instead")
"Find a group by ID"
findGroup(id: ID!): Group
"A function which queries Group objects"
findGroups(
group_filter: GroupFilterType
filter: FindFilterType
ids: [ID!]
): FindGroupsResultType!
): FindMoviesResultType!
findGallery(id: ID!): Gallery
findGalleries(
@@ -166,12 +134,6 @@ type Query {
input: ScrapeSingleStudioInput!
): [ScrapedStudio!]!
"Scrape for a single tag"
scrapeSingleTag(
source: ScraperSourceInput!
input: ScrapeSingleTagInput!
): [ScrapedTag!]!
"Scrape for a single performer"
scrapeSinglePerformer(
source: ScraperSourceInput!
@@ -193,19 +155,7 @@ type Query {
scrapeSingleMovie(
source: ScraperSourceInput!
input: ScrapeSingleMovieInput!
): [ScrapedMovie!]! @deprecated(reason: "Use scrapeSingleGroup instead")
"Scrape for a single group"
scrapeSingleGroup(
source: ScraperSourceInput!
input: ScrapeSingleGroupInput!
): [ScrapedGroup!]!
"Scrape for a single image"
scrapeSingleImage(
source: ScraperSourceInput!
input: ScrapeSingleImageInput!
): [ScrapedImage!]!
): [ScrapedMovie!]!
"Scrapes content based on a URL"
scrapeURL(url: String!, ty: ScrapeContentType!): ScrapedContent
@@ -216,13 +166,8 @@ type Query {
scrapeSceneURL(url: String!): ScrapedScene
"Scrapes a complete gallery record based on a URL"
scrapeGalleryURL(url: String!): ScrapedGallery
"Scrapes a complete image record based on a URL"
scrapeImageURL(url: String!): ScrapedImage
"Scrapes a complete movie record based on a URL"
scrapeMovieURL(url: String!): ScrapedMovie
@deprecated(reason: "Use scrapeGroupURL instead")
"Scrapes a complete group record based on a URL"
scrapeGroupURL(url: String!): ScrapedGroup
# Plugins
"List loaded plugins"
@@ -268,7 +213,7 @@ type Query {
allPerformers: [Performer!]!
allTags: [Tag!]! @deprecated(reason: "Use findTags instead")
allStudios: [Studio!]! @deprecated(reason: "Use findStudios instead")
allMovies: [Movie!]! @deprecated(reason: "Use findGroups instead")
allMovies: [Movie!]! @deprecated(reason: "Use findMovies instead")
# Get everything with minimal metadata
@@ -281,12 +226,7 @@ type Query {
type Mutation {
setup(input: SetupInput!): Boolean!
"Migrates the schema to the required version. Returns the job ID"
migrate(input: MigrateInput!): ID!
"Downloads and installs ffmpeg and ffprobe binaries into the configuration directory. Returns the job ID."
downloadFFMpeg: ID!
migrate(input: MigrateInput!): Boolean!
sceneCreate(input: SceneCreateInput!): Scene
sceneUpdate(input: SceneUpdateInput!): Scene
@@ -312,13 +252,6 @@ type Mutation {
"Sets the resume time point (if provided) and adds the provided duration to the scene's play duration"
sceneSaveActivity(id: ID!, resume_time: Float, playDuration: Float): Boolean!
"Resets the resume time point and play duration"
sceneResetActivity(
id: ID!
reset_resume: Boolean
reset_duration: Boolean
): Boolean!
"Increments the play count for the scene. Returns the new play count value."
sceneIncrementPlayCount(id: ID!): Int!
@deprecated(reason: "Use sceneAddPlay instead")
@@ -335,9 +268,7 @@ type Mutation {
sceneMarkerCreate(input: SceneMarkerCreateInput!): SceneMarker
sceneMarkerUpdate(input: SceneMarkerUpdateInput!): SceneMarker
bulkSceneMarkerUpdate(input: BulkSceneMarkerUpdateInput!): [SceneMarker!]
sceneMarkerDestroy(id: ID!): Boolean!
sceneMarkersDestroy(ids: [ID!]!): Boolean!
sceneAssignFile(input: AssignSceneFileInput!): Boolean!
@@ -362,8 +293,6 @@ type Mutation {
addGalleryImages(input: GalleryAddInput!): Boolean!
removeGalleryImages(input: GalleryRemoveInput!): Boolean!
setGalleryCover(input: GallerySetCoverInput!): Boolean!
resetGalleryCover(input: GalleryResetCoverInput!): Boolean!
galleryChapterCreate(input: GalleryChapterCreateInput!): GalleryChapter
galleryChapterUpdate(input: GalleryChapterUpdateInput!): GalleryChapter
@@ -374,43 +303,23 @@ type Mutation {
performerDestroy(input: PerformerDestroyInput!): Boolean!
performersDestroy(ids: [ID!]!): Boolean!
bulkPerformerUpdate(input: BulkPerformerUpdateInput!): [Performer!]
performerMerge(input: PerformerMergeInput!): Performer!
studioCreate(input: StudioCreateInput!): Studio
studioUpdate(input: StudioUpdateInput!): Studio
studioDestroy(input: StudioDestroyInput!): Boolean!
studiosDestroy(ids: [ID!]!): Boolean!
bulkStudioUpdate(input: BulkStudioUpdateInput!): [Studio!]
movieCreate(input: MovieCreateInput!): Movie
@deprecated(reason: "Use groupCreate instead")
movieUpdate(input: MovieUpdateInput!): Movie
@deprecated(reason: "Use groupUpdate instead")
movieDestroy(input: MovieDestroyInput!): Boolean!
@deprecated(reason: "Use groupDestroy instead")
moviesDestroy(ids: [ID!]!): Boolean!
@deprecated(reason: "Use groupsDestroy instead")
bulkMovieUpdate(input: BulkMovieUpdateInput!): [Movie!]
@deprecated(reason: "Use bulkGroupUpdate instead")
groupCreate(input: GroupCreateInput!): Group
groupUpdate(input: GroupUpdateInput!): Group
groupDestroy(input: GroupDestroyInput!): Boolean!
groupsDestroy(ids: [ID!]!): Boolean!
bulkGroupUpdate(input: BulkGroupUpdateInput!): [Group!]
addGroupSubGroups(input: GroupSubGroupAddInput!): Boolean!
removeGroupSubGroups(input: GroupSubGroupRemoveInput!): Boolean!
"Reorder sub groups within a group. Returns true if successful."
reorderSubGroups(input: ReorderSubGroupsInput!): Boolean!
tagCreate(input: TagCreateInput!): Tag
tagUpdate(input: TagUpdateInput!): Tag
tagDestroy(input: TagDestroyInput!): Boolean!
tagsDestroy(ids: [ID!]!): Boolean!
tagsMerge(input: TagsMergeInput!): Tag
bulkTagUpdate(input: BulkTagUpdateInput!): [Tag!]
"""
Moves the given files to the given destination. Returns true if successful.
@@ -423,20 +332,13 @@ type Mutation {
"""
moveFiles(input: MoveFilesInput!): Boolean!
deleteFiles(ids: [ID!]!): Boolean!
"Deletes file entries from the database without deleting the files from the filesystem"
destroyFiles(ids: [ID!]!): Boolean!
fileSetFingerprints(input: FileSetFingerprintsInput!): Boolean!
"Reveal the file in the system file manager"
revealFileInFileManager(id: ID!): Boolean!
"Reveal the folder in the system file manager"
revealFolderInFileManager(id: ID!): Boolean!
# Saved filters
saveFilter(input: SaveFilterInput!): SavedFilter!
destroySavedFilter(input: DestroyFilterInput!): Boolean!
setDefaultFilter(input: SetDefaultFilterInput!): Boolean!
@deprecated(reason: "now uses UI config")
"Change general configuration options"
configureGeneral(input: ConfigGeneralInput!): ConfigGeneralResult!
@@ -447,19 +349,12 @@ type Mutation {
input: ConfigDefaultSettingsInput!
): ConfigDefaultSettingsResult!
"overwrites the entire plugin configuration for the given plugin"
# overwrites the entire plugin configuration for the given plugin
configurePlugin(plugin_id: ID!, input: Map!): Map!
"""
overwrites the UI configuration
if input is provided, then the entire UI configuration is replaced
if partial is provided, then the partial UI configuration is merged into the existing UI configuration
"""
configureUI(input: Map, partial: Map): Map!
"""
sets a single UI key value
key is a dot separated path to the value
"""
# overwrites the entire UI configuration
configureUI(input: Map!): Map!
# sets a single UI key value
configureUISetting(key: String!, value: Any): Map!
"Generate and set (or clear) API key"
@@ -584,8 +479,6 @@ type Mutation {
stashBoxBatchPerformerTag(input: StashBoxBatchTagInput!): String!
"Run batch studio tag task. Returns the job ID."
stashBoxBatchStudioTag(input: StashBoxBatchTagInput!): String!
"Run batch tag tag task. Returns the job ID."
stashBoxBatchTagTag(input: StashBoxBatchTagInput!): String!
"Enables DLNA for an optional duration. Has no effect if DLNA is enabled by default"
enableDLNA(input: EnableDLNAInput!): Boolean!

View File

@@ -2,8 +2,6 @@ input SetupInput {
"Empty to indicate $HOME/.stash/config.yml default"
configLocation: String!
stashes: [StashConfigInput!]!
"True if SFW content mode is enabled"
sfwContentMode: Boolean
"Empty to indicate default"
databaseFile: String!
"Empty to indicate default"
@@ -69,8 +67,6 @@ input ConfigGeneralInput {
databasePath: String
"Path to backup directory"
backupDirectoryPath: String
"Path to trash directory - if set, deleted files will be moved here instead of being permanently deleted"
deleteTrashPath: String
"Path to generated files"
generatedPath: String
"Path to import/export files"
@@ -85,10 +81,6 @@ input ConfigGeneralInput {
blobsPath: String
"Where to store blobs"
blobsStorage: BlobsStorageType
"Path to the ffmpeg binary. If empty, stash will attempt to find it in the path or config directory"
ffmpegPath: String
"Path to the ffprobe binary. If empty, stash will attempt to find it in the path or config directory"
ffprobePath: String
"Whether to calculate MD5 checksums for scene video files"
calculateMD5: Boolean
"Hash algorithm to use for generated file naming"
@@ -157,8 +149,6 @@ input ConfigGeneralInput {
logLevel: String
"Whether to log http access"
logAccess: Boolean
"Maximum log size"
logFileMaxSize: Int
"True if galleries should be created from folders with images"
createGalleriesFromFolders: Boolean
"Regex used to identify images as gallery covers"
@@ -184,18 +174,6 @@ input ConfigGeneralInput {
scraperPackageSources: [PackageSourceInput!]
"Source of plugin packages"
pluginPackageSources: [PackageSourceInput!]
"Size of the longest dimension for each sprite in pixels"
spriteScreenshotSize: Int
"True if sprite generation should use the sprite interval and min/max sprites settings instead of the default"
useCustomSpriteInterval: Boolean
"Time between two different scrubber sprites in seconds - only used if useCustomSpriteInterval is true"
spriteInterval: Float
"Minimum number of sprites to be generated - only used if useCustomSpriteInterval is true"
minimumSprites: Int
"Minimum number of sprites to be generated - only used if useCustomSpriteInterval is true"
maximumSprites: Int
}
type ConfigGeneralResult {
@@ -205,8 +183,6 @@ type ConfigGeneralResult {
databasePath: String!
"Path to backup directory"
backupDirectoryPath: String!
"Path to trash directory - if set, deleted files will be moved here instead of being permanently deleted"
deleteTrashPath: String!
"Path to generated files"
generatedPath: String!
"Path to import/export files"
@@ -223,10 +199,6 @@ type ConfigGeneralResult {
blobsPath: String!
"Where to store blobs"
blobsStorage: BlobsStorageType!
"Path to the ffmpeg binary. If empty, stash will attempt to find it in the path or config directory"
ffmpegPath: String!
"Path to the ffprobe binary. If empty, stash will attempt to find it in the path or config directory"
ffprobePath: String!
"Whether to calculate MD5 checksums for scene video files"
calculateMD5: Boolean!
"Hash algorithm to use for generated file naming"
@@ -297,18 +269,6 @@ type ConfigGeneralResult {
logLevel: String!
"Whether to log http access"
logAccess: Boolean!
"Maximum log size"
logFileMaxSize: Int!
"True if sprite generation should use the sprite interval and min/max sprites settings instead of the default"
useCustomSpriteInterval: Boolean!
"Time between two different scrubber sprites in seconds - only used if useCustomSpriteInterval is true"
spriteInterval: Float!
"Minimum number of sprites to be generated - only used if useCustomSpriteInterval is true"
minimumSprites: Int!
"Maximum number of sprites to be generated - only used if useCustomSpriteInterval is true"
maximumSprites: Int!
"Size of the longest dimension for each sprite in pixels"
spriteScreenshotSize: Int!
"Array of video file extensions"
videoExtensions: [String!]!
"Array of image file extensions"
@@ -341,7 +301,6 @@ input ConfigDisableDropdownCreateInput {
tag: Boolean
studio: Boolean
movie: Boolean
gallery: Boolean
}
enum ImageLightboxDisplayMode {
@@ -362,7 +321,6 @@ input ConfigImageLightboxInput {
resetZoomOnNav: Boolean
scrollMode: ImageLightboxScrollMode
scrollAttemptsBeforeChange: Int
disableAnimation: Boolean
}
type ConfigImageLightboxResult {
@@ -372,13 +330,9 @@ type ConfigImageLightboxResult {
resetZoomOnNav: Boolean
scrollMode: ImageLightboxScrollMode
scrollAttemptsBeforeChange: Int!
disableAnimation: Boolean
}
input ConfigInterfaceInput {
"True if SFW content mode is enabled"
sfwContentMode: Boolean
"Ordered list of items that should be shown in the menu"
menuItems: [String!]
@@ -417,9 +371,6 @@ input ConfigInterfaceInput {
customLocales: String
customLocalesEnabled: Boolean
"When true, disables all customizations (plugins, CSS, JavaScript, locales) for troubleshooting"
disableCustomizations: Boolean
"Interface language"
language: String
@@ -445,13 +396,9 @@ type ConfigDisableDropdownCreate {
tag: Boolean!
studio: Boolean!
movie: Boolean!
gallery: Boolean!
}
type ConfigInterfaceResult {
"True if SFW content mode is enabled"
sfwContentMode: Boolean!
"Ordered list of items that should be shown in the menu"
menuItems: [String!]
@@ -494,9 +441,6 @@ type ConfigInterfaceResult {
customLocales: String
customLocalesEnabled: Boolean
"When true, disables all customizations (plugins, CSS, JavaScript, locales) for troubleshooting"
disableCustomizations: Boolean
"Interface language"
language: String
@@ -517,8 +461,6 @@ input ConfigDLNAInput {
serverName: String
"True if DLNA service should be enabled by default"
enabled: Boolean
"Defaults to 1338"
port: Int
"List of IPs whitelisted for DLNA service"
whitelistedIPs: [String!]
"List of interfaces to run DLNA on. Empty for all"
@@ -531,8 +473,6 @@ type ConfigDLNAResult {
serverName: String!
"True if DLNA service should be enabled by default"
enabled: Boolean!
"Defaults to 1338"
port: Int!
"List of IPs whitelisted for DLNA service"
whitelistedIPs: [String!]!
"List of interfaces to run DLNA on. Empty for all"

View File

@@ -6,18 +6,9 @@ type Fingerprint {
type Folder {
id: ID!
path: String!
basename: String!
parent_folder_id: ID @deprecated(reason: "Use parent_folder instead")
zip_file_id: ID @deprecated(reason: "Use zip_file instead")
parent_folder: Folder
"Returns all parent folders in order from immediate parent to top-level"
parent_folders: [Folder!]!
zip_file: BasicFile
"Returns direct sub-folders"
sub_folders: [Folder!]!
parent_folder_id: ID
zip_file_id: ID
mod_time: Time!
@@ -30,32 +21,8 @@ interface BaseFile {
path: String!
basename: String!
parent_folder_id: ID! @deprecated(reason: "Use parent_folder instead")
zip_file_id: ID @deprecated(reason: "Use zip_file instead")
parent_folder: Folder!
zip_file: BasicFile
mod_time: Time!
size: Int64!
fingerprint(type: String!): String
fingerprints: [Fingerprint!]!
created_at: Time!
updated_at: Time!
}
type BasicFile implements BaseFile {
id: ID!
path: String!
basename: String!
parent_folder_id: ID! @deprecated(reason: "Use parent_folder instead")
zip_file_id: ID @deprecated(reason: "Use zip_file instead")
parent_folder: Folder!
zip_file: BasicFile
parent_folder_id: ID!
zip_file_id: ID
mod_time: Time!
size: Int64!
@@ -72,11 +39,8 @@ type VideoFile implements BaseFile {
path: String!
basename: String!
parent_folder_id: ID! @deprecated(reason: "Use parent_folder instead")
zip_file_id: ID @deprecated(reason: "Use zip_file instead")
parent_folder: Folder!
zip_file: BasicFile
parent_folder_id: ID!
zip_file_id: ID
mod_time: Time!
size: Int64!
@@ -93,8 +57,6 @@ type VideoFile implements BaseFile {
frame_rate: Float!
bit_rate: Int!
scenes: [Scene!]!
created_at: Time!
updated_at: Time!
}
@@ -104,11 +66,8 @@ type ImageFile implements BaseFile {
path: String!
basename: String!
parent_folder_id: ID! @deprecated(reason: "Use parent_folder instead")
zip_file_id: ID @deprecated(reason: "Use zip_file instead")
parent_folder: Folder!
zip_file: BasicFile
parent_folder_id: ID!
zip_file_id: ID
mod_time: Time!
size: Int64!
@@ -116,12 +75,9 @@ type ImageFile implements BaseFile {
fingerprint(type: String!): String
fingerprints: [Fingerprint!]!
format: String!
width: Int!
height: Int!
images: [Image!]!
created_at: Time!
updated_at: Time!
}
@@ -133,11 +89,8 @@ type GalleryFile implements BaseFile {
path: String!
basename: String!
parent_folder_id: ID! @deprecated(reason: "Use parent_folder instead")
zip_file_id: ID @deprecated(reason: "Use zip_file instead")
parent_folder: Folder!
zip_file: BasicFile
parent_folder_id: ID!
zip_file_id: ID
mod_time: Time!
size: Int64!
@@ -145,8 +98,6 @@ type GalleryFile implements BaseFile {
fingerprint(type: String!): String
fingerprints: [Fingerprint!]!
galleries: [Gallery!]!
created_at: Time!
updated_at: Time!
}
@@ -165,7 +116,7 @@ input MoveFilesInput {
input SetFingerprintsInput {
type: String!
"a null value will remove the fingerprint"
"an null value will remove the fingerprint"
value: String
}
@@ -174,22 +125,3 @@ input FileSetFingerprintsInput {
"only supplied fingerprint types will be modified"
fingerprints: [SetFingerprintsInput!]!
}
type FindFilesResultType {
count: Int!
"Total megapixels of any image files"
megapixels: Float!
"Total duration in seconds of any video files"
duration: Float!
"Total file size in bytes"
size: Int!
files: [BaseFile!]!
}
type FindFoldersResultType {
count: Int!
folders: [Folder!]!
}

View File

@@ -8,7 +8,6 @@ input FindFilterType {
page: Int
"use per_page = -1 to indicate all results. Defaults to 25."
per_page: Int
# TODO - this should be refactored to not use a string
sort: String
direction: SortDirectionEnum
}
@@ -75,51 +74,19 @@ input OrientationCriterionInput {
value: [OrientationEnum!]!
}
input DuplicationCriterionInput {
duplicated: Boolean @deprecated(reason: "Use phash field instead")
"Currently unimplemented. Intended for phash distance matching."
input PHashDuplicationCriterionInput {
duplicated: Boolean
"Currently unimplemented"
distance: Int
"Filter by phash duplication"
phash: Boolean
"Filter by URL duplication"
url: Boolean
"Filter by Stash ID duplication"
stash_id: Boolean
"Filter by title duplication"
title: Boolean
}
input FileDuplicationCriterionInput {
duplicated: Boolean @deprecated(reason: "Use phash field instead")
"Currently unimplemented. Intended for phash distance matching."
distance: Int
"Filter by phash duplication"
phash: Boolean
}
input StashIDCriterionInput {
"""
If present, this value is treated as a predicate.
That is, it will filter based on stash_id with the matching endpoint
"""
endpoint: String
stash_id: String
modifier: CriterionModifier!
}
input StashIDsCriterionInput {
"""
If present, this value is treated as a predicate.
That is, it will filter based on stash_ids with the matching endpoint
"""
endpoint: String
stash_ids: [String]
modifier: CriterionModifier!
}
input CustomFieldCriterionInput {
field: String!
value: [Any!]
stash_id: String
modifier: CriterionModifier!
}
@@ -152,15 +119,10 @@ input PerformerFilterType {
fake_tits: StringCriterionInput
"Filter by penis length value"
penis_length: FloatCriterionInput
"Filter by circumcision"
"Filter by ciricumcision"
circumcised: CircumcisionCriterionInput
"Deprecated: use career_start and career_end. This filter is non-functional."
"Filter by career length"
career_length: StringCriterionInput
@deprecated(reason: "Use career_start and career_end")
"Filter by career start"
career_start: DateCriterionInput
"Filter by career end"
career_end: DateCriterionInput
"Filter by tattoos"
tattoos: StringCriterionInput
"Filter by piercings"
@@ -177,21 +139,14 @@ input PerformerFilterType {
tag_count: IntCriterionInput
"Filter by scene count"
scene_count: IntCriterionInput
"Filter by marker count (via scene)"
marker_count: IntCriterionInput
"Filter by image count"
image_count: IntCriterionInput
"Filter by gallery count"
gallery_count: IntCriterionInput
"Filter by play count"
play_count: IntCriterionInput
"Filter by o count"
o_counter: IntCriterionInput
"Filter by StashID"
stash_id_endpoint: StashIDCriterionInput
@deprecated(reason: "use stash_ids_endpoint instead")
"Filter by StashIDs"
stash_ids_endpoint: StashIDsCriterionInput
# rating expressed as 1-100
rating100: IntCriterionInput
"Filter by url"
@@ -204,8 +159,6 @@ input PerformerFilterType {
death_year: IntCriterionInput
"Filter by studios where performer appears in scene/image/gallery"
studios: HierarchicalMultiCriterionInput
"Filter by groups where performer appears in scene"
groups: HierarchicalMultiCriterionInput
"Filter by performers where performer appears with another performer in scene/image/gallery"
performers: MultiCriterionInput
"Filter by autotag ignore value"
@@ -214,22 +167,10 @@ input PerformerFilterType {
birthdate: DateCriterionInput
"Filter by death date"
death_date: DateCriterionInput
"Filter by related scenes that meet this criteria"
scenes_filter: SceneFilterType
"Filter by related images that meet this criteria"
images_filter: ImageFilterType
"Filter by related galleries that meet this criteria"
galleries_filter: GalleryFilterType
"Filter by related tags that meet this criteria"
tags_filter: TagFilterType
"Filter by related scene markers (via scene) that meet this criteria"
markers_filter: SceneMarkerFilterType
"Filter by creation time"
created_at: TimestampCriterionInput
"Filter by last update time"
updated_at: TimestampCriterionInput
custom_fields: [CustomFieldCriterionInput!]
}
input SceneMarkerFilterType {
@@ -239,22 +180,16 @@ input SceneMarkerFilterType {
scene_tags: HierarchicalMultiCriterionInput
"Filter to only include scene markers with these performers"
performers: MultiCriterionInput
"Filter to only include scene markers from these scenes"
scenes: MultiCriterionInput
"Filter by duration (in seconds)"
duration: FloatCriterionInput
"Filter by creation time"
created_at: TimestampCriterionInput
"Filter by last update time"
updated_at: TimestampCriterionInput
"Filter by scene date"
scene_date: DateCriterionInput
"Filter by scene creation time"
"Filter by cscene reation time"
scene_created_at: TimestampCriterionInput
"Filter by scene last update time"
"Filter by lscene ast update time"
scene_updated_at: TimestampCriterionInput
"Filter by related scenes that meet this criteria"
scene_filter: SceneFilterType
}
input SceneFilterType {
@@ -286,16 +221,14 @@ input SceneFilterType {
organized: Boolean
"Filter by o-counter"
o_counter: IntCriterionInput
"Filter Scenes by duplication criteria"
duplicated: DuplicationCriterionInput
"Filter Scenes that have an exact phash match available"
duplicated: PHashDuplicationCriterionInput
"Filter by resolution"
resolution: ResolutionCriterionInput
"Filter by orientation"
orientation: OrientationCriterionInput
"Filter by frame rate"
framerate: IntCriterionInput
"Filter by bit rate"
bitrate: IntCriterionInput
"Filter by video codec"
video_codec: StringCriterionInput
"Filter by audio codec"
@@ -309,11 +242,7 @@ input SceneFilterType {
"Filter to only include scenes with this studio"
studios: HierarchicalMultiCriterionInput
"Filter to only include scenes with this movie"
movies: MultiCriterionInput @deprecated(reason: "use groups instead")
"Filter to only include scenes with this group"
groups: HierarchicalMultiCriterionInput
"Filter to only include scenes with this gallery"
galleries: MultiCriterionInput
movies: MultiCriterionInput
"Filter to only include scenes with these tags"
tags: HierarchicalMultiCriterionInput
"Filter by tag count"
@@ -330,11 +259,6 @@ input SceneFilterType {
performer_count: IntCriterionInput
"Filter by StashID"
stash_id_endpoint: StashIDCriterionInput
@deprecated(reason: "use stash_ids_endpoint instead")
"Filter by StashIDs"
stash_ids_endpoint: StashIDsCriterionInput
"Filter by StashID count"
stash_id_count: IntCriterionInput
"Filter by url"
url: StringCriterionInput
"Filter by interactive"
@@ -349,41 +273,15 @@ input SceneFilterType {
play_count: IntCriterionInput
"Filter by play duration (in seconds)"
play_duration: IntCriterionInput
"Filter by scene last played time"
last_played_at: TimestampCriterionInput
"Filter by date"
date: DateCriterionInput
"Filter by creation time"
created_at: TimestampCriterionInput
"Filter by last update time"
updated_at: TimestampCriterionInput
"Filter by related galleries that meet this criteria"
galleries_filter: GalleryFilterType
"Filter by related performers that meet this criteria"
performers_filter: PerformerFilterType
"Filter by related studios that meet this criteria"
studios_filter: StudioFilterType
"Filter by related tags that meet this criteria"
tags_filter: TagFilterType
"Filter by related movies that meet this criteria"
movies_filter: MovieFilterType
@deprecated(reason: "use groups_filter instead")
"Filter by related groups that meet this criteria"
groups_filter: GroupFilterType
"Filter by related markers that meet this criteria"
markers_filter: SceneMarkerFilterType
"Filter by related files that meet this criteria"
files_filter: FileFilterType
custom_fields: [CustomFieldCriterionInput!]
}
input MovieFilterType {
AND: MovieFilterType
OR: MovieFilterType
NOT: MovieFilterType
name: StringCriterionInput
director: StringCriterionInput
synopsis: StringCriterionInput
@@ -400,75 +298,12 @@ input MovieFilterType {
url: StringCriterionInput
"Filter to only include movies where performer appears in a scene"
performers: MultiCriterionInput
"Filter to only include movies with these tags"
tags: HierarchicalMultiCriterionInput
"Filter by tag count"
tag_count: IntCriterionInput
"Filter by date"
date: DateCriterionInput
"Filter by creation time"
created_at: TimestampCriterionInput
"Filter by last update time"
updated_at: TimestampCriterionInput
"Filter by related scenes that meet this criteria"
scenes_filter: SceneFilterType
"Filter by related studios that meet this criteria"
studios_filter: StudioFilterType
}
input GroupFilterType {
AND: GroupFilterType
OR: GroupFilterType
NOT: GroupFilterType
name: StringCriterionInput
director: StringCriterionInput
synopsis: StringCriterionInput
"Filter by duration (in seconds)"
duration: IntCriterionInput
# rating expressed as 1-100
rating100: IntCriterionInput
"Filter to only include groups with this studio"
studios: HierarchicalMultiCriterionInput
"Filter to only include groups missing this property"
is_missing: String
"Filter by url"
url: StringCriterionInput
"Filter to only include groups where performer appears in a scene"
performers: MultiCriterionInput
"Filter to only include groups with these tags"
tags: HierarchicalMultiCriterionInput
"Filter by tag count"
tag_count: IntCriterionInput
"Filter by date"
date: DateCriterionInput
"Filter by creation time"
created_at: TimestampCriterionInput
"Filter by last update time"
updated_at: TimestampCriterionInput
"Filter by o-counter"
o_counter: IntCriterionInput
"Filter by containing groups"
containing_groups: HierarchicalMultiCriterionInput
"Filter by sub groups"
sub_groups: HierarchicalMultiCriterionInput
"Filter by number of containing groups the group has"
containing_group_count: IntCriterionInput
"Filter by number of sub-groups the group has"
sub_group_count: IntCriterionInput
"Filter by number of scenes the group has"
scene_count: IntCriterionInput
"Filter by related scenes that meet this criteria"
scenes_filter: SceneFilterType
"Filter by related studios that meet this criteria"
studios_filter: StudioFilterType
"Filter by custom fields"
custom_fields: [CustomFieldCriterionInput!]
}
input StudioFilterType {
@@ -482,27 +317,16 @@ input StudioFilterType {
parents: MultiCriterionInput
"Filter by StashID"
stash_id_endpoint: StashIDCriterionInput
@deprecated(reason: "use stash_ids_endpoint instead")
"Filter by StashIDs"
stash_ids_endpoint: StashIDsCriterionInput
"Filter to only include studios with these tags"
tags: HierarchicalMultiCriterionInput
"Filter to only include studios missing this property"
is_missing: String
# rating expressed as 1-100
rating100: IntCriterionInput
"Filter by favorite"
favorite: Boolean
"Filter by scene count"
scene_count: IntCriterionInput
"Filter by image count"
image_count: IntCriterionInput
"Filter by gallery count"
gallery_count: IntCriterionInput
"Filter by group count"
group_count: IntCriterionInput
"Filter by tag count"
tag_count: IntCriterionInput
"Filter by url"
url: StringCriterionInput
"Filter by studio aliases"
@@ -511,22 +335,10 @@ input StudioFilterType {
child_count: IntCriterionInput
"Filter by autotag ignore value"
ignore_auto_tag: Boolean
"Filter by organized"
organized: Boolean
"Filter by related scenes that meet this criteria"
scenes_filter: SceneFilterType
"Filter by related images that meet this criteria"
images_filter: ImageFilterType
"Filter by related galleries that meet this criteria"
galleries_filter: GalleryFilterType
"Filter by related groups that meet this criteria"
groups_filter: GroupFilterType
"Filter by creation time"
created_at: TimestampCriterionInput
"Filter by last update time"
updated_at: TimestampCriterionInput
custom_fields: [CustomFieldCriterionInput!]
}
input GalleryFilterType {
@@ -556,8 +368,6 @@ input GalleryFilterType {
average_resolution: ResolutionCriterionInput
"Filter to only include galleries that have chapters. `true` or `false`"
has_chapters: String
"Filter to only include galleries with these scenes"
scenes: MultiCriterionInput
"Filter to only include galleries with this studio"
studios: HierarchicalMultiCriterionInput
"Filter to only include galleries with these tags"
@@ -588,25 +398,6 @@ input GalleryFilterType {
code: StringCriterionInput
"Filter by photographer"
photographer: StringCriterionInput
"Filter by related scenes that meet this criteria"
scenes_filter: SceneFilterType
"Filter by related images that meet this criteria"
images_filter: ImageFilterType
"Filter by related performers that meet this criteria"
performers_filter: PerformerFilterType
"Filter by related studios that meet this criteria"
studios_filter: StudioFilterType
"Filter by related tags that meet this criteria"
tags_filter: TagFilterType
"Filter by related files that meet this criteria"
files_filter: FileFilterType
"Filter by related folders that meet this criteria"
folders_filter: FolderFilterType
"Filter by parent folder of the zip or folder the gallery is in"
parent_folder: HierarchicalMultiCriterionInput
custom_fields: [CustomFieldCriterionInput!]
}
input TagFilterType {
@@ -617,15 +408,9 @@ input TagFilterType {
"Filter by tag name"
name: StringCriterionInput
"Filter by tag sort_name"
sort_name: StringCriterionInput
"Filter by tag aliases"
aliases: StringCriterionInput
"Filter by favorite"
favorite: Boolean
"Filter by tag description"
description: StringCriterionInput
@@ -633,28 +418,19 @@ input TagFilterType {
is_missing: String
"Filter by number of scenes with this tag"
scene_count: HierarchicalCountInput
scene_count: IntCriterionInput
"Filter by number of images with this tag"
image_count: HierarchicalCountInput
image_count: IntCriterionInput
"Filter by number of galleries with this tag"
gallery_count: HierarchicalCountInput
gallery_count: IntCriterionInput
"Filter by number of performers with this tag"
performer_count: HierarchicalCountInput
"Filter by number of studios with this tag"
studio_count: HierarchicalCountInput
"Filter by number of movies with this tag"
movie_count: HierarchicalCountInput
"Filter by number of group with this tag"
group_count: HierarchicalCountInput
performer_count: IntCriterionInput
"Filter by number of markers with this tag"
marker_count: HierarchicalCountInput
marker_count: IntCriterionInput
"Filter by parent tags"
parents: HierarchicalMultiCriterionInput
@@ -665,41 +441,17 @@ input TagFilterType {
"Filter by number of parent tags the tag has"
parent_count: IntCriterionInput
"Filter by number of child tags the tag has"
"Filter by number f child tags the tag has"
child_count: IntCriterionInput
"Filter by autotag ignore value"
ignore_auto_tag: Boolean
"Filter by StashID"
stash_id_endpoint: StashIDCriterionInput
@deprecated(reason: "use stash_ids_endpoint instead")
"Filter by StashID"
stash_ids_endpoint: StashIDsCriterionInput
"Filter by related scenes that meet this criteria"
scenes_filter: SceneFilterType
"Filter by related images that meet this criteria"
images_filter: ImageFilterType
"Filter by related galleries that meet this criteria"
galleries_filter: GalleryFilterType
"Filter by related groups that meet this criteria"
groups_filter: GroupFilterType
"Filter by related performers that meet this criteria"
performers_filter: PerformerFilterType
"Filter by related studios that meet this criteria"
studios_filter: StudioFilterType
"Filter by related scene markers that meet this criteria"
markers_filter: SceneMarkerFilterType
"Filter by creation time"
created_at: TimestampCriterionInput
"Filter by last update time"
updated_at: TimestampCriterionInput
custom_fields: [CustomFieldCriterionInput!]
}
input ImageFilterType {
@@ -714,8 +466,6 @@ input ImageFilterType {
id: IntCriterionInput
"Filter by file checksum"
checksum: StringCriterionInput
"Filter by file phash distance"
phash_distance: PhashDistanceCriterionInput
"Filter by path"
path: StringCriterionInput
"Filter by file count"
@@ -762,118 +512,6 @@ input ImageFilterType {
code: StringCriterionInput
"Filter by photographer"
photographer: StringCriterionInput
"Filter by related galleries that meet this criteria"
galleries_filter: GalleryFilterType
"Filter by related performers that meet this criteria"
performers_filter: PerformerFilterType
"Filter by related studios that meet this criteria"
studios_filter: StudioFilterType
"Filter by related tags that meet this criteria"
tags_filter: TagFilterType
"Filter by related files that meet this criteria"
files_filter: FileFilterType
"Filter by custom fields"
custom_fields: [CustomFieldCriterionInput!]
}
input FileFilterType {
AND: FileFilterType
OR: FileFilterType
NOT: FileFilterType
path: StringCriterionInput
basename: StringCriterionInput
dir: StringCriterionInput
parent_folder: HierarchicalMultiCriterionInput
zip_file: MultiCriterionInput
"Filter by modification time"
mod_time: TimestampCriterionInput
"Filter files by duplication criteria (only phash applies to files)"
duplicated: FileDuplicationCriterionInput
"find files based on hash"
hashes: [FingerprintFilterInput!]
video_file_filter: VideoFileFilterInput
image_file_filter: ImageFileFilterInput
scene_count: IntCriterionInput
image_count: IntCriterionInput
gallery_count: IntCriterionInput
"Filter by related scenes that meet this criteria"
scenes_filter: SceneFilterType
"Filter by related images that meet this criteria"
images_filter: ImageFilterType
"Filter by related galleries that meet this criteria"
galleries_filter: GalleryFilterType
"Filter by creation time"
created_at: TimestampCriterionInput
"Filter by last update time"
updated_at: TimestampCriterionInput
}
input FolderFilterType {
AND: FolderFilterType
OR: FolderFilterType
NOT: FolderFilterType
path: StringCriterionInput
basename: StringCriterionInput
parent_folder: HierarchicalMultiCriterionInput
zip_file: MultiCriterionInput
"Filter by modification time"
mod_time: TimestampCriterionInput
gallery_count: IntCriterionInput
"Filter by files that meet this criteria"
files_filter: FileFilterType
"Filter by related galleries that meet this criteria"
galleries_filter: GalleryFilterType
"Filter by creation time"
created_at: TimestampCriterionInput
"Filter by last update time"
updated_at: TimestampCriterionInput
}
input VideoFileFilterInput {
resolution: ResolutionCriterionInput
orientation: OrientationCriterionInput
framerate: IntCriterionInput
bitrate: IntCriterionInput
format: StringCriterionInput
video_codec: StringCriterionInput
audio_codec: StringCriterionInput
"in seconds"
duration: IntCriterionInput
captions: StringCriterionInput
interactive: Boolean
interactive_speed: IntCriterionInput
}
input ImageFileFilterInput {
format: StringCriterionInput
resolution: ResolutionCriterionInput
orientation: OrientationCriterionInput
}
input FingerprintFilterInput {
type: String!
value: String!
"Hamming distance - defaults to 0"
distance: Int
}
enum CriterionModifier {
@@ -914,14 +552,6 @@ input IntCriterionInput {
modifier: CriterionModifier!
}
input HierarchicalCountInput {
value: Int!
value2: Int
modifier: CriterionModifier!
"Number of descendant levels to include. Negative values include all descendants."
depth: Int
}
input FloatCriterionInput {
value: Float!
value2: Float
@@ -941,7 +571,7 @@ input GenderCriterionInput {
}
input CircumcisionCriterionInput {
value: [CircumcisedEnum!]
value: [CircumisedEnum!]
modifier: CriterionModifier!
}
@@ -977,7 +607,6 @@ enum FilterMode {
GALLERIES
SCENE_MARKERS
MOVIES
GROUPS
TAGS
IMAGES
}

View File

@@ -1,8 +1,3 @@
type GalleryPathsType {
cover: String!
preview: String! # Resolver
}
"Gallery type"
type Gallery {
id: ID!
@@ -30,10 +25,6 @@ type Gallery {
performers: [Performer!]!
cover: Image
paths: GalleryPathsType! # Resolver
custom_fields: Map!
image(index: Int!): Image!
}
input GalleryCreateInput {
@@ -51,8 +42,6 @@ input GalleryCreateInput {
studio_id: ID
tag_ids: [ID!]
performer_ids: [ID!]
custom_fields: Map
}
input GalleryUpdateInput {
@@ -74,8 +63,6 @@ input GalleryUpdateInput {
performer_ids: [ID!]
primary_file_id: ID
custom_fields: CustomFieldsInput
}
input BulkGalleryUpdateInput {
@@ -94,8 +81,6 @@ input BulkGalleryUpdateInput {
studio_id: ID
tag_ids: BulkUpdateIds
performer_ids: BulkUpdateIds
custom_fields: CustomFieldsInput
}
input GalleryDestroyInput {
@@ -107,8 +92,6 @@ input GalleryDestroyInput {
"""
delete_file: Boolean
delete_generated: Boolean
"If true, delete the file entry from the database if the file is not assigned to any other objects"
destroy_file_entry: Boolean
}
type FindGalleriesResultType {
@@ -125,12 +108,3 @@ input GalleryRemoveInput {
gallery_id: ID!
image_ids: [ID!]!
}
input GallerySetCoverInput {
gallery_id: ID!
cover_image_id: ID!
}
input GalleryResetCoverInput {
gallery_id: ID!
}

View File

@@ -1,148 +0,0 @@
"GroupDescription represents a relationship to a group with a description of the relationship"
type GroupDescription {
group: Group!
description: String
}
type Group {
id: ID!
name: String!
aliases: String
"Duration in seconds"
duration: Int
date: String
# rating expressed as 1-100
rating100: Int
studio: Studio
director: String
synopsis: String
urls: [String!]!
tags: [Tag!]!
created_at: Time!
updated_at: Time!
containing_groups: [GroupDescription!]!
sub_groups: [GroupDescription!]!
front_image_path: String # Resolver
back_image_path: String # Resolver
scene_count(depth: Int): Int! # Resolver
performer_count(depth: Int): Int! # Resolver
sub_group_count(depth: Int): Int! # Resolver
scenes: [Scene!]!
o_counter: Int # Resolver
custom_fields: Map!
}
input GroupDescriptionInput {
group_id: ID!
description: String
}
input GroupCreateInput {
name: String!
aliases: String
"Duration in seconds"
duration: Int
date: String
# rating expressed as 1-100
rating100: Int
studio_id: ID
director: String
synopsis: String
urls: [String!]
tag_ids: [ID!]
containing_groups: [GroupDescriptionInput!]
sub_groups: [GroupDescriptionInput!]
"This should be a URL or a base64 encoded data URL"
front_image: String
"This should be a URL or a base64 encoded data URL"
back_image: String
custom_fields: Map
}
input GroupUpdateInput {
id: ID!
name: String
aliases: String
duration: Int
date: String
# rating expressed as 1-100
rating100: Int
studio_id: ID
director: String
synopsis: String
urls: [String!]
tag_ids: [ID!]
containing_groups: [GroupDescriptionInput!]
sub_groups: [GroupDescriptionInput!]
"This should be a URL or a base64 encoded data URL"
front_image: String
"This should be a URL or a base64 encoded data URL"
back_image: String
custom_fields: CustomFieldsInput
}
input BulkUpdateGroupDescriptionsInput {
groups: [GroupDescriptionInput!]!
mode: BulkUpdateIdMode!
}
input BulkGroupUpdateInput {
clientMutationId: String
ids: [ID!]
# rating expressed as 1-100
rating100: Int
date: String
synopsis: String
studio_id: ID
director: String
urls: BulkUpdateStrings
tag_ids: BulkUpdateIds
containing_groups: BulkUpdateGroupDescriptionsInput
sub_groups: BulkUpdateGroupDescriptionsInput
custom_fields: CustomFieldsInput
}
input GroupDestroyInput {
id: ID!
}
input ReorderSubGroupsInput {
"ID of the group to reorder sub groups for"
group_id: ID!
"""
IDs of the sub groups to reorder. These must be a subset of the current sub groups.
Sub groups will be inserted in this order at the insert_index
"""
sub_group_ids: [ID!]!
"The sub-group ID at which to insert the sub groups"
insert_at_id: ID!
"If true, the sub groups will be inserted after the insert_index, otherwise they will be inserted before"
insert_after: Boolean
}
type FindGroupsResultType {
count: Int!
groups: [Group!]!
}
input GroupSubGroupAddInput {
containing_group_id: ID!
sub_groups: [GroupDescriptionInput!]!
"The index at which to insert the sub groups. If not provided, the sub groups will be appended to the end"
insert_index: Int
}
input GroupSubGroupRemoveInput {
containing_group_id: ID!
sub_group_ids: [ID!]!
}

View File

@@ -21,7 +21,6 @@ type Image {
studio: Studio
tags: [Tag!]!
performers: [Performer!]!
custom_fields: Map!
}
type ImageFileType {
@@ -57,7 +56,6 @@ input ImageUpdateInput {
gallery_ids: [ID!]
primary_file_id: ID
custom_fields: CustomFieldsInput
}
input BulkImageUpdateInput {
@@ -78,23 +76,18 @@ input BulkImageUpdateInput {
performer_ids: BulkUpdateIds
tag_ids: BulkUpdateIds
gallery_ids: BulkUpdateIds
custom_fields: CustomFieldsInput
}
input ImageDestroyInput {
id: ID!
delete_file: Boolean
delete_generated: Boolean
"If true, delete the file entry from the database if the file is not assigned to any other objects"
destroy_file_entry: Boolean
}
input ImagesDestroyInput {
ids: [ID!]!
delete_file: Boolean
delete_generated: Boolean
"If true, delete the file entry from the database if the file is not assigned to any other objects"
destroy_file_entry: Boolean
}
type FindImagesResultType {

View File

@@ -4,7 +4,6 @@ enum JobStatus {
FINISHED
STOPPING
CANCELLED
FAILED
}
type Job {
@@ -16,7 +15,6 @@ type Job {
startTime: Time
endTime: Time
addTime: Time!
error: String
}
input FindJobInput {

View File

@@ -10,11 +10,8 @@ input GenerateMetadataInput {
transcodes: Boolean
"Generate transcodes even if not required"
forceTranscodes: Boolean
"Generate video phashes during scan"
phashes: Boolean
interactiveHeatmapsSpeeds: Boolean
"Generate image phashes during scan"
imagePhashes: Boolean
imageThumbnails: Boolean
clipPreviews: Boolean
@@ -22,12 +19,6 @@ input GenerateMetadataInput {
sceneIDs: [ID!]
"marker ids to generate for"
markerIDs: [ID!]
"image ids to generate for"
imageIDs: [ID!]
"gallery ids to generate for"
galleryIDs: [ID!]
"paths to run generate on, in addition to the other ID lists"
paths: [String!]
"overwrite existing media"
overwrite: Boolean
@@ -84,8 +75,6 @@ input ScanMetaDataFilterInput {
input ScanMetadataInput {
paths: [String!]
"Forces a rescan on files even if modification time is unchanged"
rescan: Boolean
"Generate covers during scan"
scanGenerateCovers: Boolean
"Generate previews during scan"
@@ -94,10 +83,8 @@ input ScanMetadataInput {
scanGenerateImagePreviews: Boolean
"Generate sprites during scan"
scanGenerateSprites: Boolean
"Generate video phashes during scan"
"Generate phashes during scan"
scanGeneratePhashes: Boolean
"Generate image phashes during scan"
scanGenerateImagePhashes: Boolean
"Generate image thumbnails during scan"
scanGenerateThumbnails: Boolean
"Generate image clip previews during scan"
@@ -108,8 +95,6 @@ input ScanMetadataInput {
}
type ScanMetadataOptions {
"Forces a rescan on files even if modification time is unchanged"
rescan: Boolean!
"Generate covers during scan"
scanGenerateCovers: Boolean!
"Generate previews during scan"
@@ -118,10 +103,8 @@ type ScanMetadataOptions {
scanGenerateImagePreviews: Boolean!
"Generate sprites during scan"
scanGenerateSprites: Boolean!
"Generate video phashes during scan"
"Generate phashes during scan"
scanGeneratePhashes: Boolean!
"Generate image phashes during scan"
scanGenerateImagePhashes: Boolean
"Generate image thumbnails during scan"
scanGenerateThumbnails: Boolean!
"Generate image clip previews during scan"
@@ -131,14 +114,6 @@ type ScanMetadataOptions {
input CleanMetadataInput {
paths: [String!]
"""
Don't check zip file contents when determining whether to clean a file.
This can significantly speed up the clean process, but will potentially miss removed files within zip files.
Where users do not modify zip files contents directly, this should be safe to use.
Defaults to false.
"""
ignoreZipFileContents: Boolean
"Do a dry run. Don't delete any files"
dryRun: Boolean!
}
@@ -225,9 +200,7 @@ input IdentifyMetadataOptionsInput {
setCoverImage: Boolean
setOrganized: Boolean
"defaults to true if not provided"
includeMalePerformers: Boolean @deprecated(reason: "Use performerGenders")
"Filter to only include performers with these genders. If not provided, all genders are included."
performerGenders: [GenderEnum!]
includeMalePerformers: Boolean
"defaults to true if not provided"
skipMultipleMatches: Boolean
"tag to tag skipped multiple matches with"
@@ -272,9 +245,7 @@ type IdentifyMetadataOptions {
setCoverImage: Boolean
setOrganized: Boolean
"defaults to true if not provided"
includeMalePerformers: Boolean @deprecated(reason: "Use performerGenders")
"Filter to only include performers with these genders. If not provided, all genders are included."
performerGenders: [GenderEnum!]
includeMalePerformers: Boolean
"defaults to true if not provided"
skipMultipleMatches: Boolean
"tag to tag skipped multiple matches with"
@@ -309,8 +280,7 @@ input ExportObjectsInput {
studios: ExportObjectTypeInput
performers: ExportObjectTypeInput
tags: ExportObjectTypeInput
groups: ExportObjectTypeInput
movies: ExportObjectTypeInput @deprecated(reason: "Use groups instead")
movies: ExportObjectTypeInput
galleries: ExportObjectTypeInput
includeDependencies: Boolean
}
@@ -335,8 +305,6 @@ input ImportObjectsInput {
input BackupDatabaseInput {
download: Boolean
"If true, blob files will be included in the backup. This can significantly increase the size of the backup and the time it takes to create it, but allows for a complete backup of the system that can be restored without needing access to the original media files."
includeBlobs: Boolean
}
input AnonymiseDatabaseInput {
@@ -358,19 +326,8 @@ type SystemStatus {
os: String!
workingDir: String!
homeDir: String!
ffmpegPath: String
ffprobePath: String
}
input MigrateInput {
backupPath: String!
}
input CustomFieldsInput {
"If populated, the entire custom fields map will be replaced with this value"
full: Map
"If populated, only the keys in this map will be updated"
partial: Map
"Remove any keys in this list"
remove: [String!]
}

View File

@@ -10,15 +10,13 @@ type Movie {
studio: Studio
director: String
synopsis: String
url: String @deprecated(reason: "Use urls")
urls: [String!]!
tags: [Tag!]!
url: String
created_at: Time!
updated_at: Time!
front_image_path: String # Resolver
back_image_path: String # Resolver
scene_count(depth: Int): Int! # Resolver
scene_count: Int! # Resolver
scenes: [Scene!]!
}
@@ -33,9 +31,7 @@ input MovieCreateInput {
studio_id: ID
director: String
synopsis: String
url: String @deprecated(reason: "Use urls")
urls: [String!]
tag_ids: [ID!]
url: String
"This should be a URL or a base64 encoded data URL"
front_image: String
"This should be a URL or a base64 encoded data URL"
@@ -53,9 +49,7 @@ input MovieUpdateInput {
studio_id: ID
director: String
synopsis: String
url: String @deprecated(reason: "Use urls")
urls: [String!]
tag_ids: [ID!]
url: String
"This should be a URL or a base64 encoded data URL"
front_image: String
"This should be a URL or a base64 encoded data URL"
@@ -69,8 +63,6 @@ input BulkMovieUpdateInput {
rating100: Int
studio_id: ID
director: String
urls: BulkUpdateStrings
tag_ids: BulkUpdateIds
}
input MovieDestroyInput {

View File

@@ -7,7 +7,7 @@ enum GenderEnum {
NON_BINARY
}
enum CircumcisedEnum {
enum CircumisedEnum {
CUT
UNCUT
}
@@ -16,11 +16,10 @@ type Performer {
id: ID!
name: String!
disambiguation: String
url: String @deprecated(reason: "Use urls")
urls: [String!]
url: String
gender: GenderEnum
twitter: String @deprecated(reason: "Use urls")
instagram: String @deprecated(reason: "Use urls")
twitter: String
instagram: String
birthdate: String
ethnicity: String
country: String
@@ -29,10 +28,8 @@ type Performer {
measurements: String
fake_tits: String
penis_length: Float
circumcised: CircumcisedEnum
career_length: String @deprecated(reason: "Use career_start and career_end")
career_start: String
career_end: String
circumcised: CircumisedEnum
career_length: String
tattoos: String
piercings: String
alias_list: [String!]!
@@ -44,8 +41,7 @@ type Performer {
scene_count: Int! # Resolver
image_count: Int! # Resolver
gallery_count: Int! # Resolver
group_count: Int! # Resolver
movie_count: Int! @deprecated(reason: "use group_count instead") # Resolver
movie_count: Int! # Resolver
performer_count: Int! # Resolver
o_counter: Int # Resolver
scenes: [Scene!]!
@@ -58,17 +54,13 @@ type Performer {
weight: Int
created_at: Time!
updated_at: Time!
groups: [Group!]!
movies: [Movie!]! @deprecated(reason: "use groups instead")
custom_fields: Map!
movies: [Movie!]!
}
input PerformerCreateInput {
name: String!
disambiguation: String
url: String @deprecated(reason: "Use urls")
urls: [String!]
url: String
gender: GenderEnum
birthdate: String
ethnicity: String
@@ -78,16 +70,13 @@ input PerformerCreateInput {
measurements: String
fake_tits: String
penis_length: Float
circumcised: CircumcisedEnum
career_length: String @deprecated(reason: "Use career_start and career_end")
career_start: String
career_end: String
circumcised: CircumisedEnum
career_length: String
tattoos: String
piercings: String
"Duplicate aliases and those equal to name will be ignored (case-insensitive)"
alias_list: [String!]
twitter: String @deprecated(reason: "Use urls")
instagram: String @deprecated(reason: "Use urls")
twitter: String
instagram: String
favorite: Boolean
tag_ids: [ID!]
"This should be a URL or a base64 encoded data URL"
@@ -100,16 +89,13 @@ input PerformerCreateInput {
hair_color: String
weight: Int
ignore_auto_tag: Boolean
custom_fields: Map
}
input PerformerUpdateInput {
id: ID!
name: String
disambiguation: String
url: String @deprecated(reason: "Use urls")
urls: [String!]
url: String
gender: GenderEnum
birthdate: String
ethnicity: String
@@ -119,16 +105,13 @@ input PerformerUpdateInput {
measurements: String
fake_tits: String
penis_length: Float
circumcised: CircumcisedEnum
career_length: String @deprecated(reason: "Use career_start and career_end")
career_start: String
career_end: String
circumcised: CircumisedEnum
career_length: String
tattoos: String
piercings: String
"Duplicate aliases and those equal to name will be ignored (case-insensitive)"
alias_list: [String!]
twitter: String @deprecated(reason: "Use urls")
instagram: String @deprecated(reason: "Use urls")
twitter: String
instagram: String
favorite: Boolean
tag_ids: [ID!]
"This should be a URL or a base64 encoded data URL"
@@ -141,8 +124,6 @@ input PerformerUpdateInput {
hair_color: String
weight: Int
ignore_auto_tag: Boolean
custom_fields: CustomFieldsInput
}
input BulkUpdateStrings {
@@ -154,8 +135,7 @@ input BulkPerformerUpdateInput {
clientMutationId: String
ids: [ID!]
disambiguation: String
url: String @deprecated(reason: "Use urls")
urls: BulkUpdateStrings
url: String
gender: GenderEnum
birthdate: String
ethnicity: String
@@ -165,16 +145,13 @@ input BulkPerformerUpdateInput {
measurements: String
fake_tits: String
penis_length: Float
circumcised: CircumcisedEnum
career_length: String @deprecated(reason: "Use career_start and career_end")
career_start: String
career_end: String
circumcised: CircumisedEnum
career_length: String
tattoos: String
piercings: String
"Duplicate aliases and those equal to name will result in an error (case-insensitive)"
alias_list: BulkUpdateStrings
twitter: String @deprecated(reason: "Use urls")
instagram: String @deprecated(reason: "Use urls")
twitter: String
instagram: String
favorite: Boolean
tag_ids: BulkUpdateIds
# rating expressed as 1-100
@@ -184,8 +161,6 @@ input BulkPerformerUpdateInput {
hair_color: String
weight: Int
ignore_auto_tag: Boolean
custom_fields: CustomFieldsInput
}
input PerformerDestroyInput {
@@ -196,10 +171,3 @@ type FindPerformersResultType {
count: Int!
performers: [Performer!]!
}
input PerformerMergeInput {
source: [ID!]!
destination: ID!
# values defined here will override values in the destination
values: PerformerUpdateInput
}

View File

@@ -2,10 +2,7 @@ type SceneMarker {
id: ID!
scene: Scene!
title: String!
"The required start time of the marker (in seconds). Supports decimals."
seconds: Float!
"The optional end time of the marker (in seconds). Supports decimals."
end_seconds: Float
primary_tag: Tag!
tags: [Tag!]!
created_at: Time!
@@ -21,10 +18,7 @@ type SceneMarker {
input SceneMarkerCreateInput {
title: String!
"The required start time of the marker (in seconds). Supports decimals."
seconds: Float!
"The optional end time of the marker (in seconds). Supports decimals."
end_seconds: Float
scene_id: ID!
primary_tag_id: ID!
tag_ids: [ID!]
@@ -33,22 +27,12 @@ input SceneMarkerCreateInput {
input SceneMarkerUpdateInput {
id: ID!
title: String
"The start time of the marker (in seconds). Supports decimals."
seconds: Float
"The end time of the marker (in seconds). Supports decimals."
end_seconds: Float
scene_id: ID
primary_tag_id: ID
tag_ids: [ID!]
}
input BulkSceneMarkerUpdateInput {
ids: [ID!]
title: String
primary_tag_id: ID
tag_ids: BulkUpdateIds
}
type FindSceneMarkersResultType {
count: Int!
scene_markers: [SceneMarker!]!

View File

@@ -26,11 +26,6 @@ type SceneMovie {
scene_index: Int
}
type SceneGroup {
group: Group!
scene_index: Int
}
type VideoCaption {
language_code: String!
caption_type: String!
@@ -73,14 +68,11 @@ type Scene {
scene_markers: [SceneMarker!]!
galleries: [Gallery!]!
studio: Studio
groups: [SceneGroup!]!
movies: [SceneMovie!]! @deprecated(reason: "Use groups")
movies: [SceneMovie!]!
tags: [Tag!]!
performers: [Performer!]!
stash_ids: [StashID!]!
custom_fields: Map!
"Return valid stream paths"
sceneStreams: [SceneStreamEndpoint!]!
}
@@ -90,11 +82,6 @@ input SceneMovieInput {
scene_index: Int
}
input SceneGroupInput {
group_id: ID!
scene_index: Int
}
input SceneCreateInput {
title: String
code: String
@@ -109,8 +96,7 @@ input SceneCreateInput {
studio_id: ID
gallery_ids: [ID!]
performer_ids: [ID!]
groups: [SceneGroupInput!]
movies: [SceneMovieInput!] @deprecated(reason: "Use groups")
movies: [SceneMovieInput!]
tag_ids: [ID!]
"This should be a URL or a base64 encoded data URL"
cover_image: String
@@ -122,8 +108,6 @@ input SceneCreateInput {
Files must not already be primary for another scene.
"""
file_ids: [ID!]
custom_fields: Map
}
input SceneUpdateInput {
@@ -144,8 +128,7 @@ input SceneUpdateInput {
studio_id: ID
gallery_ids: [ID!]
performer_ids: [ID!]
groups: [SceneGroupInput!]
movies: [SceneMovieInput!] @deprecated(reason: "Use groups")
movies: [SceneMovieInput!]
tag_ids: [ID!]
"This should be a URL or a base64 encoded data URL"
cover_image: String
@@ -162,8 +145,6 @@ input SceneUpdateInput {
)
primary_file_id: ID
custom_fields: CustomFieldsInput
}
enum BulkUpdateIdMode {
@@ -194,26 +175,19 @@ input BulkSceneUpdateInput {
gallery_ids: BulkUpdateIds
performer_ids: BulkUpdateIds
tag_ids: BulkUpdateIds
group_ids: BulkUpdateIds
movie_ids: BulkUpdateIds @deprecated(reason: "Use group_ids")
custom_fields: CustomFieldsInput
movie_ids: BulkUpdateIds
}
input SceneDestroyInput {
id: ID!
delete_file: Boolean
delete_generated: Boolean
"If true, delete the file entry from the database if the file is not assigned to any other objects"
destroy_file_entry: Boolean
}
input ScenesDestroyInput {
ids: [ID!]!
delete_file: Boolean
delete_generated: Boolean
"If true, delete the file entry from the database if the file is not assigned to any other objects"
destroy_file_entry: Boolean
}
type FindScenesResultType {

View File

@@ -1,65 +0,0 @@
"A movie from a scraping operation..."
type ScrapedMovie {
stored_id: ID
name: String
aliases: String
duration: String
date: String
rating: String
director: String
url: String @deprecated(reason: "use urls")
urls: [String!]
synopsis: String
studio: ScrapedStudio
tags: [ScrapedTag!]
"This should be a base64 encoded data URL"
front_image: String
"This should be a base64 encoded data URL"
back_image: String
}
input ScrapedMovieInput {
name: String
aliases: String
duration: String
date: String
rating: String
director: String
url: String @deprecated(reason: "use urls")
urls: [String!]
synopsis: String
# not including tags for the input
}
"A group from a scraping operation..."
type ScrapedGroup {
stored_id: ID
name: String
aliases: String
duration: String
date: String
rating: String
director: String
urls: [String!]
synopsis: String
studio: ScrapedStudio
tags: [ScrapedTag!]
"This should be a base64 encoded data URL"
front_image: String
"This should be a base64 encoded data URL"
back_image: String
}
input ScrapedGroupInput {
name: String
aliases: String
duration: String
date: String
rating: String
director: String
urls: [String!]
synopsis: String
# not including tags for the input
}

View File

@@ -0,0 +1,29 @@
"A movie from a scraping operation..."
type ScrapedMovie {
stored_id: ID
name: String
aliases: String
duration: String
date: String
rating: String
director: String
url: String
synopsis: String
studio: ScrapedStudio
"This should be a base64 encoded data URL"
front_image: String
"This should be a base64 encoded data URL"
back_image: String
}
input ScrapedMovieInput {
name: String
aliases: String
duration: String
date: String
rating: String
director: String
url: String
synopsis: String
}

View File

@@ -5,10 +5,9 @@ type ScrapedPerformer {
name: String
disambiguation: String
gender: String
url: String @deprecated(reason: "use urls")
urls: [String!]
twitter: String @deprecated(reason: "use urls")
instagram: String @deprecated(reason: "use urls")
url: String
twitter: String
instagram: String
birthdate: String
ethnicity: String
country: String
@@ -18,9 +17,7 @@ type ScrapedPerformer {
fake_tits: String
penis_length: String
circumcised: String
career_length: String @deprecated(reason: "Use career_start and career_end")
career_start: String
career_end: String
career_length: String
tattoos: String
piercings: String
# aliases must be comma-delimited to be parsed correctly
@@ -43,10 +40,9 @@ input ScrapedPerformerInput {
name: String
disambiguation: String
gender: String
url: String @deprecated(reason: "use urls")
urls: [String!]
twitter: String @deprecated(reason: "use urls")
instagram: String @deprecated(reason: "use urls")
url: String
twitter: String
instagram: String
birthdate: String
ethnicity: String
country: String
@@ -56,9 +52,7 @@ input ScrapedPerformerInput {
fake_tits: String
penis_length: String
circumcised: String
career_length: String @deprecated(reason: "Use career_start and career_end")
career_start: String
career_end: String
career_length: String
tattoos: String
piercings: String
aliases: String

View File

@@ -10,9 +10,7 @@ enum ScrapeType {
"Type of the content a scraper generates"
enum ScrapeContentType {
GALLERY
IMAGE
MOVIE
GROUP
PERFORMER
SCENE
}
@@ -23,9 +21,7 @@ union ScrapedContent =
| ScrapedTag
| ScrapedScene
| ScrapedGallery
| ScrapedImage
| ScrapedMovie
| ScrapedGroup
| ScrapedPerformer
type ScraperSpec {
@@ -43,26 +39,17 @@ type Scraper {
scene: ScraperSpec
"Details for gallery scraper"
gallery: ScraperSpec
"Details for image scraper"
image: ScraperSpec
"Details for movie scraper"
movie: ScraperSpec @deprecated(reason: "use group")
"Details for group scraper"
group: ScraperSpec
movie: ScraperSpec
}
type ScrapedStudio {
"Set if studio matched"
stored_id: ID
name: String!
url: String @deprecated(reason: "use urls")
urls: [String!]
url: String
parent: ScrapedStudio
image: String
details: String
"Aliases must be comma-delimited to be parsed correctly"
aliases: String
tags: [ScrapedTag!]
remote_site_id: String
}
@@ -71,11 +58,6 @@ type ScrapedTag {
"Set if tag matched"
stored_id: ID
name: String!
description: String
alias_list: [String!]
parent: ScrapedTag
"Remote site ID, if applicable"
remote_site_id: String
}
type ScrapedScene {
@@ -94,8 +76,7 @@ type ScrapedScene {
studio: ScrapedStudio
tags: [ScrapedTag!]
performers: [ScrapedPerformer!]
movies: [ScrapedMovie!] @deprecated(reason: "use groups")
groups: [ScrapedGroup!]
movies: [ScrapedMovie!]
remote_site_id: String
duration: Int
@@ -142,32 +123,12 @@ input ScrapedGalleryInput {
# no studio, tags or performers
}
type ScrapedImage {
title: String
code: String
details: String
photographer: String
urls: [String!]
date: String
studio: ScrapedStudio
tags: [ScrapedTag!]
performers: [ScrapedPerformer!]
}
input ScrapedImageInput {
title: String
code: String
details: String
urls: [String!]
date: String
}
input ScraperSourceInput {
"Index of the configured stash-box instance to use. Should be unset if scraper_id is set"
stash_box_index: Int @deprecated(reason: "use stash_box_endpoint")
"Stash-box endpoint"
stash_box_endpoint: String
"Scraper ID to scrape with. Should be unset if stash_box_endpoint/stash_box_index is set"
"Scraper ID to scrape with. Should be unset if stash_box_index is set"
scraper_id: ID
}
@@ -176,7 +137,7 @@ type ScraperSource {
stash_box_index: Int @deprecated(reason: "use stash_box_endpoint")
"Stash-box endpoint"
stash_box_endpoint: String
"Scraper ID to scrape with. Should be unset if stash_box_endpoint/stash_box_index is set"
"Scraper ID to scrape with. Should be unset if stash_box_index is set"
scraper_id: ID
}
@@ -201,13 +162,6 @@ input ScrapeSingleStudioInput {
query: String
}
input ScrapeSingleTagInput {
"""
Query can be either a name or a Stash ID
"""
query: String
}
input ScrapeSinglePerformerInput {
"Instructs to query by string"
query: String
@@ -231,38 +185,18 @@ input ScrapeSingleGalleryInput {
gallery_input: ScrapedGalleryInput
}
input ScrapeSingleImageInput {
"Instructs to query by string"
query: String
"Instructs to query by image id"
image_id: ID
"Instructs to query by image fragment"
image_input: ScrapedImageInput
}
input ScrapeSingleMovieInput {
"Instructs to query by string"
query: String
"Instructs to query by movie id"
movie_id: ID
"Instructs to query by movie fragment"
"Instructs to query by gallery fragment"
movie_input: ScrapedMovieInput
}
input ScrapeSingleGroupInput {
"Instructs to query by string"
query: String
"Instructs to query by group id"
group_id: ID
"Instructs to query by group fragment"
group_input: ScrapedGroupInput
}
input StashBoxSceneQueryInput {
"Index of the configured stash-box instance to use"
stash_box_index: Int @deprecated(reason: "use stash_box_endpoint")
"Endpoint of the stash-box instance to use"
stash_box_endpoint: String
stash_box_index: Int!
"Instructs query by scene fingerprints"
scene_ids: [ID!]
"Query by query string"
@@ -271,9 +205,7 @@ input StashBoxSceneQueryInput {
input StashBoxPerformerQueryInput {
"Index of the configured stash-box instance to use"
stash_box_index: Int @deprecated(reason: "use stash_box_endpoint")
"Endpoint of the stash-box instance to use"
stash_box_endpoint: String
stash_box_index: Int!
"Instructs query by scene fingerprints"
performer_ids: [ID!]
"Query by query string"
@@ -291,34 +223,22 @@ type StashBoxFingerprint {
duration: Int!
}
"""
Accepts either ids, or a combination of names and stash_ids.
If none are set, then all existing items will be tagged.
"""
"If neither ids nor names are set, tag all items"
input StashBoxBatchTagInput {
"Stash endpoint to use for the tagging"
endpoint: Int @deprecated(reason: "use stash_box_endpoint")
"Endpoint of the stash-box instance to use"
stash_box_endpoint: String
endpoint: Int!
"Fields to exclude when executing the tagging"
exclude_fields: [String!]
"Collection fields to merge (add to existing) instead of overwriting when executing the tagging"
merge_fields: [String!]
"Refresh items already tagged by StashBox if true. Only tag items with no StashBox tagging if false"
refresh: Boolean!
"If batch adding studios, should their parent studios also be created?"
createParent: Boolean!
"""
IDs in stash of the items to update.
If set, names and stash_ids fields will be ignored.
"""
"If set, only tag these ids"
ids: [ID!]
"Names of the items in the stash-box instance to search for and create"
"If set, only tag these names"
names: [String!]
"Stash IDs of the items in the stash-box instance to search for and create"
stash_ids: [String!]
"IDs in stash of the performers to update"
"If set, only tag these performer ids"
performer_ids: [ID!] @deprecated(reason: "use ids")
"Names of the performers in the stash-box instance to search for and create"
"If set, only tag these performer names"
performer_names: [String!] @deprecated(reason: "use names")
}

View File

@@ -2,37 +2,30 @@ type StashBox {
endpoint: String!
api_key: String!
name: String!
max_requests_per_minute: Int!
}
input StashBoxInput {
endpoint: String!
api_key: String!
name: String!
# defaults to 240
max_requests_per_minute: Int
}
type StashID {
endpoint: String!
stash_id: String!
updated_at: Time!
}
input StashIDInput {
endpoint: String!
stash_id: String!
updated_at: Time
}
input StashBoxFingerprintSubmissionInput {
scene_ids: [String!]!
stash_box_index: Int @deprecated(reason: "use stash_box_endpoint")
stash_box_endpoint: String
stash_box_index: Int!
}
input StashBoxDraftSubmissionInput {
id: String!
stash_box_index: Int @deprecated(reason: "use stash_box_endpoint")
stash_box_endpoint: String
stash_box_index: Int!
}

View File

@@ -7,8 +7,7 @@ type StatsResultType {
gallery_count: Int!
performer_count: Int!
studio_count: Int!
group_count: Int!
movie_count: Int! @deprecated(reason: "use group_count instead")
movie_count: Int!
tag_count: Int!
total_o_count: Int!
total_play_duration: Float!

View File

@@ -1,93 +1,54 @@
type Studio {
id: ID!
name: String!
url: String @deprecated(reason: "Use urls")
urls: [String!]!
url: String
parent_studio: Studio
child_studios: [Studio!]!
aliases: [String!]!
tags: [Tag!]!
ignore_auto_tag: Boolean!
organized: Boolean!
image_path: String # Resolver
scene_count(depth: Int): Int! # Resolver
image_count(depth: Int): Int! # Resolver
gallery_count(depth: Int): Int! # Resolver
performer_count(depth: Int): Int! # Resolver
group_count(depth: Int): Int! # Resolver
movie_count(depth: Int): Int! @deprecated(reason: "use group_count instead") # Resolver
movie_count(depth: Int): Int! # Resolver
stash_ids: [StashID!]!
# rating expressed as 1-100
rating100: Int
favorite: Boolean!
details: String
created_at: Time!
updated_at: Time!
groups: [Group!]!
movies: [Movie!]! @deprecated(reason: "use groups instead")
o_counter: Int
custom_fields: Map!
movies: [Movie!]!
}
input StudioCreateInput {
name: String!
url: String @deprecated(reason: "Use urls")
urls: [String!]
url: String
parent_id: ID
child_ids: [ID!]
"This should be a URL or a base64 encoded data URL"
image: String
stash_ids: [StashIDInput!]
# rating expressed as 1-100
rating100: Int
favorite: Boolean
details: String
"Duplicate aliases and those equal to name will be ignored (case-insensitive)"
aliases: [String!]
tag_ids: [ID!]
ignore_auto_tag: Boolean
organized: Boolean
custom_fields: Map
}
input StudioUpdateInput {
id: ID!
name: String
url: String @deprecated(reason: "Use urls")
urls: [String!]
url: String
parent_id: ID
child_ids: [ID!]
"This should be a URL or a base64 encoded data URL"
image: String
stash_ids: [StashIDInput!]
# rating expressed as 1-100
rating100: Int
favorite: Boolean
details: String
"Duplicate aliases and those equal to name will be ignored (case-insensitive)"
aliases: [String!]
tag_ids: [ID!]
ignore_auto_tag: Boolean
organized: Boolean
custom_fields: CustomFieldsInput
}
input BulkStudioUpdateInput {
ids: [ID!]!
url: String @deprecated(reason: "Use urls")
urls: BulkUpdateStrings
parent_id: ID
# rating expressed as 1-100
rating100: Int
favorite: Boolean
details: String
tag_ids: BulkUpdateIds
ignore_auto_tag: Boolean
organized: Boolean
}
input StudioDestroyInput {

View File

@@ -1,69 +1,50 @@
type Tag {
id: ID!
name: String!
"Value that does not appear in the UI but overrides name for sorting"
sort_name: String
description: String
aliases: [String!]!
ignore_auto_tag: Boolean!
created_at: Time!
updated_at: Time!
favorite: Boolean!
stash_ids: [StashID!]!
image_path: String # Resolver
scene_count(depth: Int): Int! # Resolver
scene_marker_count(depth: Int): Int! # Resolver
image_count(depth: Int): Int! # Resolver
gallery_count(depth: Int): Int! # Resolver
performer_count(depth: Int): Int! # Resolver
studio_count(depth: Int): Int! # Resolver
group_count(depth: Int): Int! # Resolver
movie_count(depth: Int): Int! @deprecated(reason: "use group_count instead") # Resolver
parents: [Tag!]!
children: [Tag!]!
parent_count: Int! # Resolver
child_count: Int! # Resolver
custom_fields: Map!
}
input TagCreateInput {
name: String!
"Value that does not appear in the UI but overrides name for sorting"
sort_name: String
description: String
"Duplicate aliases and those equal to name will be ignored (case-insensitive)"
aliases: [String!]
ignore_auto_tag: Boolean
favorite: Boolean
"This should be a URL or a base64 encoded data URL"
image: String
stash_ids: [StashIDInput!]
parent_ids: [ID!]
child_ids: [ID!]
custom_fields: Map
}
input TagUpdateInput {
id: ID!
name: String
"Value that does not appear in the UI but overrides name for sorting"
sort_name: String
description: String
"Duplicate aliases and those equal to name will be ignored (case-insensitive)"
aliases: [String!]
ignore_auto_tag: Boolean
favorite: Boolean
"This should be a URL or a base64 encoded data URL"
image: String
stash_ids: [StashIDInput!]
parent_ids: [ID!]
child_ids: [ID!]
custom_fields: CustomFieldsInput
}
input TagDestroyInput {
@@ -78,18 +59,4 @@ type FindTagsResultType {
input TagsMergeInput {
source: [ID!]!
destination: ID!
# values defined here will override values in the destination
values: TagUpdateInput
}
input BulkTagUpdateInput {
ids: [ID!]
description: String
"Duplicate aliases and those equal to name will result in an error (case-insensitive)"
aliases: BulkUpdateStrings
ignore_auto_tag: Boolean
favorite: Boolean
parent_ids: BulkUpdateIds
child_ids: BulkUpdateIds
}

View File

@@ -13,7 +13,6 @@ fragment ImageFragment on Image {
fragment StudioFragment on Studio {
name
id
aliases
urls {
...URLFragment
}
@@ -29,13 +28,11 @@ fragment StudioFragment on Studio {
fragment TagFragment on Tag {
name
id
description
aliases
category {
id
name
description
}
}
fragment FuzzyDateFragment on FuzzyDate {
date
accuracy
}
fragment MeasurementsFragment on Measurements {
@@ -57,16 +54,15 @@ fragment PerformerFragment on Performer {
aliases
gender
merged_ids
deleted
merged_into_id
urls {
...URLFragment
}
images {
...ImageFragment
}
birth_date
death_date
birthdate {
...FuzzyDateFragment
}
ethnicity
country
eye_color
@@ -127,6 +123,18 @@ fragment SceneFragment on Scene {
}
}
query FindSceneByFingerprint($fingerprint: FingerprintQueryInput!) {
findSceneByFingerprint(fingerprint: $fingerprint) {
...SceneFragment
}
}
query FindScenesByFullFingerprints($fingerprints: [FingerprintQueryInput!]!) {
findScenesByFullFingerprints(fingerprints: $fingerprints) {
...SceneFragment
}
}
query FindScenesBySceneFingerprints(
$fingerprints: [[FingerprintQueryInput!]!]!
) {
@@ -165,21 +173,6 @@ query FindStudio($id: ID, $name: String) {
}
}
query FindTag($id: ID, $name: String) {
findTag(id: $id, name: $name) {
...TagFragment
}
}
query QueryTags($input: TagQueryInput!) {
queryTags(input: $input) {
count
tags {
...TagFragment
}
}
}
mutation SubmitFingerprint($input: FingerprintSubmission!) {
submitFingerprint(input: $input)
}

View File

@@ -12,17 +12,16 @@ import (
"github.com/stashapp/stash/internal/manager/config"
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/session"
"github.com/stashapp/stash/pkg/signedurl"
)
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://discourse.stashapp.cc/t/-/1658"
"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://discourse.stashapp.cc/t/-/1658"
"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 {
@@ -30,46 +29,6 @@ func allowUnauthenticated(r *http.Request) bool {
return strings.HasPrefix(r.URL.Path, loginEndpoint) || r.URL.Path == logoutEndpoint || r.URL.Path == "/css" || strings.HasPrefix(r.URL.Path, "/assets")
}
// authenticateSignedRequest checks if the request is a valid signed media request.
// Returns the matched username and true if valid, or empty string and false otherwise.
func authenticateSignedRequest(r *http.Request) (string, bool) {
// Only apply to scene stream paths (used by AirPlay/Chromecast devices that can't pass cookies)
if !strings.HasPrefix(r.URL.Path, "/scene/") {
return "", false
}
c := config.GetInstance()
// Signed URLs are only relevant when credentials are configured
if !c.HasCredentials() {
return "", false
}
// Check for signed URL parameters
q := r.URL.Query()
if q.Get(signedurl.CIDParam) == "" || q.Get(signedurl.ExpiresParam) == "" || q.Get(signedurl.SigParam) == "" {
return "", false
}
// Extract the credential ID and look up the user's signing key.
// We need the key before we can verify the signature, since in a
// multi-user setup each user could have their own signing key.
cid := q.Get(signedurl.CIDParam)
username, secret, found := resolveCredentialID(c, cid)
if !found {
logger.Warnf("signed URL credential ID mismatch")
return "", false
}
// Verify the signature using the user's signing key
if _, err := signedurl.VerifyURL(r.URL.Path, q, secret); err != nil {
logger.Warnf("signed URL verification failed: %v", err)
return "", false
}
return username, true
}
func authenticateHandler() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -81,20 +40,9 @@ func authenticateHandler() func(http.Handler) http.Handler {
return
}
r = session.SetLocalRequest(r)
// Check for signed media requests
if username, ok := authenticateSignedRequest(r); ok {
ctx := r.Context()
ctx = session.SetCurrentUserID(ctx, username)
r = r.WithContext(ctx)
next.ServeHTTP(w, r)
return
}
userID, err := manager.GetInstance().SessionStore.Authenticate(w, r)
if err != nil {
if !errors.Is(err, session.ErrUnauthorized) {
if errors.Is(err, session.ErrUnauthorized) {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}

View File

@@ -98,7 +98,7 @@ func (t changesetTranslator) string(value *string) string {
return ""
}
return strings.TrimSpace(*value)
return *value
}
func (t changesetTranslator) optionalString(value *string, field string) models.OptionalString {
@@ -106,12 +106,7 @@ func (t changesetTranslator) optionalString(value *string, field string) models.
return models.OptionalString{}
}
if value == nil {
return models.NewOptionalStringPtr(nil)
}
trimmed := strings.TrimSpace(*value)
return models.NewOptionalString(trimmed)
return models.NewOptionalStringPtr(value)
}
func (t changesetTranslator) optionalDate(value *string, field string) (models.OptionalDate, error) {
@@ -323,14 +318,8 @@ func (t changesetTranslator) updateStrings(value []string, field string) *models
return nil
}
// Trim whitespace from each string
trimmedValues := make([]string, len(value))
for i, v := range value {
trimmedValues[i] = strings.TrimSpace(v)
}
return &models.UpdateStrings{
Values: trimmedValues,
Values: value,
Mode: models.RelationshipUpdateModeSet,
}
}
@@ -340,98 +329,49 @@ func (t changesetTranslator) updateStringsBulk(value *BulkUpdateStrings, field s
return nil
}
// Trim whitespace from each string
trimmedValues := make([]string, len(value.Values))
for i, v := range value.Values {
trimmedValues[i] = strings.TrimSpace(v)
}
return &models.UpdateStrings{
Values: trimmedValues,
Values: value.Values,
Mode: value.Mode,
}
}
func (t changesetTranslator) updateStashIDs(value models.StashIDInputs, field string) *models.UpdateStashIDs {
func (t changesetTranslator) updateStashIDs(value []models.StashID, field string) *models.UpdateStashIDs {
if !t.hasField(field) {
return nil
}
return &models.UpdateStashIDs{
StashIDs: value.ToStashIDs(),
StashIDs: value,
Mode: models.RelationshipUpdateModeSet,
}
}
func (t changesetTranslator) relatedGroupsFromMovies(value []models.SceneMovieInput) (models.RelatedGroups, error) {
groupsScenes, err := models.GroupsScenesFromInput(value)
func (t changesetTranslator) relatedMovies(value []models.SceneMovieInput) (models.RelatedMovies, error) {
moviesScenes, err := models.MoviesScenesFromInput(value)
if err != nil {
return models.RelatedGroups{}, err
return models.RelatedMovies{}, err
}
return models.NewRelatedGroups(groupsScenes), nil
return models.NewRelatedMovies(moviesScenes), nil
}
func groupsScenesFromGroupInput(input []models.SceneGroupInput) ([]models.GroupsScenes, error) {
ret := make([]models.GroupsScenes, len(input))
for i, v := range input {
mID, err := strconv.Atoi(v.GroupID)
if err != nil {
return nil, fmt.Errorf("invalid group ID: %s", v.GroupID)
}
ret[i] = models.GroupsScenes{
GroupID: mID,
SceneIndex: v.SceneIndex,
}
}
return ret, nil
}
func (t changesetTranslator) relatedGroups(value []models.SceneGroupInput) (models.RelatedGroups, error) {
groupsScenes, err := groupsScenesFromGroupInput(value)
if err != nil {
return models.RelatedGroups{}, err
}
return models.NewRelatedGroups(groupsScenes), nil
}
func (t changesetTranslator) updateGroupIDsFromMovies(value []models.SceneMovieInput, field string) (*models.UpdateGroupIDs, error) {
func (t changesetTranslator) updateMovieIDs(value []models.SceneMovieInput, field string) (*models.UpdateMovieIDs, error) {
if !t.hasField(field) {
return nil, nil
}
groupsScenes, err := models.GroupsScenesFromInput(value)
moviesScenes, err := models.MoviesScenesFromInput(value)
if err != nil {
return nil, err
}
return &models.UpdateGroupIDs{
Groups: groupsScenes,
return &models.UpdateMovieIDs{
Movies: moviesScenes,
Mode: models.RelationshipUpdateModeSet,
}, nil
}
func (t changesetTranslator) updateGroupIDs(value []models.SceneGroupInput, field string) (*models.UpdateGroupIDs, error) {
if !t.hasField(field) {
return nil, nil
}
groupsScenes, err := groupsScenesFromGroupInput(value)
if err != nil {
return nil, err
}
return &models.UpdateGroupIDs{
Groups: groupsScenes,
Mode: models.RelationshipUpdateModeSet,
}, nil
}
func (t changesetTranslator) updateGroupIDsBulk(value *BulkUpdateIds, field string) (*models.UpdateGroupIDs, error) {
func (t changesetTranslator) updateMovieIDsBulk(value *BulkUpdateIds, field string) (*models.UpdateMovieIDs, error) {
if !t.hasField(field) || value == nil {
return nil, nil
}
@@ -441,74 +381,13 @@ func (t changesetTranslator) updateGroupIDsBulk(value *BulkUpdateIds, field stri
return nil, fmt.Errorf("converting ids [%v]: %w", value.Ids, err)
}
groups := make([]models.GroupsScenes, len(ids))
movies := make([]models.MoviesScenes, len(ids))
for i, id := range ids {
groups[i] = models.GroupsScenes{GroupID: id}
movies[i] = models.MoviesScenes{MovieID: id}
}
return &models.UpdateGroupIDs{
Groups: groups,
Mode: value.Mode,
}, nil
}
func groupsDescriptionsFromGroupInput(input []*GroupDescriptionInput) ([]models.GroupIDDescription, error) {
ret := make([]models.GroupIDDescription, len(input))
for i, v := range input {
gID, err := strconv.Atoi(v.GroupID)
if err != nil {
return nil, fmt.Errorf("invalid group ID: %s", v.GroupID)
}
ret[i] = models.GroupIDDescription{
GroupID: gID,
}
if v.Description != nil {
ret[i].Description = strings.TrimSpace(*v.Description)
}
}
return ret, nil
}
func (t changesetTranslator) groupIDDescriptions(value []*GroupDescriptionInput) (models.RelatedGroupDescriptions, error) {
groupsScenes, err := groupsDescriptionsFromGroupInput(value)
if err != nil {
return models.RelatedGroupDescriptions{}, err
}
return models.NewRelatedGroupDescriptions(groupsScenes), nil
}
func (t changesetTranslator) updateGroupIDDescriptions(value []*GroupDescriptionInput, field string) (*models.UpdateGroupDescriptions, error) {
if !t.hasField(field) {
return nil, nil
}
groupsScenes, err := groupsDescriptionsFromGroupInput(value)
if err != nil {
return nil, err
}
return &models.UpdateGroupDescriptions{
Groups: groupsScenes,
Mode: models.RelationshipUpdateModeSet,
}, nil
}
func (t changesetTranslator) updateGroupIDDescriptionsBulk(value *BulkUpdateGroupDescriptionsInput, field string) (*models.UpdateGroupDescriptions, error) {
if !t.hasField(field) || value == nil {
return nil, nil
}
groups, err := groupsDescriptionsFromGroupInput(value.Groups)
if err != nil {
return nil, err
}
return &models.UpdateGroupDescriptions{
Groups: groups,
return &models.UpdateMovieIDs{
Movies: movies,
Mode: value.Mode,
}, nil
}

View File

@@ -7,10 +7,8 @@ import (
"fmt"
"io"
"net/http"
"os"
"regexp"
"runtime"
"strings"
"time"
"golang.org/x/sys/cpu"
@@ -38,24 +36,6 @@ var stashReleases = func() map[string]string {
}
}
// isMacOSBundle checks if the application is running from within a macOS .app bundle
func isMacOSBundle() bool {
exec, err := os.Executable()
return err == nil && strings.Contains(exec, "Stash.app/")
}
// getWantedRelease determines which release variant to download based on platform and bundle type
func getWantedRelease(platform string) string {
release := stashReleases()[platform]
// On macOS, check if running from .app bundle
if runtime.GOOS == "darwin" && isMacOSBundle() {
return "Stash.app.zip"
}
return release
}
type githubReleasesResponse struct {
Url string
Assets_url string
@@ -148,12 +128,12 @@ func makeGithubRequest(ctx context.Context, url string, output interface{}) erro
response, err := client.Do(req)
if err != nil {
//nolint:staticcheck // ST1005 Github is a proper capitalized noun
//lint:ignore ST1005 Github is a proper capitalized noun
return fmt.Errorf("Github API request failed: %w", err)
}
if response.StatusCode != http.StatusOK {
//nolint:staticcheck // ST1005 Github is a proper capitalized noun
//lint:ignore ST1005 Github is a proper capitalized noun
return fmt.Errorf("Github API request failed: %s", response.Status)
}
@@ -161,7 +141,7 @@ func makeGithubRequest(ctx context.Context, url string, output interface{}) erro
data, err := io.ReadAll(response.Body)
if err != nil {
//nolint:staticcheck // ST1005 Github is a proper capitalized noun
//lint:ignore ST1005 Github is a proper capitalized noun
return fmt.Errorf("Github API read response failed: %w", err)
}
@@ -188,7 +168,7 @@ func GetLatestRelease(ctx context.Context) (*LatestRelease, error) {
}
platform := fmt.Sprintf("%s/%s", runtime.GOOS, arch)
wantedRelease := getWantedRelease(platform)
wantedRelease := stashReleases()[platform]
url := apiReleases
if build.IsDevelop() {
@@ -295,10 +275,10 @@ func printLatestVersion(ctx context.Context) {
logger.Errorf("Couldn't retrieve latest version: %v", err)
} else {
_, githash, _ := build.Version()
switch githash {
case "":
switch {
case githash == "":
logger.Infof("Latest version: %s (%s)", latestRelease.Version, latestRelease.ShortHash)
case 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

@@ -5,11 +5,11 @@ package api
type key int
const (
galleryKey key = iota
performerKey
// galleryKey key = 0
performerKey key = iota + 1
sceneKey
studioKey
groupKey
movieKey
tagKey
downloadKey
imageKey

View File

@@ -1,12 +0,0 @@
package api
import "github.com/stashapp/stash/pkg/models"
func handleUpdateCustomFields(input models.CustomFieldsInput) models.CustomFieldsInput {
ret := input
// convert json.Numbers to int/float
ret.Full = convertMapJSONNumbers(ret.Full)
ret.Partial = convertMapJSONNumbers(ret.Partial)
return ret
}

View File

@@ -1,2 +0,0 @@
// Package api provides the HTTP and Graphql API for the application.
package api

View File

@@ -1,23 +0,0 @@
package api
import (
"context"
"github.com/99designs/gqlgen/graphql"
)
type queryFields []string
func collectQueryFields(ctx context.Context) queryFields {
fields := graphql.CollectAllFields(ctx)
return queryFields(fields)
}
func (f queryFields) Has(field string) bool {
for _, v := range f {
if v == field {
return true
}
}
return false
}

View File

@@ -26,7 +26,6 @@ var imageBoxExts = []string{
".gif",
".svg",
".webp",
".avif",
}
func newImageBox(box fs.FS) (*imageBox, error) {
@@ -102,7 +101,7 @@ func initCustomPerformerImages(customPath string) {
}
}
func getDefaultPerformerImage(name string, gender *models.GenderEnum, sfwMode bool) []byte {
func getDefaultPerformerImage(name string, gender *models.GenderEnum) []byte {
// try the custom box first if we have one
if performerBoxCustom != nil {
ret, err := performerBoxCustom.GetRandomImageByName(name)
@@ -112,10 +111,6 @@ func getDefaultPerformerImage(name string, gender *models.GenderEnum, sfwMode bo
logger.Warnf("error loading custom default performer image: %v", err)
}
if sfwMode {
return static.ReadAll(static.DefaultSFWPerformerImage)
}
var g models.GenderEnum
if gender != nil {
g = *gender

View File

@@ -1,35 +0,0 @@
package api
import (
"fmt"
"github.com/stashapp/stash/pkg/sliceutil/stringslice"
)
// TODO - apply handleIDs to other resolvers that accept ID lists
// handleIDList validates and converts a list of string IDs to integers
func handleIDList(idList []string, field string) ([]int, error) {
if err := validateIDList(idList); err != nil {
return nil, fmt.Errorf("validating %s: %w", field, err)
}
ids, err := stringslice.StringSliceToIntSlice(idList)
if err != nil {
return nil, fmt.Errorf("converting %s: %w", field, err)
}
return ids, nil
}
// validateIDList returns an error if there are any duplicate ids in the list
func validateIDList(ids []string) error {
seen := make(map[string]struct{})
for _, id := range ids {
if _, exists := seen[id]; exists {
return fmt.Errorf("duplicate id found: %s", id)
}
seen[id] = struct{}{}
}
return nil
}

View File

@@ -1,65 +0,0 @@
package api
import (
"encoding/json"
"strings"
"github.com/stashapp/stash/pkg/models"
)
// jsonNumberToNumber converts a JSON number to either a float64 or int64.
func jsonNumberToNumber(n json.Number) interface{} {
if strings.Contains(string(n), ".") {
f, _ := n.Float64()
return f
}
ret, _ := n.Int64()
return ret
}
// anyJSONNumberToNumber converts a JSON number using jsonNumberToNumber, otherwise it returns the existing value
func anyJSONNumberToNumber(v any) any {
if n, ok := v.(json.Number); ok {
return jsonNumberToNumber(n)
}
return v
}
// ConvertMapJSONNumbers converts all JSON numbers in a map to either float64 or int64.
func convertMapJSONNumbers(m map[string]interface{}) (ret map[string]interface{}) {
if m == nil {
return nil
}
ret = make(map[string]interface{})
for k, v := range m {
if n, ok := v.(json.Number); ok {
ret[k] = jsonNumberToNumber(n)
} else if mm, ok := v.(map[string]interface{}); ok {
ret[k] = convertMapJSONNumbers(mm)
} else {
ret[k] = v
}
}
return ret
}
func convertCustomFieldCriterionValues(c models.CustomFieldCriterionInput) models.CustomFieldCriterionInput {
nv := make([]any, len(c.Value))
for i, v := range c.Value {
nv[i] = anyJSONNumberToNumber(v)
}
c.Value = nv
return c
}
func convertCustomFieldCriterionInputJSONNumbers(c []models.CustomFieldCriterionInput) []models.CustomFieldCriterionInput {
ret := make([]models.CustomFieldCriterionInput, len(c))
for i, v := range c {
ret[i] = convertCustomFieldCriterionValues(v)
}
return ret
}

View File

@@ -1,60 +0,0 @@
package api
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/assert"
)
func TestConvertMapJSONNumbers(t *testing.T) {
tests := []struct {
name string
input map[string]interface{}
expected map[string]interface{}
}{
{
name: "Convert JSON numbers to numbers",
input: map[string]interface{}{
"int": json.Number("12"),
"float": json.Number("12.34"),
"string": "foo",
},
expected: map[string]interface{}{
"int": int64(12),
"float": 12.34,
"string": "foo",
},
},
{
name: "Convert JSON numbers to numbers in nested maps",
input: map[string]interface{}{
"foo": map[string]interface{}{
"int": json.Number("56"),
"float": json.Number("56.78"),
"nested-string": "bar",
},
"int": json.Number("12"),
"float": json.Number("12.34"),
"string": "foo",
},
expected: map[string]interface{}{
"foo": map[string]interface{}{
"int": int64(56),
"float": 56.78,
"nested-string": "bar",
},
"int": int64(12),
"float": 12.34,
"string": "foo",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := convertMapJSONNumbers(tt.input)
assert.Equal(t, tt.expected, result)
})
}
}

View File

@@ -1,221 +0,0 @@
// Code generated by github.com/vektah/dataloaden, DO NOT EDIT.
package loaders
import (
"sync"
"time"
"github.com/stashapp/stash/pkg/models"
)
// CustomFieldsLoaderConfig captures the config to create a new CustomFieldsLoader
type CustomFieldsLoaderConfig struct {
// Fetch is a method that provides the data for the loader
Fetch func(keys []int) ([]models.CustomFieldMap, []error)
// Wait is how long wait before sending a batch
Wait time.Duration
// MaxBatch will limit the maximum number of keys to send in one batch, 0 = not limit
MaxBatch int
}
// NewCustomFieldsLoader creates a new CustomFieldsLoader given a fetch, wait, and maxBatch
func NewCustomFieldsLoader(config CustomFieldsLoaderConfig) *CustomFieldsLoader {
return &CustomFieldsLoader{
fetch: config.Fetch,
wait: config.Wait,
maxBatch: config.MaxBatch,
}
}
// CustomFieldsLoader batches and caches requests
type CustomFieldsLoader struct {
// this method provides the data for the loader
fetch func(keys []int) ([]models.CustomFieldMap, []error)
// how long to done before sending a batch
wait time.Duration
// this will limit the maximum number of keys to send in one batch, 0 = no limit
maxBatch int
// INTERNAL
// lazily created cache
cache map[int]models.CustomFieldMap
// the current batch. keys will continue to be collected until timeout is hit,
// then everything will be sent to the fetch method and out to the listeners
batch *customFieldsLoaderBatch
// mutex to prevent races
mu sync.Mutex
}
type customFieldsLoaderBatch struct {
keys []int
data []models.CustomFieldMap
error []error
closing bool
done chan struct{}
}
// Load a CustomFieldMap by key, batching and caching will be applied automatically
func (l *CustomFieldsLoader) Load(key int) (models.CustomFieldMap, error) {
return l.LoadThunk(key)()
}
// LoadThunk returns a function that when called will block waiting for a CustomFieldMap.
// This method should be used if you want one goroutine to make requests to many
// different data loaders without blocking until the thunk is called.
func (l *CustomFieldsLoader) LoadThunk(key int) func() (models.CustomFieldMap, error) {
l.mu.Lock()
if it, ok := l.cache[key]; ok {
l.mu.Unlock()
return func() (models.CustomFieldMap, error) {
return it, nil
}
}
if l.batch == nil {
l.batch = &customFieldsLoaderBatch{done: make(chan struct{})}
}
batch := l.batch
pos := batch.keyIndex(l, key)
l.mu.Unlock()
return func() (models.CustomFieldMap, error) {
<-batch.done
var data models.CustomFieldMap
if pos < len(batch.data) {
data = batch.data[pos]
}
var err error
// its convenient to be able to return a single error for everything
if len(batch.error) == 1 {
err = batch.error[0]
} else if batch.error != nil {
err = batch.error[pos]
}
if err == nil {
l.mu.Lock()
l.unsafeSet(key, data)
l.mu.Unlock()
}
return data, err
}
}
// LoadAll fetches many keys at once. It will be broken into appropriate sized
// sub batches depending on how the loader is configured
func (l *CustomFieldsLoader) LoadAll(keys []int) ([]models.CustomFieldMap, []error) {
results := make([]func() (models.CustomFieldMap, error), len(keys))
for i, key := range keys {
results[i] = l.LoadThunk(key)
}
customFieldMaps := make([]models.CustomFieldMap, len(keys))
errors := make([]error, len(keys))
for i, thunk := range results {
customFieldMaps[i], errors[i] = thunk()
}
return customFieldMaps, errors
}
// LoadAllThunk returns a function that when called will block waiting for a CustomFieldMaps.
// This method should be used if you want one goroutine to make requests to many
// different data loaders without blocking until the thunk is called.
func (l *CustomFieldsLoader) LoadAllThunk(keys []int) func() ([]models.CustomFieldMap, []error) {
results := make([]func() (models.CustomFieldMap, error), len(keys))
for i, key := range keys {
results[i] = l.LoadThunk(key)
}
return func() ([]models.CustomFieldMap, []error) {
customFieldMaps := make([]models.CustomFieldMap, len(keys))
errors := make([]error, len(keys))
for i, thunk := range results {
customFieldMaps[i], errors[i] = thunk()
}
return customFieldMaps, errors
}
}
// Prime the cache with the provided key and value. If the key already exists, no change is made
// and false is returned.
// (To forcefully prime the cache, clear the key first with loader.clear(key).prime(key, value).)
func (l *CustomFieldsLoader) Prime(key int, value models.CustomFieldMap) bool {
l.mu.Lock()
var found bool
if _, found = l.cache[key]; !found {
l.unsafeSet(key, value)
}
l.mu.Unlock()
return !found
}
// Clear the value at key from the cache, if it exists
func (l *CustomFieldsLoader) Clear(key int) {
l.mu.Lock()
delete(l.cache, key)
l.mu.Unlock()
}
func (l *CustomFieldsLoader) unsafeSet(key int, value models.CustomFieldMap) {
if l.cache == nil {
l.cache = map[int]models.CustomFieldMap{}
}
l.cache[key] = value
}
// keyIndex will return the location of the key in the batch, if its not found
// it will add the key to the batch
func (b *customFieldsLoaderBatch) keyIndex(l *CustomFieldsLoader, key int) int {
for i, existingKey := range b.keys {
if key == existingKey {
return i
}
}
pos := len(b.keys)
b.keys = append(b.keys, key)
if pos == 0 {
go b.startTimer(l)
}
if l.maxBatch != 0 && pos >= l.maxBatch-1 {
if !b.closing {
b.closing = true
l.batch = nil
go b.end(l)
}
}
return pos
}
func (b *customFieldsLoaderBatch) startTimer(l *CustomFieldsLoader) {
time.Sleep(l.wait)
l.mu.Lock()
// we must have hit a batch limit and are already finalizing this batch
if b.closing {
l.mu.Unlock()
return
}
l.batch = nil
l.mu.Unlock()
b.end(l)
}
func (b *customFieldsLoaderBatch) end(l *CustomFieldsLoader) {
b.data, b.error = l.fetch(b.keys)
close(b.done)
}

View File

@@ -1,20 +1,14 @@
// Package loaders contains the dataloaders used by the resolver in [api].
// They are generated with `make generate-dataloaders`.
// The dataloaders are used to batch requests to the database.
//go:generate go run github.com/vektah/dataloaden SceneLoader int *github.com/stashapp/stash/pkg/models.Scene
//go:generate go run github.com/vektah/dataloaden GalleryLoader int *github.com/stashapp/stash/pkg/models.Gallery
//go:generate go run github.com/vektah/dataloaden ImageLoader int *github.com/stashapp/stash/pkg/models.Image
//go:generate go run github.com/vektah/dataloaden PerformerLoader int *github.com/stashapp/stash/pkg/models.Performer
//go:generate go run github.com/vektah/dataloaden StudioLoader int *github.com/stashapp/stash/pkg/models.Studio
//go:generate go run github.com/vektah/dataloaden TagLoader int *github.com/stashapp/stash/pkg/models.Tag
//go:generate go run github.com/vektah/dataloaden GroupLoader int *github.com/stashapp/stash/pkg/models.Group
//go:generate go run github.com/vektah/dataloaden MovieLoader int *github.com/stashapp/stash/pkg/models.Movie
//go:generate go run github.com/vektah/dataloaden FileLoader github.com/stashapp/stash/pkg/models.FileID github.com/stashapp/stash/pkg/models.File
//go:generate go run github.com/vektah/dataloaden FolderLoader github.com/stashapp/stash/pkg/models.FolderID *github.com/stashapp/stash/pkg/models.Folder
//go:generate go run github.com/vektah/dataloaden FolderRelatedFolderIDsLoader github.com/stashapp/stash/pkg/models.FolderID []github.com/stashapp/stash/pkg/models.FolderID
//go:generate go run github.com/vektah/dataloaden RelatedFileIDsLoader int []github.com/stashapp/stash/pkg/models.FileID
//go:generate go run github.com/vektah/dataloaden FileIDsRelatedIDsLoader github.com/stashapp/stash/pkg/models.FileID []int
//go:generate go run github.com/vektah/dataloaden CustomFieldsLoader int github.com/stashapp/stash/pkg/models.CustomFieldMap
//go:generate go run github.com/vektah/dataloaden SceneFileIDsLoader int []github.com/stashapp/stash/pkg/models.FileID
//go:generate go run github.com/vektah/dataloaden ImageFileIDsLoader int []github.com/stashapp/stash/pkg/models.FileID
//go:generate go run github.com/vektah/dataloaden GalleryFileIDsLoader int []github.com/stashapp/stash/pkg/models.FileID
//go:generate go run github.com/vektah/dataloaden SceneOCountLoader int int
//go:generate go run github.com/vektah/dataloaden ScenePlayCountLoader int int
//go:generate go run github.com/vektah/dataloaden SceneOHistoryLoader int []time.Time
@@ -42,43 +36,24 @@ const (
)
type Loaders struct {
SceneByID *SceneLoader
SceneIDsByFileID *FileIDsRelatedIDsLoader
SceneFiles *RelatedFileIDsLoader
ScenePlayCount *ScenePlayCountLoader
SceneOCount *SceneOCountLoader
ScenePlayHistory *ScenePlayHistoryLoader
SceneOHistory *SceneOHistoryLoader
SceneLastPlayed *SceneLastPlayedLoader
SceneCustomFields *CustomFieldsLoader
SceneByID *SceneLoader
SceneFiles *SceneFileIDsLoader
ScenePlayCount *ScenePlayCountLoader
SceneOCount *SceneOCountLoader
ScenePlayHistory *ScenePlayHistoryLoader
SceneOHistory *SceneOHistoryLoader
SceneLastPlayed *SceneLastPlayedLoader
ImageFiles *RelatedFileIDsLoader
GalleryFiles *RelatedFileIDsLoader
ImageFiles *ImageFileIDsLoader
GalleryFiles *GalleryFileIDsLoader
GalleryByID *GalleryLoader
GalleryIDsByFileID *FileIDsRelatedIDsLoader
GalleryCustomFields *CustomFieldsLoader
ImageByID *ImageLoader
ImageIDsByFileID *FileIDsRelatedIDsLoader
ImageCustomFields *CustomFieldsLoader
PerformerByID *PerformerLoader
PerformerCustomFields *CustomFieldsLoader
StudioByID *StudioLoader
StudioCustomFields *CustomFieldsLoader
TagByID *TagLoader
TagCustomFields *CustomFieldsLoader
GroupByID *GroupLoader
GroupCustomFields *CustomFieldsLoader
FileByID *FileLoader
FolderByID *FolderLoader
FolderParentFolderIDs *FolderRelatedFolderIDsLoader
FolderSubFolderIDs *FolderRelatedFolderIDsLoader
GalleryByID *GalleryLoader
ImageByID *ImageLoader
PerformerByID *PerformerLoader
StudioByID *StudioLoader
TagByID *TagLoader
MovieByID *MovieLoader
FileByID *FileLoader
}
type Middleware struct {
@@ -94,61 +69,21 @@ func (m Middleware) Middleware(next http.Handler) http.Handler {
maxBatch: maxBatch,
fetch: m.fetchScenes(ctx),
},
SceneIDsByFileID: &FileIDsRelatedIDsLoader{
wait: wait,
maxBatch: maxBatch,
fetch: m.fetchSceneIDsByFileID(ctx),
},
GalleryByID: &GalleryLoader{
wait: wait,
maxBatch: maxBatch,
fetch: m.fetchGalleries(ctx),
},
GalleryIDsByFileID: &FileIDsRelatedIDsLoader{
wait: wait,
maxBatch: maxBatch,
fetch: m.fetchGalleryIDsByFileID(ctx),
},
GalleryCustomFields: &CustomFieldsLoader{
wait: wait,
maxBatch: maxBatch,
fetch: m.fetchGalleryCustomFields(ctx),
},
ImageByID: &ImageLoader{
wait: wait,
maxBatch: maxBatch,
fetch: m.fetchImages(ctx),
},
ImageIDsByFileID: &FileIDsRelatedIDsLoader{
wait: wait,
maxBatch: maxBatch,
fetch: m.fetchImageIDsByFileID(ctx),
},
ImageCustomFields: &CustomFieldsLoader{
wait: wait,
maxBatch: maxBatch,
fetch: m.fetchImageCustomFields(ctx),
},
PerformerByID: &PerformerLoader{
wait: wait,
maxBatch: maxBatch,
fetch: m.fetchPerformers(ctx),
},
PerformerCustomFields: &CustomFieldsLoader{
wait: wait,
maxBatch: maxBatch,
fetch: m.fetchPerformerCustomFields(ctx),
},
StudioCustomFields: &CustomFieldsLoader{
wait: wait,
maxBatch: maxBatch,
fetch: m.fetchStudioCustomFields(ctx),
},
SceneCustomFields: &CustomFieldsLoader{
wait: wait,
maxBatch: maxBatch,
fetch: m.fetchSceneCustomFields(ctx),
},
StudioByID: &StudioLoader{
wait: wait,
maxBatch: maxBatch,
@@ -159,52 +94,27 @@ func (m Middleware) Middleware(next http.Handler) http.Handler {
maxBatch: maxBatch,
fetch: m.fetchTags(ctx),
},
TagCustomFields: &CustomFieldsLoader{
MovieByID: &MovieLoader{
wait: wait,
maxBatch: maxBatch,
fetch: m.fetchTagCustomFields(ctx),
},
GroupByID: &GroupLoader{
wait: wait,
maxBatch: maxBatch,
fetch: m.fetchGroups(ctx),
},
GroupCustomFields: &CustomFieldsLoader{
wait: wait,
maxBatch: maxBatch,
fetch: m.fetchGroupCustomFields(ctx),
fetch: m.fetchMovies(ctx),
},
FileByID: &FileLoader{
wait: wait,
maxBatch: maxBatch,
fetch: m.fetchFiles(ctx),
},
FolderByID: &FolderLoader{
wait: wait,
maxBatch: maxBatch,
fetch: m.fetchFolders(ctx),
},
FolderParentFolderIDs: &FolderRelatedFolderIDsLoader{
wait: wait,
maxBatch: maxBatch,
fetch: m.fetchFoldersParentFolderIDs(ctx),
},
FolderSubFolderIDs: &FolderRelatedFolderIDsLoader{
wait: wait,
maxBatch: maxBatch,
fetch: m.fetchFoldersSubFolderIDs(ctx),
},
SceneFiles: &RelatedFileIDsLoader{
SceneFiles: &SceneFileIDsLoader{
wait: wait,
maxBatch: maxBatch,
fetch: m.fetchScenesFileIDs(ctx),
},
ImageFiles: &RelatedFileIDsLoader{
ImageFiles: &ImageFileIDsLoader{
wait: wait,
maxBatch: maxBatch,
fetch: m.fetchImagesFileIDs(ctx),
},
GalleryFiles: &RelatedFileIDsLoader{
GalleryFiles: &GalleryFileIDsLoader{
wait: wait,
maxBatch: maxBatch,
fetch: m.fetchGalleriesFileIDs(ctx),
@@ -264,29 +174,6 @@ func (m Middleware) fetchScenes(ctx context.Context) func(keys []int) ([]*models
}
}
func (m Middleware) fetchSceneIDsByFileID(ctx context.Context) func(keys []models.FileID) ([][]int, []error) {
return func(keys []models.FileID) (ret [][]int, errs []error) {
err := m.Repository.WithDB(ctx, func(ctx context.Context) error {
var err error
ret, err = m.Repository.Scene.GetManyIDsByFileIDs(ctx, keys)
return err
})
return ret, toErrorSlice(err)
}
}
func (m Middleware) fetchSceneCustomFields(ctx context.Context) func(keys []int) ([]models.CustomFieldMap, []error) {
return func(keys []int) (ret []models.CustomFieldMap, errs []error) {
err := m.Repository.WithDB(ctx, func(ctx context.Context) error {
var err error
ret, err = m.Repository.Scene.GetCustomFieldsBulk(ctx, keys)
return err
})
return ret, toErrorSlice(err)
}
}
func (m Middleware) fetchImages(ctx context.Context) func(keys []int) ([]*models.Image, []error) {
return func(keys []int) (ret []*models.Image, errs []error) {
err := m.Repository.WithDB(ctx, func(ctx context.Context) error {
@@ -299,29 +186,6 @@ func (m Middleware) fetchImages(ctx context.Context) func(keys []int) ([]*models
}
}
func (m Middleware) fetchImageIDsByFileID(ctx context.Context) func(keys []models.FileID) ([][]int, []error) {
return func(keys []models.FileID) (ret [][]int, errs []error) {
err := m.Repository.WithDB(ctx, func(ctx context.Context) error {
var err error
ret, err = m.Repository.Image.GetManyIDsByFileIDs(ctx, keys)
return err
})
return ret, toErrorSlice(err)
}
}
func (m Middleware) fetchImageCustomFields(ctx context.Context) func(keys []int) ([]models.CustomFieldMap, []error) {
return func(keys []int) (ret []models.CustomFieldMap, errs []error) {
err := m.Repository.WithDB(ctx, func(ctx context.Context) error {
var err error
ret, err = m.Repository.Image.GetCustomFieldsBulk(ctx, keys)
return err
})
return ret, toErrorSlice(err)
}
}
func (m Middleware) fetchGalleries(ctx context.Context) func(keys []int) ([]*models.Gallery, []error) {
return func(keys []int) (ret []*models.Gallery, errs []error) {
err := m.Repository.WithDB(ctx, func(ctx context.Context) error {
@@ -334,17 +198,6 @@ func (m Middleware) fetchGalleries(ctx context.Context) func(keys []int) ([]*mod
}
}
func (m Middleware) fetchGalleryIDsByFileID(ctx context.Context) func(keys []models.FileID) ([][]int, []error) {
return func(keys []models.FileID) (ret [][]int, errs []error) {
err := m.Repository.WithDB(ctx, func(ctx context.Context) error {
var err error
ret, err = m.Repository.Gallery.GetManyIDsByFileIDs(ctx, keys)
return err
})
return ret, toErrorSlice(err)
}
}
func (m Middleware) fetchPerformers(ctx context.Context) func(keys []int) ([]*models.Performer, []error) {
return func(keys []int) (ret []*models.Performer, errs []error) {
err := m.Repository.WithDB(ctx, func(ctx context.Context) error {
@@ -357,18 +210,6 @@ func (m Middleware) fetchPerformers(ctx context.Context) func(keys []int) ([]*mo
}
}
func (m Middleware) fetchPerformerCustomFields(ctx context.Context) func(keys []int) ([]models.CustomFieldMap, []error) {
return func(keys []int) (ret []models.CustomFieldMap, errs []error) {
err := m.Repository.WithDB(ctx, func(ctx context.Context) error {
var err error
ret, err = m.Repository.Performer.GetCustomFieldsBulk(ctx, keys)
return err
})
return ret, toErrorSlice(err)
}
}
func (m Middleware) fetchStudios(ctx context.Context) func(keys []int) ([]*models.Studio, []error) {
return func(keys []int) (ret []*models.Studio, errs []error) {
err := m.Repository.WithDB(ctx, func(ctx context.Context) error {
@@ -380,18 +221,6 @@ func (m Middleware) fetchStudios(ctx context.Context) func(keys []int) ([]*model
}
}
func (m Middleware) fetchStudioCustomFields(ctx context.Context) func(keys []int) ([]models.CustomFieldMap, []error) {
return func(keys []int) (ret []models.CustomFieldMap, errs []error) {
err := m.Repository.WithDB(ctx, func(ctx context.Context) error {
var err error
ret, err = m.Repository.Studio.GetCustomFieldsBulk(ctx, keys)
return err
})
return ret, toErrorSlice(err)
}
}
func (m Middleware) fetchTags(ctx context.Context) func(keys []int) ([]*models.Tag, []error) {
return func(keys []int) (ret []*models.Tag, errs []error) {
err := m.Repository.WithDB(ctx, func(ctx context.Context) error {
@@ -403,47 +232,11 @@ func (m Middleware) fetchTags(ctx context.Context) func(keys []int) ([]*models.T
}
}
func (m Middleware) fetchTagCustomFields(ctx context.Context) func(keys []int) ([]models.CustomFieldMap, []error) {
return func(keys []int) (ret []models.CustomFieldMap, errs []error) {
func (m Middleware) fetchMovies(ctx context.Context) func(keys []int) ([]*models.Movie, []error) {
return func(keys []int) (ret []*models.Movie, errs []error) {
err := m.Repository.WithDB(ctx, func(ctx context.Context) error {
var err error
ret, err = m.Repository.Tag.GetCustomFieldsBulk(ctx, keys)
return err
})
return ret, toErrorSlice(err)
}
}
func (m Middleware) fetchGroupCustomFields(ctx context.Context) func(keys []int) ([]models.CustomFieldMap, []error) {
return func(keys []int) (ret []models.CustomFieldMap, errs []error) {
err := m.Repository.WithDB(ctx, func(ctx context.Context) error {
var err error
ret, err = m.Repository.Group.GetCustomFieldsBulk(ctx, keys)
return err
})
return ret, toErrorSlice(err)
}
}
func (m Middleware) fetchGalleryCustomFields(ctx context.Context) func(keys []int) ([]models.CustomFieldMap, []error) {
return func(keys []int) (ret []models.CustomFieldMap, errs []error) {
err := m.Repository.WithDB(ctx, func(ctx context.Context) error {
var err error
ret, err = m.Repository.Gallery.GetCustomFieldsBulk(ctx, keys)
return err
})
return ret, toErrorSlice(err)
}
}
func (m Middleware) fetchGroups(ctx context.Context) func(keys []int) ([]*models.Group, []error) {
return func(keys []int) (ret []*models.Group, errs []error) {
err := m.Repository.WithDB(ctx, func(ctx context.Context) error {
var err error
ret, err = m.Repository.Group.FindMany(ctx, keys)
ret, err = m.Repository.Movie.FindMany(ctx, keys)
return err
})
return ret, toErrorSlice(err)
@@ -461,39 +254,6 @@ func (m Middleware) fetchFiles(ctx context.Context) func(keys []models.FileID) (
}
}
func (m Middleware) fetchFolders(ctx context.Context) func(keys []models.FolderID) ([]*models.Folder, []error) {
return func(keys []models.FolderID) (ret []*models.Folder, errs []error) {
err := m.Repository.WithDB(ctx, func(ctx context.Context) error {
var err error
ret, err = m.Repository.Folder.FindMany(ctx, keys)
return err
})
return ret, toErrorSlice(err)
}
}
func (m Middleware) fetchFoldersParentFolderIDs(ctx context.Context) func(keys []models.FolderID) ([][]models.FolderID, []error) {
return func(keys []models.FolderID) (ret [][]models.FolderID, errs []error) {
err := m.Repository.WithDB(ctx, func(ctx context.Context) error {
var err error
ret, err = m.Repository.Folder.GetManyParentFolderIDs(ctx, keys)
return err
})
return ret, toErrorSlice(err)
}
}
func (m Middleware) fetchFoldersSubFolderIDs(ctx context.Context) func(keys []models.FolderID) ([][]models.FolderID, []error) {
return func(keys []models.FolderID) (ret [][]models.FolderID, errs []error) {
err := m.Repository.WithDB(ctx, func(ctx context.Context) error {
var err error
ret, err = m.Repository.Folder.GetManySubFolderIDs(ctx, keys)
return err
})
return ret, toErrorSlice(err)
}
}
func (m Middleware) fetchScenesFileIDs(ctx context.Context) func(keys []int) ([][]models.FileID, []error) {
return func(keys []int) (ret [][]models.FileID, errs []error) {
err := m.Repository.WithDB(ctx, func(ctx context.Context) error {

View File

@@ -1,225 +0,0 @@
// Code generated by github.com/vektah/dataloaden, DO NOT EDIT.
package loaders
import (
"sync"
"time"
"github.com/stashapp/stash/pkg/models"
)
// FolderParentFolderIDsLoaderConfig captures the config to create a new FolderParentFolderIDsLoader
type FolderParentFolderIDsLoaderConfig struct {
// Fetch is a method that provides the data for the loader
Fetch func(keys []models.FolderID) ([][]models.FolderID, []error)
// Wait is how long wait before sending a batch
Wait time.Duration
// MaxBatch will limit the maximum number of keys to send in one batch, 0 = not limit
MaxBatch int
}
// NewFolderParentFolderIDsLoader creates a new FolderParentFolderIDsLoader given a fetch, wait, and maxBatch
func NewFolderParentFolderIDsLoader(config FolderParentFolderIDsLoaderConfig) *FolderRelatedFolderIDsLoader {
return &FolderRelatedFolderIDsLoader{
fetch: config.Fetch,
wait: config.Wait,
maxBatch: config.MaxBatch,
}
}
// FolderRelatedFolderIDsLoader batches and caches requests
type FolderRelatedFolderIDsLoader struct {
// this method provides the data for the loader
fetch func(keys []models.FolderID) ([][]models.FolderID, []error)
// how long to done before sending a batch
wait time.Duration
// this will limit the maximum number of keys to send in one batch, 0 = no limit
maxBatch int
// INTERNAL
// lazily created cache
cache map[models.FolderID][]models.FolderID
// the current batch. keys will continue to be collected until timeout is hit,
// then everything will be sent to the fetch method and out to the listeners
batch *folderParentFolderIDsLoaderBatch
// mutex to prevent races
mu sync.Mutex
}
type folderParentFolderIDsLoaderBatch struct {
keys []models.FolderID
data [][]models.FolderID
error []error
closing bool
done chan struct{}
}
// Load a FolderID by key, batching and caching will be applied automatically
func (l *FolderRelatedFolderIDsLoader) Load(key models.FolderID) ([]models.FolderID, error) {
return l.LoadThunk(key)()
}
// LoadThunk returns a function that when called will block waiting for a FolderID.
// This method should be used if you want one goroutine to make requests to many
// different data loaders without blocking until the thunk is called.
func (l *FolderRelatedFolderIDsLoader) LoadThunk(key models.FolderID) func() ([]models.FolderID, error) {
l.mu.Lock()
if it, ok := l.cache[key]; ok {
l.mu.Unlock()
return func() ([]models.FolderID, error) {
return it, nil
}
}
if l.batch == nil {
l.batch = &folderParentFolderIDsLoaderBatch{done: make(chan struct{})}
}
batch := l.batch
pos := batch.keyIndex(l, key)
l.mu.Unlock()
return func() ([]models.FolderID, error) {
<-batch.done
var data []models.FolderID
if pos < len(batch.data) {
data = batch.data[pos]
}
var err error
// its convenient to be able to return a single error for everything
if len(batch.error) == 1 {
err = batch.error[0]
} else if batch.error != nil {
err = batch.error[pos]
}
if err == nil {
l.mu.Lock()
l.unsafeSet(key, data)
l.mu.Unlock()
}
return data, err
}
}
// LoadAll fetches many keys at once. It will be broken into appropriate sized
// sub batches depending on how the loader is configured
func (l *FolderRelatedFolderIDsLoader) LoadAll(keys []models.FolderID) ([][]models.FolderID, []error) {
results := make([]func() ([]models.FolderID, error), len(keys))
for i, key := range keys {
results[i] = l.LoadThunk(key)
}
folderIDs := make([][]models.FolderID, len(keys))
errors := make([]error, len(keys))
for i, thunk := range results {
folderIDs[i], errors[i] = thunk()
}
return folderIDs, errors
}
// LoadAllThunk returns a function that when called will block waiting for a FolderIDs.
// This method should be used if you want one goroutine to make requests to many
// different data loaders without blocking until the thunk is called.
func (l *FolderRelatedFolderIDsLoader) LoadAllThunk(keys []models.FolderID) func() ([][]models.FolderID, []error) {
results := make([]func() ([]models.FolderID, error), len(keys))
for i, key := range keys {
results[i] = l.LoadThunk(key)
}
return func() ([][]models.FolderID, []error) {
folderIDs := make([][]models.FolderID, len(keys))
errors := make([]error, len(keys))
for i, thunk := range results {
folderIDs[i], errors[i] = thunk()
}
return folderIDs, errors
}
}
// Prime the cache with the provided key and value. If the key already exists, no change is made
// and false is returned.
// (To forcefully prime the cache, clear the key first with loader.clear(key).prime(key, value).)
func (l *FolderRelatedFolderIDsLoader) Prime(key models.FolderID, value []models.FolderID) bool {
l.mu.Lock()
var found bool
if _, found = l.cache[key]; !found {
// make a copy when writing to the cache, its easy to pass a pointer in from a loop var
// and end up with the whole cache pointing to the same value.
cpy := make([]models.FolderID, len(value))
copy(cpy, value)
l.unsafeSet(key, cpy)
}
l.mu.Unlock()
return !found
}
// Clear the value at key from the cache, if it exists
func (l *FolderRelatedFolderIDsLoader) Clear(key models.FolderID) {
l.mu.Lock()
delete(l.cache, key)
l.mu.Unlock()
}
func (l *FolderRelatedFolderIDsLoader) unsafeSet(key models.FolderID, value []models.FolderID) {
if l.cache == nil {
l.cache = map[models.FolderID][]models.FolderID{}
}
l.cache[key] = value
}
// keyIndex will return the location of the key in the batch, if its not found
// it will add the key to the batch
func (b *folderParentFolderIDsLoaderBatch) keyIndex(l *FolderRelatedFolderIDsLoader, key models.FolderID) int {
for i, existingKey := range b.keys {
if key == existingKey {
return i
}
}
pos := len(b.keys)
b.keys = append(b.keys, key)
if pos == 0 {
go b.startTimer(l)
}
if l.maxBatch != 0 && pos >= l.maxBatch-1 {
if !b.closing {
b.closing = true
l.batch = nil
go b.end(l)
}
}
return pos
}
func (b *folderParentFolderIDsLoaderBatch) startTimer(l *FolderRelatedFolderIDsLoader) {
time.Sleep(l.wait)
l.mu.Lock()
// we must have hit a batch limit and are already finalizing this batch
if b.closing {
l.mu.Unlock()
return
}
l.batch = nil
l.mu.Unlock()
b.end(l)
}
func (b *folderParentFolderIDsLoaderBatch) end(l *FolderRelatedFolderIDsLoader) {
b.data, b.error = l.fetch(b.keys)
close(b.done)
}

View File

@@ -9,8 +9,8 @@ import (
"github.com/stashapp/stash/pkg/models"
)
// RelatedFileIDsLoaderConfig captures the config to create a new RelatedFileIDsLoader
type RelatedFileIDsLoaderConfig struct {
// GalleryFileIDsLoaderConfig captures the config to create a new GalleryFileIDsLoader
type GalleryFileIDsLoaderConfig struct {
// Fetch is a method that provides the data for the loader
Fetch func(keys []int) ([][]models.FileID, []error)
@@ -21,17 +21,17 @@ type RelatedFileIDsLoaderConfig struct {
MaxBatch int
}
// NewRelatedFileIDsLoader creates a new RelatedFileIDsLoader given a fetch, wait, and maxBatch
func NewRelatedFileIDsLoader(config RelatedFileIDsLoaderConfig) *RelatedFileIDsLoader {
return &RelatedFileIDsLoader{
// NewGalleryFileIDsLoader creates a new GalleryFileIDsLoader given a fetch, wait, and maxBatch
func NewGalleryFileIDsLoader(config GalleryFileIDsLoaderConfig) *GalleryFileIDsLoader {
return &GalleryFileIDsLoader{
fetch: config.Fetch,
wait: config.Wait,
maxBatch: config.MaxBatch,
}
}
// RelatedFileIDsLoader batches and caches requests
type RelatedFileIDsLoader struct {
// GalleryFileIDsLoader batches and caches requests
type GalleryFileIDsLoader struct {
// this method provides the data for the loader
fetch func(keys []int) ([][]models.FileID, []error)
@@ -48,13 +48,13 @@ type RelatedFileIDsLoader struct {
// the current batch. keys will continue to be collected until timeout is hit,
// then everything will be sent to the fetch method and out to the listeners
batch *relatedFileIDsLoaderBatch
batch *galleryFileIDsLoaderBatch
// mutex to prevent races
mu sync.Mutex
}
type relatedFileIDsLoaderBatch struct {
type galleryFileIDsLoaderBatch struct {
keys []int
data [][]models.FileID
error []error
@@ -63,14 +63,14 @@ type relatedFileIDsLoaderBatch struct {
}
// Load a FileID by key, batching and caching will be applied automatically
func (l *RelatedFileIDsLoader) Load(key int) ([]models.FileID, error) {
func (l *GalleryFileIDsLoader) Load(key int) ([]models.FileID, error) {
return l.LoadThunk(key)()
}
// LoadThunk returns a function that when called will block waiting for a FileID.
// This method should be used if you want one goroutine to make requests to many
// different data loaders without blocking until the thunk is called.
func (l *RelatedFileIDsLoader) LoadThunk(key int) func() ([]models.FileID, error) {
func (l *GalleryFileIDsLoader) LoadThunk(key int) func() ([]models.FileID, error) {
l.mu.Lock()
if it, ok := l.cache[key]; ok {
l.mu.Unlock()
@@ -79,7 +79,7 @@ func (l *RelatedFileIDsLoader) LoadThunk(key int) func() ([]models.FileID, error
}
}
if l.batch == nil {
l.batch = &relatedFileIDsLoaderBatch{done: make(chan struct{})}
l.batch = &galleryFileIDsLoaderBatch{done: make(chan struct{})}
}
batch := l.batch
pos := batch.keyIndex(l, key)
@@ -113,7 +113,7 @@ func (l *RelatedFileIDsLoader) LoadThunk(key int) func() ([]models.FileID, error
// LoadAll fetches many keys at once. It will be broken into appropriate sized
// sub batches depending on how the loader is configured
func (l *RelatedFileIDsLoader) LoadAll(keys []int) ([][]models.FileID, []error) {
func (l *GalleryFileIDsLoader) LoadAll(keys []int) ([][]models.FileID, []error) {
results := make([]func() ([]models.FileID, error), len(keys))
for i, key := range keys {
@@ -131,7 +131,7 @@ func (l *RelatedFileIDsLoader) LoadAll(keys []int) ([][]models.FileID, []error)
// LoadAllThunk returns a function that when called will block waiting for a FileIDs.
// This method should be used if you want one goroutine to make requests to many
// different data loaders without blocking until the thunk is called.
func (l *RelatedFileIDsLoader) LoadAllThunk(keys []int) func() ([][]models.FileID, []error) {
func (l *GalleryFileIDsLoader) LoadAllThunk(keys []int) func() ([][]models.FileID, []error) {
results := make([]func() ([]models.FileID, error), len(keys))
for i, key := range keys {
results[i] = l.LoadThunk(key)
@@ -149,7 +149,7 @@ func (l *RelatedFileIDsLoader) LoadAllThunk(keys []int) func() ([][]models.FileI
// Prime the cache with the provided key and value. If the key already exists, no change is made
// and false is returned.
// (To forcefully prime the cache, clear the key first with loader.clear(key).prime(key, value).)
func (l *RelatedFileIDsLoader) Prime(key int, value []models.FileID) bool {
func (l *GalleryFileIDsLoader) Prime(key int, value []models.FileID) bool {
l.mu.Lock()
var found bool
if _, found = l.cache[key]; !found {
@@ -164,13 +164,13 @@ func (l *RelatedFileIDsLoader) Prime(key int, value []models.FileID) bool {
}
// Clear the value at key from the cache, if it exists
func (l *RelatedFileIDsLoader) Clear(key int) {
func (l *GalleryFileIDsLoader) Clear(key int) {
l.mu.Lock()
delete(l.cache, key)
l.mu.Unlock()
}
func (l *RelatedFileIDsLoader) unsafeSet(key int, value []models.FileID) {
func (l *GalleryFileIDsLoader) unsafeSet(key int, value []models.FileID) {
if l.cache == nil {
l.cache = map[int][]models.FileID{}
}
@@ -179,7 +179,7 @@ func (l *RelatedFileIDsLoader) unsafeSet(key int, value []models.FileID) {
// keyIndex will return the location of the key in the batch, if its not found
// it will add the key to the batch
func (b *relatedFileIDsLoaderBatch) keyIndex(l *RelatedFileIDsLoader, key int) int {
func (b *galleryFileIDsLoaderBatch) keyIndex(l *GalleryFileIDsLoader, key int) int {
for i, existingKey := range b.keys {
if key == existingKey {
return i
@@ -203,7 +203,7 @@ func (b *relatedFileIDsLoaderBatch) keyIndex(l *RelatedFileIDsLoader, key int) i
return pos
}
func (b *relatedFileIDsLoaderBatch) startTimer(l *RelatedFileIDsLoader) {
func (b *galleryFileIDsLoaderBatch) startTimer(l *GalleryFileIDsLoader) {
time.Sleep(l.wait)
l.mu.Lock()
@@ -219,7 +219,7 @@ func (b *relatedFileIDsLoaderBatch) startTimer(l *RelatedFileIDsLoader) {
b.end(l)
}
func (b *relatedFileIDsLoaderBatch) end(l *RelatedFileIDsLoader) {
func (b *galleryFileIDsLoaderBatch) end(l *GalleryFileIDsLoader) {
b.data, b.error = l.fetch(b.keys)
close(b.done)
}

View File

@@ -9,10 +9,10 @@ import (
"github.com/stashapp/stash/pkg/models"
)
// FileIDsRelatedIDsLoaderConfig captures the config to create a new FileIDsRelatedIDsLoader
type FileIDsRelatedIDsLoaderConfig struct {
// ImageFileIDsLoaderConfig captures the config to create a new ImageFileIDsLoader
type ImageFileIDsLoaderConfig struct {
// Fetch is a method that provides the data for the loader
Fetch func(keys []models.FileID) ([][]int, []error)
Fetch func(keys []int) ([][]models.FileID, []error)
// Wait is how long wait before sending a batch
Wait time.Duration
@@ -21,19 +21,19 @@ type FileIDsRelatedIDsLoaderConfig struct {
MaxBatch int
}
// NewFileIDsRelatedIDsLoader creates a new FileIDsRelatedIDsLoader given a fetch, wait, and maxBatch
func NewFileIDsRelatedIDsLoader(config FileIDsRelatedIDsLoaderConfig) *FileIDsRelatedIDsLoader {
return &FileIDsRelatedIDsLoader{
// NewImageFileIDsLoader creates a new ImageFileIDsLoader given a fetch, wait, and maxBatch
func NewImageFileIDsLoader(config ImageFileIDsLoaderConfig) *ImageFileIDsLoader {
return &ImageFileIDsLoader{
fetch: config.Fetch,
wait: config.Wait,
maxBatch: config.MaxBatch,
}
}
// FileIDsRelatedIDsLoader batches and caches requests
type FileIDsRelatedIDsLoader struct {
// ImageFileIDsLoader batches and caches requests
type ImageFileIDsLoader struct {
// this method provides the data for the loader
fetch func(keys []models.FileID) ([][]int, []error)
fetch func(keys []int) ([][]models.FileID, []error)
// how long to done before sending a batch
wait time.Duration
@@ -44,51 +44,51 @@ type FileIDsRelatedIDsLoader struct {
// INTERNAL
// lazily created cache
cache map[models.FileID][]int
cache map[int][]models.FileID
// the current batch. keys will continue to be collected until timeout is hit,
// then everything will be sent to the fetch method and out to the listeners
batch *fileIDsRelatedIDsLoaderBatch
batch *imageFileIDsLoaderBatch
// mutex to prevent races
mu sync.Mutex
}
type fileIDsRelatedIDsLoaderBatch struct {
keys []models.FileID
data [][]int
type imageFileIDsLoaderBatch struct {
keys []int
data [][]models.FileID
error []error
closing bool
done chan struct{}
}
// Load a int by key, batching and caching will be applied automatically
func (l *FileIDsRelatedIDsLoader) Load(key models.FileID) ([]int, error) {
// Load a FileID by key, batching and caching will be applied automatically
func (l *ImageFileIDsLoader) Load(key int) ([]models.FileID, error) {
return l.LoadThunk(key)()
}
// LoadThunk returns a function that when called will block waiting for a int.
// LoadThunk returns a function that when called will block waiting for a FileID.
// This method should be used if you want one goroutine to make requests to many
// different data loaders without blocking until the thunk is called.
func (l *FileIDsRelatedIDsLoader) LoadThunk(key models.FileID) func() ([]int, error) {
func (l *ImageFileIDsLoader) LoadThunk(key int) func() ([]models.FileID, error) {
l.mu.Lock()
if it, ok := l.cache[key]; ok {
l.mu.Unlock()
return func() ([]int, error) {
return func() ([]models.FileID, error) {
return it, nil
}
}
if l.batch == nil {
l.batch = &fileIDsRelatedIDsLoaderBatch{done: make(chan struct{})}
l.batch = &imageFileIDsLoaderBatch{done: make(chan struct{})}
}
batch := l.batch
pos := batch.keyIndex(l, key)
l.mu.Unlock()
return func() ([]int, error) {
return func() ([]models.FileID, error) {
<-batch.done
var data []int
var data []models.FileID
if pos < len(batch.data) {
data = batch.data[pos]
}
@@ -113,49 +113,49 @@ func (l *FileIDsRelatedIDsLoader) LoadThunk(key models.FileID) func() ([]int, er
// LoadAll fetches many keys at once. It will be broken into appropriate sized
// sub batches depending on how the loader is configured
func (l *FileIDsRelatedIDsLoader) LoadAll(keys []models.FileID) ([][]int, []error) {
results := make([]func() ([]int, error), len(keys))
func (l *ImageFileIDsLoader) LoadAll(keys []int) ([][]models.FileID, []error) {
results := make([]func() ([]models.FileID, error), len(keys))
for i, key := range keys {
results[i] = l.LoadThunk(key)
}
ints := make([][]int, len(keys))
fileIDs := make([][]models.FileID, len(keys))
errors := make([]error, len(keys))
for i, thunk := range results {
ints[i], errors[i] = thunk()
fileIDs[i], errors[i] = thunk()
}
return ints, errors
return fileIDs, errors
}
// LoadAllThunk returns a function that when called will block waiting for a ints.
// LoadAllThunk returns a function that when called will block waiting for a FileIDs.
// This method should be used if you want one goroutine to make requests to many
// different data loaders without blocking until the thunk is called.
func (l *FileIDsRelatedIDsLoader) LoadAllThunk(keys []models.FileID) func() ([][]int, []error) {
results := make([]func() ([]int, error), len(keys))
func (l *ImageFileIDsLoader) LoadAllThunk(keys []int) func() ([][]models.FileID, []error) {
results := make([]func() ([]models.FileID, error), len(keys))
for i, key := range keys {
results[i] = l.LoadThunk(key)
}
return func() ([][]int, []error) {
ints := make([][]int, len(keys))
return func() ([][]models.FileID, []error) {
fileIDs := make([][]models.FileID, len(keys))
errors := make([]error, len(keys))
for i, thunk := range results {
ints[i], errors[i] = thunk()
fileIDs[i], errors[i] = thunk()
}
return ints, errors
return fileIDs, errors
}
}
// Prime the cache with the provided key and value. If the key already exists, no change is made
// and false is returned.
// (To forcefully prime the cache, clear the key first with loader.clear(key).prime(key, value).)
func (l *FileIDsRelatedIDsLoader) Prime(key models.FileID, value []int) bool {
func (l *ImageFileIDsLoader) Prime(key int, value []models.FileID) bool {
l.mu.Lock()
var found bool
if _, found = l.cache[key]; !found {
// make a copy when writing to the cache, its easy to pass a pointer in from a loop var
// and end up with the whole cache pointing to the same value.
cpy := make([]int, len(value))
cpy := make([]models.FileID, len(value))
copy(cpy, value)
l.unsafeSet(key, cpy)
}
@@ -164,22 +164,22 @@ func (l *FileIDsRelatedIDsLoader) Prime(key models.FileID, value []int) bool {
}
// Clear the value at key from the cache, if it exists
func (l *FileIDsRelatedIDsLoader) Clear(key models.FileID) {
func (l *ImageFileIDsLoader) Clear(key int) {
l.mu.Lock()
delete(l.cache, key)
l.mu.Unlock()
}
func (l *FileIDsRelatedIDsLoader) unsafeSet(key models.FileID, value []int) {
func (l *ImageFileIDsLoader) unsafeSet(key int, value []models.FileID) {
if l.cache == nil {
l.cache = map[models.FileID][]int{}
l.cache = map[int][]models.FileID{}
}
l.cache[key] = value
}
// keyIndex will return the location of the key in the batch, if its not found
// it will add the key to the batch
func (b *fileIDsRelatedIDsLoaderBatch) keyIndex(l *FileIDsRelatedIDsLoader, key models.FileID) int {
func (b *imageFileIDsLoaderBatch) keyIndex(l *ImageFileIDsLoader, key int) int {
for i, existingKey := range b.keys {
if key == existingKey {
return i
@@ -203,7 +203,7 @@ func (b *fileIDsRelatedIDsLoaderBatch) keyIndex(l *FileIDsRelatedIDsLoader, key
return pos
}
func (b *fileIDsRelatedIDsLoaderBatch) startTimer(l *FileIDsRelatedIDsLoader) {
func (b *imageFileIDsLoaderBatch) startTimer(l *ImageFileIDsLoader) {
time.Sleep(l.wait)
l.mu.Lock()
@@ -219,7 +219,7 @@ func (b *fileIDsRelatedIDsLoaderBatch) startTimer(l *FileIDsRelatedIDsLoader) {
b.end(l)
}
func (b *fileIDsRelatedIDsLoaderBatch) end(l *FileIDsRelatedIDsLoader) {
func (b *imageFileIDsLoaderBatch) end(l *ImageFileIDsLoader) {
b.data, b.error = l.fetch(b.keys)
close(b.done)
}

View File

@@ -9,10 +9,10 @@ import (
"github.com/stashapp/stash/pkg/models"
)
// GroupLoaderConfig captures the config to create a new GroupLoader
type GroupLoaderConfig struct {
// MovieLoaderConfig captures the config to create a new MovieLoader
type MovieLoaderConfig struct {
// Fetch is a method that provides the data for the loader
Fetch func(keys []int) ([]*models.Group, []error)
Fetch func(keys []int) ([]*models.Movie, []error)
// Wait is how long wait before sending a batch
Wait time.Duration
@@ -21,19 +21,19 @@ type GroupLoaderConfig struct {
MaxBatch int
}
// NewGroupLoader creates a new GroupLoader given a fetch, wait, and maxBatch
func NewGroupLoader(config GroupLoaderConfig) *GroupLoader {
return &GroupLoader{
// NewMovieLoader creates a new MovieLoader given a fetch, wait, and maxBatch
func NewMovieLoader(config MovieLoaderConfig) *MovieLoader {
return &MovieLoader{
fetch: config.Fetch,
wait: config.Wait,
maxBatch: config.MaxBatch,
}
}
// GroupLoader batches and caches requests
type GroupLoader struct {
// MovieLoader batches and caches requests
type MovieLoader struct {
// this method provides the data for the loader
fetch func(keys []int) ([]*models.Group, []error)
fetch func(keys []int) ([]*models.Movie, []error)
// how long to done before sending a batch
wait time.Duration
@@ -44,51 +44,51 @@ type GroupLoader struct {
// INTERNAL
// lazily created cache
cache map[int]*models.Group
cache map[int]*models.Movie
// the current batch. keys will continue to be collected until timeout is hit,
// then everything will be sent to the fetch method and out to the listeners
batch *groupLoaderBatch
batch *movieLoaderBatch
// mutex to prevent races
mu sync.Mutex
}
type groupLoaderBatch struct {
type movieLoaderBatch struct {
keys []int
data []*models.Group
data []*models.Movie
error []error
closing bool
done chan struct{}
}
// Load a Group by key, batching and caching will be applied automatically
func (l *GroupLoader) Load(key int) (*models.Group, error) {
// Load a Movie by key, batching and caching will be applied automatically
func (l *MovieLoader) Load(key int) (*models.Movie, error) {
return l.LoadThunk(key)()
}
// LoadThunk returns a function that when called will block waiting for a Group.
// LoadThunk returns a function that when called will block waiting for a Movie.
// This method should be used if you want one goroutine to make requests to many
// different data loaders without blocking until the thunk is called.
func (l *GroupLoader) LoadThunk(key int) func() (*models.Group, error) {
func (l *MovieLoader) LoadThunk(key int) func() (*models.Movie, error) {
l.mu.Lock()
if it, ok := l.cache[key]; ok {
l.mu.Unlock()
return func() (*models.Group, error) {
return func() (*models.Movie, error) {
return it, nil
}
}
if l.batch == nil {
l.batch = &groupLoaderBatch{done: make(chan struct{})}
l.batch = &movieLoaderBatch{done: make(chan struct{})}
}
batch := l.batch
pos := batch.keyIndex(l, key)
l.mu.Unlock()
return func() (*models.Group, error) {
return func() (*models.Movie, error) {
<-batch.done
var data *models.Group
var data *models.Movie
if pos < len(batch.data) {
data = batch.data[pos]
}
@@ -113,43 +113,43 @@ func (l *GroupLoader) LoadThunk(key int) func() (*models.Group, error) {
// LoadAll fetches many keys at once. It will be broken into appropriate sized
// sub batches depending on how the loader is configured
func (l *GroupLoader) LoadAll(keys []int) ([]*models.Group, []error) {
results := make([]func() (*models.Group, error), len(keys))
func (l *MovieLoader) LoadAll(keys []int) ([]*models.Movie, []error) {
results := make([]func() (*models.Movie, error), len(keys))
for i, key := range keys {
results[i] = l.LoadThunk(key)
}
groups := make([]*models.Group, len(keys))
movies := make([]*models.Movie, len(keys))
errors := make([]error, len(keys))
for i, thunk := range results {
groups[i], errors[i] = thunk()
movies[i], errors[i] = thunk()
}
return groups, errors
return movies, errors
}
// LoadAllThunk returns a function that when called will block waiting for a Groups.
// LoadAllThunk returns a function that when called will block waiting for a Movies.
// This method should be used if you want one goroutine to make requests to many
// different data loaders without blocking until the thunk is called.
func (l *GroupLoader) LoadAllThunk(keys []int) func() ([]*models.Group, []error) {
results := make([]func() (*models.Group, error), len(keys))
func (l *MovieLoader) LoadAllThunk(keys []int) func() ([]*models.Movie, []error) {
results := make([]func() (*models.Movie, error), len(keys))
for i, key := range keys {
results[i] = l.LoadThunk(key)
}
return func() ([]*models.Group, []error) {
groups := make([]*models.Group, len(keys))
return func() ([]*models.Movie, []error) {
movies := make([]*models.Movie, len(keys))
errors := make([]error, len(keys))
for i, thunk := range results {
groups[i], errors[i] = thunk()
movies[i], errors[i] = thunk()
}
return groups, errors
return movies, errors
}
}
// Prime the cache with the provided key and value. If the key already exists, no change is made
// and false is returned.
// (To forcefully prime the cache, clear the key first with loader.clear(key).prime(key, value).)
func (l *GroupLoader) Prime(key int, value *models.Group) bool {
func (l *MovieLoader) Prime(key int, value *models.Movie) bool {
l.mu.Lock()
var found bool
if _, found = l.cache[key]; !found {
@@ -163,22 +163,22 @@ func (l *GroupLoader) Prime(key int, value *models.Group) bool {
}
// Clear the value at key from the cache, if it exists
func (l *GroupLoader) Clear(key int) {
func (l *MovieLoader) Clear(key int) {
l.mu.Lock()
delete(l.cache, key)
l.mu.Unlock()
}
func (l *GroupLoader) unsafeSet(key int, value *models.Group) {
func (l *MovieLoader) unsafeSet(key int, value *models.Movie) {
if l.cache == nil {
l.cache = map[int]*models.Group{}
l.cache = map[int]*models.Movie{}
}
l.cache[key] = value
}
// keyIndex will return the location of the key in the batch, if its not found
// it will add the key to the batch
func (b *groupLoaderBatch) keyIndex(l *GroupLoader, key int) int {
func (b *movieLoaderBatch) keyIndex(l *MovieLoader, key int) int {
for i, existingKey := range b.keys {
if key == existingKey {
return i
@@ -202,7 +202,7 @@ func (b *groupLoaderBatch) keyIndex(l *GroupLoader, key int) int {
return pos
}
func (b *groupLoaderBatch) startTimer(l *GroupLoader) {
func (b *movieLoaderBatch) startTimer(l *MovieLoader) {
time.Sleep(l.wait)
l.mu.Lock()
@@ -218,7 +218,7 @@ func (b *groupLoaderBatch) startTimer(l *GroupLoader) {
b.end(l)
}
func (b *groupLoaderBatch) end(l *GroupLoader) {
func (b *movieLoaderBatch) end(l *MovieLoader) {
b.data, b.error = l.fetch(b.keys)
close(b.done)
}

View File

@@ -9,10 +9,10 @@ import (
"github.com/stashapp/stash/pkg/models"
)
// FolderLoaderConfig captures the config to create a new FolderLoader
type FolderLoaderConfig struct {
// SceneFileIDsLoaderConfig captures the config to create a new SceneFileIDsLoader
type SceneFileIDsLoaderConfig struct {
// Fetch is a method that provides the data for the loader
Fetch func(keys []models.FolderID) ([]*models.Folder, []error)
Fetch func(keys []int) ([][]models.FileID, []error)
// Wait is how long wait before sending a batch
Wait time.Duration
@@ -21,19 +21,19 @@ type FolderLoaderConfig struct {
MaxBatch int
}
// NewFolderLoader creates a new FolderLoader given a fetch, wait, and maxBatch
func NewFolderLoader(config FolderLoaderConfig) *FolderLoader {
return &FolderLoader{
// NewSceneFileIDsLoader creates a new SceneFileIDsLoader given a fetch, wait, and maxBatch
func NewSceneFileIDsLoader(config SceneFileIDsLoaderConfig) *SceneFileIDsLoader {
return &SceneFileIDsLoader{
fetch: config.Fetch,
wait: config.Wait,
maxBatch: config.MaxBatch,
}
}
// FolderLoader batches and caches requests
type FolderLoader struct {
// SceneFileIDsLoader batches and caches requests
type SceneFileIDsLoader struct {
// this method provides the data for the loader
fetch func(keys []models.FolderID) ([]*models.Folder, []error)
fetch func(keys []int) ([][]models.FileID, []error)
// how long to done before sending a batch
wait time.Duration
@@ -44,51 +44,51 @@ type FolderLoader struct {
// INTERNAL
// lazily created cache
cache map[models.FolderID]*models.Folder
cache map[int][]models.FileID
// the current batch. keys will continue to be collected until timeout is hit,
// then everything will be sent to the fetch method and out to the listeners
batch *folderLoaderBatch
batch *sceneFileIDsLoaderBatch
// mutex to prevent races
mu sync.Mutex
}
type folderLoaderBatch struct {
keys []models.FolderID
data []*models.Folder
type sceneFileIDsLoaderBatch struct {
keys []int
data [][]models.FileID
error []error
closing bool
done chan struct{}
}
// Load a Folder by key, batching and caching will be applied automatically
func (l *FolderLoader) Load(key models.FolderID) (*models.Folder, error) {
// Load a FileID by key, batching and caching will be applied automatically
func (l *SceneFileIDsLoader) Load(key int) ([]models.FileID, error) {
return l.LoadThunk(key)()
}
// LoadThunk returns a function that when called will block waiting for a Folder.
// LoadThunk returns a function that when called will block waiting for a FileID.
// This method should be used if you want one goroutine to make requests to many
// different data loaders without blocking until the thunk is called.
func (l *FolderLoader) LoadThunk(key models.FolderID) func() (*models.Folder, error) {
func (l *SceneFileIDsLoader) LoadThunk(key int) func() ([]models.FileID, error) {
l.mu.Lock()
if it, ok := l.cache[key]; ok {
l.mu.Unlock()
return func() (*models.Folder, error) {
return func() ([]models.FileID, error) {
return it, nil
}
}
if l.batch == nil {
l.batch = &folderLoaderBatch{done: make(chan struct{})}
l.batch = &sceneFileIDsLoaderBatch{done: make(chan struct{})}
}
batch := l.batch
pos := batch.keyIndex(l, key)
l.mu.Unlock()
return func() (*models.Folder, error) {
return func() ([]models.FileID, error) {
<-batch.done
var data *models.Folder
var data []models.FileID
if pos < len(batch.data) {
data = batch.data[pos]
}
@@ -113,72 +113,73 @@ func (l *FolderLoader) LoadThunk(key models.FolderID) func() (*models.Folder, er
// LoadAll fetches many keys at once. It will be broken into appropriate sized
// sub batches depending on how the loader is configured
func (l *FolderLoader) LoadAll(keys []models.FolderID) ([]*models.Folder, []error) {
results := make([]func() (*models.Folder, error), len(keys))
func (l *SceneFileIDsLoader) LoadAll(keys []int) ([][]models.FileID, []error) {
results := make([]func() ([]models.FileID, error), len(keys))
for i, key := range keys {
results[i] = l.LoadThunk(key)
}
folders := make([]*models.Folder, len(keys))
fileIDs := make([][]models.FileID, len(keys))
errors := make([]error, len(keys))
for i, thunk := range results {
folders[i], errors[i] = thunk()
fileIDs[i], errors[i] = thunk()
}
return folders, errors
return fileIDs, errors
}
// LoadAllThunk returns a function that when called will block waiting for a Folders.
// LoadAllThunk returns a function that when called will block waiting for a FileIDs.
// This method should be used if you want one goroutine to make requests to many
// different data loaders without blocking until the thunk is called.
func (l *FolderLoader) LoadAllThunk(keys []models.FolderID) func() ([]*models.Folder, []error) {
results := make([]func() (*models.Folder, error), len(keys))
func (l *SceneFileIDsLoader) LoadAllThunk(keys []int) func() ([][]models.FileID, []error) {
results := make([]func() ([]models.FileID, error), len(keys))
for i, key := range keys {
results[i] = l.LoadThunk(key)
}
return func() ([]*models.Folder, []error) {
folders := make([]*models.Folder, len(keys))
return func() ([][]models.FileID, []error) {
fileIDs := make([][]models.FileID, len(keys))
errors := make([]error, len(keys))
for i, thunk := range results {
folders[i], errors[i] = thunk()
fileIDs[i], errors[i] = thunk()
}
return folders, errors
return fileIDs, errors
}
}
// Prime the cache with the provided key and value. If the key already exists, no change is made
// and false is returned.
// (To forcefully prime the cache, clear the key first with loader.clear(key).prime(key, value).)
func (l *FolderLoader) Prime(key models.FolderID, value *models.Folder) bool {
func (l *SceneFileIDsLoader) Prime(key int, value []models.FileID) bool {
l.mu.Lock()
var found bool
if _, found = l.cache[key]; !found {
// make a copy when writing to the cache, its easy to pass a pointer in from a loop var
// and end up with the whole cache pointing to the same value.
cpy := *value
l.unsafeSet(key, &cpy)
cpy := make([]models.FileID, len(value))
copy(cpy, value)
l.unsafeSet(key, cpy)
}
l.mu.Unlock()
return !found
}
// Clear the value at key from the cache, if it exists
func (l *FolderLoader) Clear(key models.FolderID) {
func (l *SceneFileIDsLoader) Clear(key int) {
l.mu.Lock()
delete(l.cache, key)
l.mu.Unlock()
}
func (l *FolderLoader) unsafeSet(key models.FolderID, value *models.Folder) {
func (l *SceneFileIDsLoader) unsafeSet(key int, value []models.FileID) {
if l.cache == nil {
l.cache = map[models.FolderID]*models.Folder{}
l.cache = map[int][]models.FileID{}
}
l.cache[key] = value
}
// keyIndex will return the location of the key in the batch, if its not found
// it will add the key to the batch
func (b *folderLoaderBatch) keyIndex(l *FolderLoader, key models.FolderID) int {
func (b *sceneFileIDsLoaderBatch) keyIndex(l *SceneFileIDsLoader, key int) int {
for i, existingKey := range b.keys {
if key == existingKey {
return i
@@ -202,7 +203,7 @@ func (b *folderLoaderBatch) keyIndex(l *FolderLoader, key models.FolderID) int {
return pos
}
func (b *folderLoaderBatch) startTimer(l *FolderLoader) {
func (b *sceneFileIDsLoaderBatch) startTimer(l *SceneFileIDsLoader) {
time.Sleep(l.wait)
l.mu.Lock()
@@ -218,7 +219,7 @@ func (b *folderLoaderBatch) startTimer(l *FolderLoader) {
b.end(l)
}
func (b *folderLoaderBatch) end(l *FolderLoader) {
func (b *sceneFileIDsLoaderBatch) end(l *SceneFileIDsLoader) {
b.data, b.error = l.fetch(b.keys)
close(b.done)
}

View File

@@ -4,7 +4,6 @@ import (
"fmt"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/sliceutil"
)
type BaseFile interface {
@@ -28,36 +27,14 @@ func convertVisualFile(f models.File) (VisualFile, error) {
}
}
func convertBaseFile(f models.File) BaseFile {
if f == nil {
return nil
}
switch f := f.(type) {
case BaseFile:
return f
case *models.VideoFile:
return &VideoFile{VideoFile: f}
case *models.ImageFile:
return &ImageFile{ImageFile: f}
case *models.BaseFile:
// assume gallery file if it's not a video or image file
return &GalleryFile{BaseFile: f}
default:
panic("unknown file type")
}
}
func convertBaseFiles(files []models.File) []BaseFile {
return sliceutil.Map(files, convertBaseFile)
}
type GalleryFile struct {
*models.BaseFile
}
func (GalleryFile) IsBaseFile() {}
func (GalleryFile) IsVisualFile() {}
func (f *GalleryFile) Fingerprints() []models.Fingerprint {
return f.BaseFile.Fingerprints
}
@@ -85,15 +62,3 @@ func (ImageFile) IsVisualFile() {}
func (f *ImageFile) Fingerprints() []models.Fingerprint {
return f.ImageFile.Fingerprints
}
type BasicFile struct {
*models.BaseFile
}
func (BasicFile) IsBaseFile() {}
func (BasicFile) IsVisualFile() {}
func (f *BasicFile) Fingerprints() []models.Fingerprint {
return f.BaseFile.Fingerprints
}

View File

@@ -7,13 +7,13 @@ import (
"sort"
"strconv"
"github.com/99designs/gqlgen/graphql"
"github.com/stashapp/stash/internal/build"
"github.com/stashapp/stash/internal/manager"
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/plugin/hook"
"github.com/stashapp/stash/pkg/scraper"
"github.com/stashapp/stash/pkg/scraper/stashbox"
)
var (
@@ -37,7 +37,6 @@ type Resolver struct {
sceneService manager.SceneService
imageService manager.ImageService
galleryService manager.GalleryService
groupService manager.GroupService
hookExecutor hookExecutor
}
@@ -73,14 +72,9 @@ func (r *Resolver) SceneMarker() SceneMarkerResolver {
func (r *Resolver) Studio() StudioResolver {
return &studioResolver{r}
}
func (r *Resolver) Group() GroupResolver {
return &groupResolver{r}
}
func (r *Resolver) Movie() MovieResolver {
return &movieResolver{&groupResolver{r}}
return &movieResolver{r}
}
func (r *Resolver) Subscription() SubscriptionResolver {
return &subscriptionResolver{r}
}
@@ -96,12 +90,6 @@ func (r *Resolver) VideoFile() VideoFileResolver {
func (r *Resolver) ImageFile() ImageFileResolver {
return &imageFileResolver{r}
}
func (r *Resolver) BasicFile() BasicFileResolver {
return &basicFileResolver{r}
}
func (r *Resolver) Folder() FolderResolver {
return &folderResolver{r}
}
func (r *Resolver) SavedFilter() SavedFilterResolver {
return &savedFilterResolver{r}
}
@@ -123,17 +111,11 @@ type sceneResolver struct{ *Resolver }
type sceneMarkerResolver struct{ *Resolver }
type imageResolver struct{ *Resolver }
type studioResolver struct{ *Resolver }
// movie is group under the hood
type groupResolver struct{ *Resolver }
type movieResolver struct{ *groupResolver }
type movieResolver struct{ *Resolver }
type tagResolver struct{ *Resolver }
type galleryFileResolver struct{ *Resolver }
type videoFileResolver struct{ *Resolver }
type imageFileResolver struct{ *Resolver }
type basicFileResolver struct{ *Resolver }
type folderResolver struct{ *Resolver }
type savedFilterResolver struct{ *Resolver }
type pluginResolver struct{ *Resolver }
type configResultResolver struct{ *Resolver }
@@ -146,11 +128,8 @@ func (r *Resolver) withReadTxn(ctx context.Context, fn func(ctx context.Context)
return r.repository.WithReadTxn(ctx, fn)
}
// idOnly returns true if the query is only asking for the id field.
// This can be used to optimize certain queries where we don't need to load the full object if we're only getting the id.
func (r *Resolver) idOnly(ctx context.Context) bool {
fields := graphql.CollectAllFields(ctx)
return len(fields) == 1 && fields[0] == "id"
func (r *Resolver) stashboxRepository() stashbox.Repository {
return stashbox.NewRepository(r.repository)
}
func (r *queryResolver) MarkerWall(ctx context.Context, q *string) (ret []*models.SceneMarker, err error) {
@@ -194,7 +173,7 @@ func (r *queryResolver) Stats(ctx context.Context) (*StatsResultType, error) {
galleryQB := repo.Gallery
studioQB := repo.Studio
performerQB := repo.Performer
movieQB := repo.Group
movieQB := repo.Movie
tagQB := repo.Tag
// embrace the error
@@ -239,7 +218,7 @@ func (r *queryResolver) Stats(ctx context.Context) (*StatsResultType, error) {
return err
}
groupsCount, err := movieQB.Count(ctx)
moviesCount, err := movieQB.Count(ctx)
if err != nil {
return err
}
@@ -283,8 +262,7 @@ func (r *queryResolver) Stats(ctx context.Context) (*StatsResultType, error) {
GalleryCount: galleryCount,
PerformerCount: performersCount,
StudioCount: studiosCount,
GroupCount: groupsCount,
MovieCount: groupsCount,
MovieCount: moviesCount,
TagCount: tagsCount,
TotalOCount: totalOCount,
TotalPlayDuration: totalPlayDuration,

View File

@@ -1,113 +1,30 @@
package api
import (
"context"
import "context"
"github.com/stashapp/stash/internal/api/loaders"
"github.com/stashapp/stash/pkg/models"
)
func fingerprintResolver(fp models.Fingerprints, type_ string) (*string, error) {
fingerprint := fp.For(type_)
if fingerprint != nil {
value := fingerprint.Value()
return &value, nil
func (r *galleryFileResolver) Fingerprint(ctx context.Context, obj *GalleryFile, type_ string) (*string, error) {
fp := obj.BaseFile.Fingerprints.For(type_)
if fp != nil {
v := fp.Value()
return &v, nil
}
return nil, nil
}
func (r *galleryFileResolver) Fingerprint(ctx context.Context, obj *GalleryFile, type_ string) (*string, error) {
return fingerprintResolver(obj.BaseFile.Fingerprints, type_)
}
func (r *imageFileResolver) Fingerprint(ctx context.Context, obj *ImageFile, type_ string) (*string, error) {
return fingerprintResolver(obj.ImageFile.Fingerprints, type_)
fp := obj.ImageFile.Fingerprints.For(type_)
if fp != nil {
v := fp.Value()
return &v, nil
}
return nil, nil
}
func (r *videoFileResolver) Fingerprint(ctx context.Context, obj *VideoFile, type_ string) (*string, error) {
return fingerprintResolver(obj.VideoFile.Fingerprints, type_)
}
func (r *basicFileResolver) Fingerprint(ctx context.Context, obj *BasicFile, type_ string) (*string, error) {
return fingerprintResolver(obj.BaseFile.Fingerprints, type_)
}
func (r *galleryFileResolver) ParentFolder(ctx context.Context, obj *GalleryFile) (*models.Folder, error) {
return loaders.From(ctx).FolderByID.Load(obj.ParentFolderID)
}
func (r *imageFileResolver) ParentFolder(ctx context.Context, obj *ImageFile) (*models.Folder, error) {
return loaders.From(ctx).FolderByID.Load(obj.ParentFolderID)
}
func (r *imageFileResolver) Images(ctx context.Context, obj *ImageFile) ([]*models.Image, error) {
imageIDs, err := loaders.From(ctx).ImageIDsByFileID.Load(obj.ID)
if err != nil {
return nil, err
fp := obj.VideoFile.Fingerprints.For(type_)
if fp != nil {
v := fp.Value()
return &v, nil
}
var errs []error
ret, errs := loaders.From(ctx).ImageByID.LoadAll(imageIDs)
return ret, firstError(errs)
}
func (r *videoFileResolver) ParentFolder(ctx context.Context, obj *VideoFile) (*models.Folder, error) {
return loaders.From(ctx).FolderByID.Load(obj.ParentFolderID)
}
func (r *videoFileResolver) Scenes(ctx context.Context, obj *VideoFile) ([]*models.Scene, error) {
sceneIDs, err := loaders.From(ctx).SceneIDsByFileID.Load(obj.ID)
if err != nil {
return nil, err
}
var errs []error
ret, errs := loaders.From(ctx).SceneByID.LoadAll(sceneIDs)
return ret, firstError(errs)
}
func (r *basicFileResolver) ParentFolder(ctx context.Context, obj *BasicFile) (*models.Folder, error) {
return loaders.From(ctx).FolderByID.Load(obj.ParentFolderID)
}
func zipFileResolver(ctx context.Context, zipFileID *models.FileID) (*BasicFile, error) {
if zipFileID == nil {
return nil, nil
}
f, err := loaders.From(ctx).FileByID.Load(*zipFileID)
if err != nil {
return nil, err
}
return &BasicFile{
BaseFile: f.Base(),
}, nil
}
func (r *galleryFileResolver) ZipFile(ctx context.Context, obj *GalleryFile) (*BasicFile, error) {
return zipFileResolver(ctx, obj.ZipFileID)
}
func (r *galleryFileResolver) Galleries(ctx context.Context, obj *GalleryFile) ([]*models.Gallery, error) {
galleryIDs, err := loaders.From(ctx).GalleryIDsByFileID.Load(obj.ID)
if err != nil {
return nil, err
}
var errs []error
ret, errs := loaders.From(ctx).GalleryByID.LoadAll(galleryIDs)
return ret, firstError(errs)
}
func (r *imageFileResolver) ZipFile(ctx context.Context, obj *ImageFile) (*BasicFile, error) {
return zipFileResolver(ctx, obj.ZipFileID)
}
func (r *videoFileResolver) ZipFile(ctx context.Context, obj *VideoFile) (*BasicFile, error) {
return zipFileResolver(ctx, obj.ZipFileID)
}
func (r *basicFileResolver) ZipFile(ctx context.Context, obj *BasicFile) (*BasicFile, error) {
return zipFileResolver(ctx, obj.ZipFileID)
return nil, nil
}

View File

@@ -1,78 +0,0 @@
package api
import (
"context"
"path/filepath"
"github.com/stashapp/stash/internal/api/loaders"
"github.com/stashapp/stash/pkg/models"
)
func (r *folderResolver) Basename(ctx context.Context, obj *models.Folder) (string, error) {
return filepath.Base(obj.Path), nil
}
func (r *folderResolver) ParentFolder(ctx context.Context, obj *models.Folder) (*models.Folder, error) {
if obj.ParentFolderID == nil {
return nil, nil
}
if r.idOnly(ctx) {
return &models.Folder{ID: *obj.ParentFolderID}, nil
}
return loaders.From(ctx).FolderByID.Load(*obj.ParentFolderID)
}
func foldersFromIDs(ids []models.FolderID) []*models.Folder {
ret := make([]*models.Folder, len(ids))
for i, id := range ids {
ret[i] = &models.Folder{ID: id}
}
return ret
}
func (r *folderResolver) ParentFolders(ctx context.Context, obj *models.Folder) ([]*models.Folder, error) {
ids, err := loaders.From(ctx).FolderParentFolderIDs.Load(obj.ID)
if err != nil {
return nil, err
}
if r.idOnly(ctx) {
return foldersFromIDs(ids), nil
}
var errs []error
ret, errs := loaders.From(ctx).FolderByID.LoadAll(ids)
return ret, firstError(errs)
}
func (r *folderResolver) SubFolders(ctx context.Context, obj *models.Folder) ([]*models.Folder, error) {
ids, err := loaders.From(ctx).FolderSubFolderIDs.Load(obj.ID)
if err != nil {
return nil, err
}
if r.idOnly(ctx) {
return foldersFromIDs(ids), nil
}
var errs []error
ret, errs := loaders.From(ctx).FolderByID.LoadAll(ids)
return ret, firstError(errs)
}
func (r *folderResolver) ZipFile(ctx context.Context, obj *models.Folder) (*BasicFile, error) {
// shortcut for id only queries
if r.idOnly(ctx) {
if obj.ZipFileID == nil {
return nil, nil
}
return &BasicFile{
BaseFile: &models.BaseFile{ID: *obj.ZipFileID},
}, nil
}
return zipFileResolver(ctx, obj.ZipFileID)
}

View File

@@ -2,10 +2,8 @@ package api
import (
"context"
"fmt"
"github.com/stashapp/stash/internal/api/loaders"
"github.com/stashapp/stash/internal/api/urlbuilders"
"github.com/stashapp/stash/internal/manager/config"
"github.com/stashapp/stash/pkg/image"
@@ -191,41 +189,3 @@ func (r *galleryResolver) Urls(ctx context.Context, obj *models.Gallery) ([]stri
return obj.URLs.List(), nil
}
func (r *galleryResolver) Paths(ctx context.Context, obj *models.Gallery) (*GalleryPathsType, error) {
baseURL, _ := ctx.Value(BaseURLCtxKey).(string)
builder := urlbuilders.NewGalleryURLBuilder(baseURL, obj)
return &GalleryPathsType{
Cover: builder.GetCoverURL(),
Preview: builder.GetPreviewURL(),
}, nil
}
func (r *galleryResolver) Image(ctx context.Context, obj *models.Gallery, index int) (ret *models.Image, err error) {
if index < 0 {
return nil, fmt.Errorf("index must >= 0")
}
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
ret, err = r.repository.Image.FindByGalleryIDIndex(ctx, obj.ID, uint(index))
return err
}); err != nil {
return nil, err
}
return
}
func (r *galleryResolver) CustomFields(ctx context.Context, obj *models.Gallery) (map[string]interface{}, error) {
m, err := loaders.From(ctx).GalleryCustomFields.Load(obj.ID)
if err != nil {
return nil, err
}
if m == nil {
return make(map[string]interface{}), nil
}
return m, nil
}

View File

@@ -18,6 +18,11 @@ func (r *imageResolver) getFiles(ctx context.Context, obj *models.Image) ([]mode
return files, firstError(errs)
}
func (r *imageResolver) Title(ctx context.Context, obj *models.Image) (*string, error) {
ret := obj.GetTitle()
return &ret, nil
}
func (r *imageResolver) VisualFiles(ctx context.Context, obj *models.Image) ([]VisualFile, error) {
files, err := r.getFiles(ctx, obj)
if err != nil {
@@ -161,12 +166,3 @@ func (r *imageResolver) Urls(ctx context.Context, obj *models.Image) ([]string,
return obj.URLs.List(), nil
}
func (r *imageResolver) CustomFields(ctx context.Context, obj *models.Image) (map[string]interface{}, error) {
customFields, err := loaders.From(ctx).ImageCustomFields.Load(obj.ID)
if err != nil {
return nil, err
}
return customFields, nil
}

View File

@@ -5,13 +5,10 @@ import (
"github.com/stashapp/stash/internal/api/loaders"
"github.com/stashapp/stash/internal/api/urlbuilders"
"github.com/stashapp/stash/pkg/group"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/performer"
"github.com/stashapp/stash/pkg/scene"
)
func (r *groupResolver) Date(ctx context.Context, obj *models.Group) (*string, error) {
func (r *movieResolver) Date(ctx context.Context, obj *models.Movie) (*string, error) {
if obj.Date != nil {
result := obj.Date.String()
return &result, nil
@@ -19,40 +16,11 @@ func (r *groupResolver) Date(ctx context.Context, obj *models.Group) (*string, e
return nil, nil
}
func (r *groupResolver) Rating100(ctx context.Context, obj *models.Group) (*int, error) {
func (r *movieResolver) Rating100(ctx context.Context, obj *models.Movie) (*int, error) {
return obj.Rating, nil
}
func (r *groupResolver) URL(ctx context.Context, obj *models.Group) (*string, error) {
if !obj.URLs.Loaded() {
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
return obj.LoadURLs(ctx, r.repository.Group)
}); err != nil {
return nil, err
}
}
urls := obj.URLs.List()
if len(urls) == 0 {
return nil, nil
}
return &urls[0], nil
}
func (r *groupResolver) Urls(ctx context.Context, obj *models.Group) ([]string, error) {
if !obj.URLs.Loaded() {
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
return obj.LoadURLs(ctx, r.repository.Group)
}); err != nil {
return nil, err
}
}
return obj.URLs.List(), nil
}
func (r *groupResolver) Studio(ctx context.Context, obj *models.Group) (ret *models.Studio, err error) {
func (r *movieResolver) Studio(ctx context.Context, obj *models.Movie) (ret *models.Studio, err error) {
if obj.StudioID == nil {
return nil, nil
}
@@ -60,102 +28,26 @@ func (r *groupResolver) Studio(ctx context.Context, obj *models.Group) (ret *mod
return loaders.From(ctx).StudioByID.Load(*obj.StudioID)
}
func (r groupResolver) Tags(ctx context.Context, obj *models.Group) (ret []*models.Tag, err error) {
if !obj.TagIDs.Loaded() {
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
return obj.LoadTagIDs(ctx, r.repository.Group)
}); err != nil {
return nil, err
}
}
var errs []error
ret, errs = loaders.From(ctx).TagByID.LoadAll(obj.TagIDs.List())
return ret, firstError(errs)
}
func (r groupResolver) relatedGroups(ctx context.Context, rgd models.RelatedGroupDescriptions) (ret []*GroupDescription, err error) {
// rgd must be loaded
gds := rgd.List()
ids := make([]int, len(gds))
for i, gd := range gds {
ids[i] = gd.GroupID
}
groups, errs := loaders.From(ctx).GroupByID.LoadAll(ids)
err = firstError(errs)
if err != nil {
return
}
ret = make([]*GroupDescription, len(groups))
for i, group := range groups {
ret[i] = &GroupDescription{Group: group}
d := gds[i].Description
if d != "" {
ret[i].Description = &d
}
}
return ret, firstError(errs)
}
func (r groupResolver) ContainingGroups(ctx context.Context, obj *models.Group) (ret []*GroupDescription, err error) {
if !obj.ContainingGroups.Loaded() {
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
return obj.LoadContainingGroupIDs(ctx, r.repository.Group)
}); err != nil {
return nil, err
}
}
return r.relatedGroups(ctx, obj.ContainingGroups)
}
func (r groupResolver) SubGroups(ctx context.Context, obj *models.Group) (ret []*GroupDescription, err error) {
if !obj.SubGroups.Loaded() {
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
return obj.LoadSubGroupIDs(ctx, r.repository.Group)
}); err != nil {
return nil, err
}
}
return r.relatedGroups(ctx, obj.SubGroups)
}
func (r *groupResolver) SubGroupCount(ctx context.Context, obj *models.Group, depth *int) (ret int, err error) {
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
ret, err = group.CountByContainingGroupID(ctx, r.repository.Group, obj.ID, depth)
return err
}); err != nil {
return 0, err
}
return ret, nil
}
func (r *groupResolver) FrontImagePath(ctx context.Context, obj *models.Group) (*string, error) {
func (r *movieResolver) FrontImagePath(ctx context.Context, obj *models.Movie) (*string, error) {
var hasImage bool
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
var err error
hasImage, err = r.repository.Group.HasFrontImage(ctx, obj.ID)
hasImage, err = r.repository.Movie.HasFrontImage(ctx, obj.ID)
return err
}); err != nil {
return nil, err
}
baseURL, _ := ctx.Value(BaseURLCtxKey).(string)
imagePath := urlbuilders.NewGroupURLBuilder(baseURL, obj).GetGroupFrontImageURL(hasImage)
imagePath := urlbuilders.NewMovieURLBuilder(baseURL, obj).GetMovieFrontImageURL(hasImage)
return &imagePath, nil
}
func (r *groupResolver) BackImagePath(ctx context.Context, obj *models.Group) (*string, error) {
func (r *movieResolver) BackImagePath(ctx context.Context, obj *models.Movie) (*string, error) {
var hasImage bool
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
var err error
hasImage, err = r.repository.Group.HasBackImage(ctx, obj.ID)
hasImage, err = r.repository.Movie.HasBackImage(ctx, obj.ID)
return err
}); err != nil {
return nil, err
@@ -167,13 +59,13 @@ func (r *groupResolver) BackImagePath(ctx context.Context, obj *models.Group) (*
}
baseURL, _ := ctx.Value(BaseURLCtxKey).(string)
imagePath := urlbuilders.NewGroupURLBuilder(baseURL, obj).GetGroupBackImageURL()
imagePath := urlbuilders.NewMovieURLBuilder(baseURL, obj).GetMovieBackImageURL()
return &imagePath, nil
}
func (r *groupResolver) SceneCount(ctx context.Context, obj *models.Group, depth *int) (ret int, err error) {
func (r *movieResolver) SceneCount(ctx context.Context, obj *models.Movie) (ret int, err error) {
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
ret, err = scene.CountByGroupID(ctx, r.repository.Scene, obj.ID, depth)
ret, err = r.repository.Scene.CountByMovieID(ctx, obj.ID)
return err
}); err != nil {
return 0, err
@@ -182,21 +74,10 @@ func (r *groupResolver) SceneCount(ctx context.Context, obj *models.Group, depth
return ret, nil
}
func (r *groupResolver) PerformerCount(ctx context.Context, obj *models.Group, depth *int) (ret int, err error) {
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
ret, err = performer.CountByGroupID(ctx, r.repository.Performer, obj.ID, depth)
return err
}); err != nil {
return 0, err
}
return ret, nil
}
func (r *groupResolver) Scenes(ctx context.Context, obj *models.Group) (ret []*models.Scene, err error) {
func (r *movieResolver) Scenes(ctx context.Context, obj *models.Movie) (ret []*models.Scene, err error) {
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
var err error
ret, err = r.repository.Scene.FindByGroupID(ctx, obj.ID)
ret, err = r.repository.Scene.FindByMovieID(ctx, obj.ID)
return err
}); err != nil {
return nil, err
@@ -204,27 +85,3 @@ func (r *groupResolver) Scenes(ctx context.Context, obj *models.Group) (ret []*m
return ret, nil
}
func (r *groupResolver) OCounter(ctx context.Context, obj *models.Group) (ret *int, err error) {
var count int
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
count, err = r.repository.Scene.OCountByGroupID(ctx, obj.ID)
return err
}); err != nil {
return nil, err
}
return &count, nil
}
func (r *groupResolver) CustomFields(ctx context.Context, obj *models.Group) (map[string]interface{}, error) {
m, err := loaders.From(ctx).GroupCustomFields.Load(obj.ID)
if err != nil {
return nil, err
}
if m == nil {
return make(map[string]interface{}), nil
}
return m, nil
}

View File

@@ -24,79 +24,6 @@ func (r *performerResolver) AliasList(ctx context.Context, obj *models.Performer
return obj.Aliases.List(), nil
}
func (r *performerResolver) URL(ctx context.Context, obj *models.Performer) (*string, error) {
if !obj.URLs.Loaded() {
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
return obj.LoadURLs(ctx, r.repository.Performer)
}); err != nil {
return nil, err
}
}
urls := obj.URLs.List()
if len(urls) == 0 {
return nil, nil
}
return &urls[0], nil
}
func (r *performerResolver) Twitter(ctx context.Context, obj *models.Performer) (*string, error) {
if !obj.URLs.Loaded() {
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
return obj.LoadURLs(ctx, r.repository.Performer)
}); err != nil {
return nil, err
}
}
urls := obj.URLs.List()
// find the first twitter url
for _, url := range urls {
if performer.IsTwitterURL(url) {
u := url
return &u, nil
}
}
return nil, nil
}
func (r *performerResolver) Instagram(ctx context.Context, obj *models.Performer) (*string, error) {
if !obj.URLs.Loaded() {
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
return obj.LoadURLs(ctx, r.repository.Performer)
}); err != nil {
return nil, err
}
}
urls := obj.URLs.List()
// find the first instagram url
for _, url := range urls {
if performer.IsInstagramURL(url) {
u := url
return &u, nil
}
}
return nil, nil
}
func (r *performerResolver) Urls(ctx context.Context, obj *models.Performer) ([]string, error) {
if !obj.URLs.Loaded() {
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
return obj.LoadURLs(ctx, r.repository.Performer)
}); err != nil {
return nil, err
}
}
return obj.URLs.List(), nil
}
func (r *performerResolver) Height(ctx context.Context, obj *models.Performer) (*string, error) {
if obj.Height != nil {
ret := strconv.Itoa(*obj.Height)
@@ -109,31 +36,6 @@ func (r *performerResolver) HeightCm(ctx context.Context, obj *models.Performer)
return obj.Height, nil
}
func (r *performerResolver) CareerStart(ctx context.Context, obj *models.Performer) (*string, error) {
if obj.CareerStart != nil {
ret := obj.CareerStart.String()
return &ret, nil
}
return nil, nil
}
func (r *performerResolver) CareerEnd(ctx context.Context, obj *models.Performer) (*string, error) {
if obj.CareerEnd != nil {
ret := obj.CareerEnd.String()
return &ret, nil
}
return nil, nil
}
func (r *performerResolver) CareerLength(ctx context.Context, obj *models.Performer) (*string, error) {
if obj.CareerStart == nil && obj.CareerEnd == nil {
return nil, nil
}
ret := models.FormatYearRange(obj.CareerStart, obj.CareerEnd)
return &ret, nil
}
func (r *performerResolver) Birthdate(ctx context.Context, obj *models.Performer) (*string, error) {
if obj.Birthdate != nil {
ret := obj.Birthdate.String()
@@ -204,9 +106,9 @@ func (r *performerResolver) GalleryCount(ctx context.Context, obj *models.Perfor
return ret, nil
}
func (r *performerResolver) GroupCount(ctx context.Context, obj *models.Performer) (ret int, err error) {
func (r *performerResolver) MovieCount(ctx context.Context, obj *models.Performer) (ret int, err error) {
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
ret, err = r.repository.Group.CountByPerformerID(ctx, obj.ID)
ret, err = r.repository.Movie.CountByPerformerID(ctx, obj.ID)
return err
}); err != nil {
return 0, err
@@ -215,11 +117,6 @@ func (r *performerResolver) GroupCount(ctx context.Context, obj *models.Performe
return ret, nil
}
// deprecated
func (r *performerResolver) MovieCount(ctx context.Context, obj *models.Performer) (ret int, err error) {
return r.GroupCount(ctx, obj)
}
func (r *performerResolver) PerformerCount(ctx context.Context, obj *models.Performer) (ret int, err error) {
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
ret, err = performer.CountByAppearsWith(ctx, r.repository.Performer, obj.ID)
@@ -282,9 +179,9 @@ func (r *performerResolver) DeathDate(ctx context.Context, obj *models.Performer
return nil, nil
}
func (r *performerResolver) Groups(ctx context.Context, obj *models.Performer) (ret []*models.Group, err error) {
func (r *performerResolver) Movies(ctx context.Context, obj *models.Performer) (ret []*models.Movie, err error) {
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
ret, err = r.repository.Group.FindByPerformerID(ctx, obj.ID)
ret, err = r.repository.Movie.FindByPerformerID(ctx, obj.ID)
return err
}); err != nil {
return nil, err
@@ -292,21 +189,3 @@ func (r *performerResolver) Groups(ctx context.Context, obj *models.Performer) (
return ret, nil
}
func (r *performerResolver) CustomFields(ctx context.Context, obj *models.Performer) (map[string]interface{}, error) {
m, err := loaders.From(ctx).PerformerCustomFields.Load(obj.ID)
if err != nil {
return nil, err
}
if m == nil {
return make(map[string]interface{}), nil
}
return m, nil
}
// deprecated
func (r *performerResolver) Movies(ctx context.Context, obj *models.Performer) (ret []*models.Group, err error) {
return r.Groups(ctx, obj)
}

View File

@@ -9,8 +9,6 @@ import (
"github.com/stashapp/stash/internal/api/urlbuilders"
"github.com/stashapp/stash/internal/manager"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/session"
"github.com/stashapp/stash/pkg/signedurl"
)
func convertVideoFile(f models.File) (*models.VideoFile, error) {
@@ -109,38 +107,15 @@ 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)
var streamPath string
var captionBasePath string
if config.HasCredentials() {
userID := session.GetCurrentUserID(ctx)
if userID == nil {
return nil, fmt.Errorf("user ID not found")
}
// Sign the stream prefix
streamURL := builder.GetStreamURL("")
streamURL.RawQuery = signedParams(config, *userID, signedurl.DerivePrefix(streamURL.Path)).Encode()
streamPath = streamURL.String()
// Sign the caption prefix
captionBase := builder.GetCaptionURL()
captionBasePath = captionBase + "?" + signedParams(config, *userID, builder.GetCaptionPath()).Encode()
} else {
apiKey := config.GetAPIKey()
streamURL := builder.GetStreamURL(apiKey)
streamPath = streamURL.String()
captionBasePath = builder.GetCaptionURL()
}
// Web-only formats: use unsigned URLs (rely on cookie authentication)
screenshotPath := builder.GetScreenshotURL()
previewPath := builder.GetStreamPreviewURL()
streamPath := builder.GetStreamURL(config.GetAPIKey()).String()
webpPath := builder.GetStreamPreviewImageURL()
objHash := obj.GetHash(config.GetVideoFileNamingAlgorithm())
vttPath := builder.GetSpriteVTTURL(objHash)
spritePath := builder.GetSpriteURL(objHash)
funscriptPath := builder.GetFunscriptURL(config.GetAPIKey()).String()
funscriptPath := builder.GetFunscriptURL()
captionBasePath := builder.GetCaptionURL()
interactiveHeatmap := builder.GetInteractiveHeatmapURL()
return &ScenePathsType{
@@ -209,20 +184,20 @@ func (r *sceneResolver) Studio(ctx context.Context, obj *models.Scene) (ret *mod
}
func (r *sceneResolver) Movies(ctx context.Context, obj *models.Scene) (ret []*SceneMovie, err error) {
if !obj.Groups.Loaded() {
if !obj.Movies.Loaded() {
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
qb := r.repository.Scene
return obj.LoadGroups(ctx, qb)
return obj.LoadMovies(ctx, qb)
}); err != nil {
return nil, err
}
}
loader := loaders.From(ctx).GroupByID
loader := loaders.From(ctx).MovieByID
for _, sm := range obj.Groups.List() {
movie, err := loader.Load(sm.GroupID)
for _, sm := range obj.Movies.List() {
movie, err := loader.Load(sm.MovieID)
if err != nil {
return nil, err
}
@@ -239,37 +214,6 @@ func (r *sceneResolver) Movies(ctx context.Context, obj *models.Scene) (ret []*S
return ret, nil
}
func (r *sceneResolver) Groups(ctx context.Context, obj *models.Scene) (ret []*SceneGroup, err error) {
if !obj.Groups.Loaded() {
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
qb := r.repository.Scene
return obj.LoadGroups(ctx, qb)
}); err != nil {
return nil, err
}
}
loader := loaders.From(ctx).GroupByID
for _, sm := range obj.Groups.List() {
group, err := loader.Load(sm.GroupID)
if err != nil {
return nil, err
}
sceneIdx := sm.SceneIndex
sceneGroup := &SceneGroup{
Group: group,
SceneIndex: sceneIdx,
}
ret = append(ret, sceneGroup)
}
return ret, nil
}
func (r *sceneResolver) Tags(ctx context.Context, obj *models.Scene) (ret []*models.Tag, err error) {
if !obj.TagIDs.Loaded() {
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
@@ -319,25 +263,9 @@ func (r *sceneResolver) SceneStreams(ctx context.Context, obj *models.Scene) ([]
baseURL, _ := ctx.Value(BaseURLCtxKey).(string)
builder := urlbuilders.NewSceneURLBuilder(baseURL, obj)
apiKey := config.GetAPIKey()
// Build the base stream URL with signing params or apikey
streamURL := builder.GetStreamURL("")
if config.HasCredentials() {
userID := session.GetCurrentUserID(ctx)
if userID == nil {
return nil, fmt.Errorf("user ID not found")
}
streamURL.RawQuery = signedParams(config, *userID, signedurl.DerivePrefix(streamURL.Path)).Encode()
} else {
apiKey := config.GetAPIKey()
if apiKey != "" {
v := streamURL.Query()
v.Set("apikey", apiKey)
streamURL.RawQuery = v.Encode()
}
}
return manager.GetSceneStreamPaths(obj, streamURL, config.GetMaxStreamingTranscodeSize())
return manager.GetSceneStreamPaths(obj, builder.GetStreamURL(apiKey), config.GetMaxStreamingTranscodeSize())
}
func (r *sceneResolver) Interactive(ctx context.Context, obj *models.Scene) (bool, error) {
@@ -451,16 +379,3 @@ func (r *sceneResolver) OHistory(ctx context.Context, obj *models.Scene) ([]*tim
return ptrRet, nil
}
func (r *sceneResolver) CustomFields(ctx context.Context, obj *models.Scene) (map[string]interface{}, error) {
m, err := loaders.From(ctx).SceneCustomFields.Load(obj.ID)
if err != nil {
return nil, err
}
if m == nil {
return make(map[string]interface{}), nil
}
return m, nil
}

View File

@@ -6,9 +6,9 @@ import (
"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/group"
"github.com/stashapp/stash/pkg/image"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/movie"
"github.com/stashapp/stash/pkg/performer"
"github.com/stashapp/stash/pkg/scene"
)
@@ -40,49 +40,6 @@ func (r *studioResolver) Aliases(ctx context.Context, obj *models.Studio) ([]str
return obj.Aliases.List(), nil
}
func (r *studioResolver) URL(ctx context.Context, obj *models.Studio) (*string, error) {
if !obj.URLs.Loaded() {
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
return obj.LoadURLs(ctx, r.repository.Studio)
}); err != nil {
return nil, err
}
}
urls := obj.URLs.List()
if len(urls) == 0 {
return nil, nil
}
return &urls[0], nil
}
func (r *studioResolver) Urls(ctx context.Context, obj *models.Studio) ([]string, error) {
if !obj.URLs.Loaded() {
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
return obj.LoadURLs(ctx, r.repository.Studio)
}); err != nil {
return nil, err
}
}
return obj.URLs.List(), nil
}
func (r *studioResolver) Tags(ctx context.Context, obj *models.Studio) (ret []*models.Tag, err error) {
if !obj.TagIDs.Loaded() {
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
return obj.LoadTagIDs(ctx, r.repository.Studio)
}); err != nil {
return nil, err
}
}
var errs []error
ret, errs = loaders.From(ctx).TagByID.LoadAll(obj.TagIDs.List())
return ret, firstError(errs)
}
func (r *studioResolver) SceneCount(ctx context.Context, obj *models.Studio, depth *int) (ret int, err error) {
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
ret, err = scene.CountByStudioID(ctx, r.repository.Scene, obj.ID, depth)
@@ -127,9 +84,9 @@ func (r *studioResolver) PerformerCount(ctx context.Context, obj *models.Studio,
return ret, nil
}
func (r *studioResolver) GroupCount(ctx context.Context, obj *models.Studio, depth *int) (ret int, err error) {
func (r *studioResolver) MovieCount(ctx context.Context, obj *models.Studio, depth *int) (ret int, err error) {
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
ret, err = group.CountByStudioID(ctx, r.repository.Group, obj.ID, depth)
ret, err = movie.CountByStudioID(ctx, r.repository.Movie, obj.ID, depth)
return err
}); err != nil {
return 0, err
@@ -138,29 +95,6 @@ func (r *studioResolver) GroupCount(ctx context.Context, obj *models.Studio, dep
return ret, nil
}
// deprecated
func (r *studioResolver) MovieCount(ctx context.Context, obj *models.Studio, depth *int) (ret int, err error) {
return r.GroupCount(ctx, obj, depth)
}
func (r *studioResolver) OCounter(ctx context.Context, obj *models.Studio) (ret *int, err error) {
var res_scene int
var res_image int
var res int
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
res_scene, err = r.repository.Scene.OCountByStudioID(ctx, obj.ID)
if err != nil {
return err
}
res_image, err = r.repository.Image.OCountByStudioID(ctx, obj.ID)
return err
}); err != nil {
return nil, err
}
res = res_scene + res_image
return &res, nil
}
func (r *studioResolver) ParentStudio(ctx context.Context, obj *models.Studio) (ret *models.Studio, err error) {
if obj.ParentID == nil {
return nil, nil
@@ -196,9 +130,9 @@ func (r *studioResolver) Rating100(ctx context.Context, obj *models.Studio) (*in
return obj.Rating, nil
}
func (r *studioResolver) Groups(ctx context.Context, obj *models.Studio) (ret []*models.Group, err error) {
func (r *studioResolver) Movies(ctx context.Context, obj *models.Studio) (ret []*models.Movie, err error) {
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
ret, err = r.repository.Group.FindByStudioID(ctx, obj.ID)
ret, err = r.repository.Movie.FindByStudioID(ctx, obj.ID)
return err
}); err != nil {
return nil, err
@@ -206,21 +140,3 @@ func (r *studioResolver) Groups(ctx context.Context, obj *models.Studio) (ret []
return ret, nil
}
func (r *studioResolver) CustomFields(ctx context.Context, obj *models.Studio) (map[string]interface{}, error) {
m, err := loaders.From(ctx).StudioCustomFields.Load(obj.ID)
if err != nil {
return nil, err
}
if m == nil {
return make(map[string]interface{}), nil
}
return m, nil
}
// deprecated
func (r *studioResolver) Movies(ctx context.Context, obj *models.Studio) (ret []*models.Group, err error) {
return r.Groups(ctx, obj)
}

View File

@@ -3,65 +3,45 @@ package api
import (
"context"
"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/group"
"github.com/stashapp/stash/pkg/image"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/performer"
"github.com/stashapp/stash/pkg/scene"
"github.com/stashapp/stash/pkg/studio"
)
func (r *tagResolver) Parents(ctx context.Context, obj *models.Tag) (ret []*models.Tag, err error) {
if !obj.ParentIDs.Loaded() {
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
return obj.LoadParentIDs(ctx, r.repository.Tag)
}); err != nil {
return nil, err
}
}
var errs []error
ret, errs = loaders.From(ctx).TagByID.LoadAll(obj.ParentIDs.List())
return ret, firstError(errs)
}
func (r *tagResolver) Children(ctx context.Context, obj *models.Tag) (ret []*models.Tag, err error) {
if !obj.ChildIDs.Loaded() {
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
return obj.LoadChildIDs(ctx, r.repository.Tag)
}); err != nil {
return nil, err
}
}
var errs []error
ret, errs = loaders.From(ctx).TagByID.LoadAll(obj.ChildIDs.List())
return ret, firstError(errs)
}
func (r *tagResolver) Aliases(ctx context.Context, obj *models.Tag) (ret []string, err error) {
if !obj.Aliases.Loaded() {
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
return obj.LoadAliases(ctx, r.repository.Tag)
}); err != nil {
return nil, err
}
}
return obj.Aliases.List(), nil
}
func (r *tagResolver) StashIds(ctx context.Context, obj *models.Tag) ([]*models.StashID, error) {
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
return obj.LoadStashIDs(ctx, r.repository.Tag)
ret, err = r.repository.Tag.FindByChildTagID(ctx, obj.ID)
return err
}); err != nil {
return nil, err
}
return stashIDsSliceToPtrSlice(obj.StashIDs.List()), nil
return ret, nil
}
func (r *tagResolver) Children(ctx context.Context, obj *models.Tag) (ret []*models.Tag, err error) {
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
ret, err = r.repository.Tag.FindByParentTagID(ctx, obj.ID)
return err
}); err != nil {
return nil, err
}
return ret, nil
}
func (r *tagResolver) Aliases(ctx context.Context, obj *models.Tag) (ret []string, err error) {
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
ret, err = r.repository.Tag.GetAliases(ctx, obj.ID)
return err
}); err != nil {
return nil, err
}
return ret, err
}
func (r *tagResolver) SceneCount(ctx context.Context, obj *models.Tag, depth *int) (ret int, err error) {
@@ -119,32 +99,6 @@ func (r *tagResolver) PerformerCount(ctx context.Context, obj *models.Tag, depth
return ret, nil
}
func (r *tagResolver) StudioCount(ctx context.Context, obj *models.Tag, depth *int) (ret int, err error) {
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
ret, err = studio.CountByTagID(ctx, r.repository.Studio, obj.ID, depth)
return err
}); err != nil {
return 0, err
}
return ret, nil
}
func (r *tagResolver) GroupCount(ctx context.Context, obj *models.Tag, depth *int) (ret int, err error) {
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
ret, err = group.CountByTagID(ctx, r.repository.Group, obj.ID, depth)
return err
}); err != nil {
return 0, err
}
return ret, nil
}
func (r *tagResolver) MovieCount(ctx context.Context, obj *models.Tag, depth *int) (ret int, err error) {
return r.GroupCount(ctx, obj, depth)
}
func (r *tagResolver) ImagePath(ctx context.Context, obj *models.Tag) (*string, error) {
var hasImage bool
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
@@ -181,16 +135,3 @@ func (r *tagResolver) ChildCount(ctx context.Context, obj *models.Tag) (ret int,
return ret, nil
}
func (r *tagResolver) CustomFields(ctx context.Context, obj *models.Tag) (map[string]interface{}, error) {
m, err := loaders.From(ctx).TagCustomFields.Load(obj.ID)
if err != nil {
return nil, err
}
if m == nil {
return make(map[string]interface{}), nil
}
return m, nil
}

View File

@@ -2,22 +2,16 @@ package api
import (
"context"
"encoding/json"
"errors"
"fmt"
"io/fs"
"path/filepath"
"regexp"
"strconv"
"github.com/stashapp/stash/internal/manager"
"github.com/stashapp/stash/internal/manager/config"
"github.com/stashapp/stash/internal/manager/task"
"github.com/stashapp/stash/pkg/ffmpeg"
"github.com/stashapp/stash/pkg/fsutil"
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/utils"
)
var ErrOverriddenConfig = errors.New("cannot set overridden value")
@@ -27,67 +21,14 @@ func (r *mutationResolver) Setup(ctx context.Context, input manager.SetupInput)
return err == nil, err
}
func (r *mutationResolver) DownloadFFMpeg(ctx context.Context) (string, error) {
mgr := manager.GetInstance()
configDir := mgr.Config.GetConfigPathAbs()
// don't run if ffmpeg is already installed
ffmpegPath := ffmpeg.FindFFMpeg(configDir)
ffprobePath := ffmpeg.FindFFProbe(configDir)
if ffmpegPath != "" && ffprobePath != "" {
return "", fmt.Errorf("ffmpeg and ffprobe already installed at %s and %s", ffmpegPath, ffprobePath)
}
t := &task.DownloadFFmpegJob{
ConfigDirectory: configDir,
OnComplete: func(ctx context.Context) {
// clear the ffmpeg and ffprobe paths
logger.Infof("Clearing ffmpeg and ffprobe config paths so they are resolved from the config directory")
mgr.Config.SetString(config.FFMpegPath, "")
mgr.Config.SetString(config.FFProbePath, "")
mgr.RefreshFFMpeg(ctx)
mgr.RefreshStreamManager()
},
}
jobID := mgr.JobManager.Add(ctx, "Downloading ffmpeg...", t)
return strconv.Itoa(jobID), nil
}
func (r *mutationResolver) setConfigString(key string, value *string) {
c := config.GetInstance()
if value != nil {
c.SetString(key, *value)
}
}
func (r *mutationResolver) setConfigBool(key string, value *bool) {
c := config.GetInstance()
if value != nil {
c.SetBool(key, *value)
}
}
func (r *mutationResolver) setConfigInt(key string, value *int) {
c := config.GetInstance()
if value != nil {
c.SetInt(key, *value)
}
}
func (r *mutationResolver) setConfigFloat(key string, value *float64) {
c := config.GetInstance()
if value != nil {
c.SetFloat(key, *value)
}
func (r *mutationResolver) Migrate(ctx context.Context, input manager.MigrateInput) (bool, error) {
err := manager.GetInstance().Migrate(ctx, input)
return err == nil, err
}
func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input ConfigGeneralInput) (*ConfigGeneralResult, error) {
c := config.GetInstance()
// #4709 - allow stash paths even if they do not exist, so that users may configure stash
// for disconnected drives or network storage.
existingPaths := c.GetStashPaths()
if input.Stashes != nil {
for _, s := range input.Stashes {
@@ -100,17 +41,13 @@ func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input ConfigGen
}
}
if isNew {
s.Path = filepath.Clean(s.Path)
// if it exists, it must be directory
exists, err := fsutil.DirExists(s.Path)
// allow it to not exist but if it does exist it must be a directory
if !exists && !errors.Is(err, fs.ErrNotExist) {
if !exists {
return makeConfigGeneralResult(), err
}
}
}
c.SetInterface(config.Stash, input.Stashes)
c.Set(config.Stash, input.Stashes)
}
checkConfigOverride := func(key string) error {
@@ -145,7 +82,7 @@ func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input ConfigGen
if ext != ".db" && ext != ".sqlite" && ext != ".sqlite3" {
return makeConfigGeneralResult(), fmt.Errorf("invalid database path, use extension db, sqlite, or sqlite3")
}
c.SetString(config.Database, *input.DatabasePath)
c.Set(config.Database, input.DatabasePath)
}
existingBackupDirectoryPath := c.GetBackupDirectoryPath()
@@ -154,16 +91,7 @@ func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input ConfigGen
return makeConfigGeneralResult(), err
}
c.SetString(config.BackupDirectoryPath, *input.BackupDirectoryPath)
}
existingDeleteTrashPath := c.GetDeleteTrashPath()
if input.DeleteTrashPath != nil && existingDeleteTrashPath != *input.DeleteTrashPath {
if err := validateDir(config.DeleteTrashPath, *input.DeleteTrashPath, true); err != nil {
return makeConfigGeneralResult(), err
}
c.SetString(config.DeleteTrashPath, *input.DeleteTrashPath)
c.Set(config.BackupDirectoryPath, input.BackupDirectoryPath)
}
existingGeneratedPath := c.GetGeneratedPath()
@@ -172,7 +100,7 @@ func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input ConfigGen
return makeConfigGeneralResult(), err
}
c.SetString(config.Generated, *input.GeneratedPath)
c.Set(config.Generated, input.GeneratedPath)
}
refreshScraperCache := false
@@ -185,7 +113,7 @@ func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input ConfigGen
refreshScraperCache = true
refreshScraperSource = true
c.SetString(config.ScrapersPath, *input.ScrapersPath)
c.Set(config.ScrapersPath, input.ScrapersPath)
}
refreshPluginCache := false
@@ -198,7 +126,7 @@ func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input ConfigGen
refreshPluginCache = true
refreshPluginSource = true
c.SetString(config.PluginsPath, *input.PluginsPath)
c.Set(config.PluginsPath, input.PluginsPath)
}
existingMetadataPath := c.GetMetadataPath()
@@ -207,7 +135,7 @@ func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input ConfigGen
return makeConfigGeneralResult(), err
}
c.SetString(config.Metadata, *input.MetadataPath)
c.Set(config.Metadata, input.MetadataPath)
}
refreshStreamManager := false
@@ -217,7 +145,7 @@ func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input ConfigGen
return makeConfigGeneralResult(), err
}
c.SetString(config.Cache, *input.CachePath)
c.Set(config.Cache, input.CachePath)
refreshStreamManager = true
}
@@ -228,7 +156,7 @@ func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input ConfigGen
return makeConfigGeneralResult(), err
}
c.SetString(config.BlobsPath, *input.BlobsPath)
c.Set(config.BlobsPath, input.BlobsPath)
refreshBlobStorage = true
}
@@ -237,34 +165,12 @@ func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input ConfigGen
return makeConfigGeneralResult(), fmt.Errorf("blobs path must be set when using filesystem storage")
}
c.SetInterface(config.BlobsStorage, *input.BlobsStorage)
// TODO - migrate between systems
c.Set(config.BlobsStorage, input.BlobsStorage)
refreshBlobStorage = true
}
refreshFfmpeg := false
if input.FfmpegPath != nil && *input.FfmpegPath != c.GetFFMpegPath() {
if *input.FfmpegPath != "" {
if err := ffmpeg.ValidateFFMpeg(*input.FfmpegPath); err != nil {
return makeConfigGeneralResult(), fmt.Errorf("invalid ffmpeg path: %w", err)
}
}
c.SetString(config.FFMpegPath, *input.FfmpegPath)
refreshFfmpeg = true
}
if input.FfprobePath != nil && *input.FfprobePath != c.GetFFProbePath() {
if *input.FfprobePath != "" {
if err := ffmpeg.ValidateFFProbe(*input.FfprobePath); err != nil {
return makeConfigGeneralResult(), fmt.Errorf("invalid ffprobe path: %w", err)
}
}
c.SetString(config.FFProbePath, *input.FfprobePath)
refreshFfmpeg = true
}
if input.VideoFileNamingAlgorithm != nil && *input.VideoFileNamingAlgorithm != c.GetVideoFileNamingAlgorithm() {
calculateMD5 := c.IsCalculateMD5()
if input.CalculateMd5 != nil {
@@ -281,47 +187,68 @@ func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input ConfigGen
return makeConfigGeneralResult(), err
}
c.SetInterface(config.VideoFileNamingAlgorithm, *input.VideoFileNamingAlgorithm)
c.Set(config.VideoFileNamingAlgorithm, *input.VideoFileNamingAlgorithm)
}
r.setConfigBool(config.CalculateMD5, input.CalculateMd5)
r.setConfigInt(config.ParallelTasks, input.ParallelTasks)
r.setConfigBool(config.PreviewAudio, input.PreviewAudio)
r.setConfigInt(config.PreviewSegments, input.PreviewSegments)
r.setConfigFloat(config.PreviewSegmentDuration, input.PreviewSegmentDuration)
r.setConfigString(config.PreviewExcludeStart, input.PreviewExcludeStart)
r.setConfigString(config.PreviewExcludeEnd, input.PreviewExcludeEnd)
if input.CalculateMd5 != nil {
c.Set(config.CalculateMD5, *input.CalculateMd5)
}
if input.ParallelTasks != nil {
c.Set(config.ParallelTasks, *input.ParallelTasks)
}
if input.PreviewAudio != nil {
c.Set(config.PreviewAudio, *input.PreviewAudio)
}
if input.PreviewSegments != nil {
c.Set(config.PreviewSegments, *input.PreviewSegments)
}
if input.PreviewSegmentDuration != nil {
c.Set(config.PreviewSegmentDuration, *input.PreviewSegmentDuration)
}
if input.PreviewExcludeStart != nil {
c.Set(config.PreviewExcludeStart, *input.PreviewExcludeStart)
}
if input.PreviewExcludeEnd != nil {
c.Set(config.PreviewExcludeEnd, *input.PreviewExcludeEnd)
}
if input.PreviewPreset != nil {
c.SetString(config.PreviewPreset, input.PreviewPreset.String())
c.Set(config.PreviewPreset, input.PreviewPreset.String())
}
r.setConfigBool(config.UseCustomSpriteInterval, input.UseCustomSpriteInterval)
r.setConfigFloat(config.SpriteInterval, input.SpriteInterval)
r.setConfigInt(config.MinimumSprites, input.MinimumSprites)
r.setConfigInt(config.MaximumSprites, input.MaximumSprites)
r.setConfigInt(config.SpriteScreenshotSize, input.SpriteScreenshotSize)
r.setConfigBool(config.TranscodeHardwareAcceleration, input.TranscodeHardwareAcceleration)
if input.TranscodeHardwareAcceleration != nil {
c.Set(config.TranscodeHardwareAcceleration, *input.TranscodeHardwareAcceleration)
}
if input.MaxTranscodeSize != nil {
c.SetString(config.MaxTranscodeSize, input.MaxTranscodeSize.String())
c.Set(config.MaxTranscodeSize, input.MaxTranscodeSize.String())
}
if input.MaxStreamingTranscodeSize != nil {
c.SetString(config.MaxStreamingTranscodeSize, input.MaxStreamingTranscodeSize.String())
c.Set(config.MaxStreamingTranscodeSize, input.MaxStreamingTranscodeSize.String())
}
if input.WriteImageThumbnails != nil {
c.Set(config.WriteImageThumbnails, *input.WriteImageThumbnails)
}
if input.CreateImageClipsFromVideos != nil {
c.Set(config.CreateImageClipsFromVideos, *input.CreateImageClipsFromVideos)
}
r.setConfigBool(config.WriteImageThumbnails, input.WriteImageThumbnails)
r.setConfigBool(config.CreateImageClipsFromVideos, input.CreateImageClipsFromVideos)
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.SetString(config.GalleryCoverRegex, *input.GalleryCoverRegex)
c.Set(config.GalleryCoverRegex, *input.GalleryCoverRegex)
}
if input.Username != nil && *input.Username != c.GetUsername() {
c.SetString(config.Username, *input.Username)
c.Set(config.Username, input.Username)
if *input.Password == "" {
logger.Info("Username cleared")
} else {
@@ -344,19 +271,26 @@ func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input ConfigGen
}
}
r.setConfigInt(config.MaxSessionAge, input.MaxSessionAge)
r.setConfigString(config.LogFile, input.LogFile)
r.setConfigBool(config.LogOut, input.LogOut)
r.setConfigBool(config.LogAccess, input.LogAccess)
if input.LogLevel != nil && *input.LogLevel != c.GetLogLevel() {
c.SetString(config.LogLevel, *input.LogLevel)
logger := manager.GetInstance().Logger
logger.SetLogLevel(*input.LogLevel)
if input.MaxSessionAge != nil {
c.Set(config.MaxSessionAge, *input.MaxSessionAge)
}
if input.LogFileMaxSize != nil && *input.LogFileMaxSize != c.GetLogFileMaxSize() {
c.SetInt(config.LogFileMaxSize, *input.LogFileMaxSize)
if input.LogFile != nil {
c.Set(config.LogFile, input.LogFile)
}
if input.LogOut != nil {
c.Set(config.LogOut, *input.LogOut)
}
if input.LogAccess != nil {
c.Set(config.LogAccess, *input.LogAccess)
}
if input.LogLevel != nil && *input.LogLevel != c.GetLogLevel() {
c.Set(config.LogLevel, input.LogLevel)
logger := manager.GetInstance().Logger
logger.SetLogLevel(*input.LogLevel)
}
if input.Excludes != nil {
@@ -366,7 +300,7 @@ func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input ConfigGen
return makeConfigGeneralResult(), fmt.Errorf("video exclusion pattern '%v' invalid: %w", r, err)
}
}
c.SetInterface(config.Exclude, input.Excludes)
c.Set(config.Exclude, input.Excludes)
}
if input.ImageExcludes != nil {
@@ -376,25 +310,27 @@ func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input ConfigGen
return makeConfigGeneralResult(), fmt.Errorf("image/gallery exclusion pattern '%v' invalid: %w", r, err)
}
}
c.SetInterface(config.ImageExclude, input.ImageExcludes)
c.Set(config.ImageExclude, input.ImageExcludes)
}
if input.VideoExtensions != nil {
c.SetInterface(config.VideoExtensions, input.VideoExtensions)
c.Set(config.VideoExtensions, input.VideoExtensions)
}
if input.ImageExtensions != nil {
c.SetInterface(config.ImageExtensions, input.ImageExtensions)
c.Set(config.ImageExtensions, input.ImageExtensions)
}
if input.GalleryExtensions != nil {
c.SetInterface(config.GalleryExtensions, input.GalleryExtensions)
c.Set(config.GalleryExtensions, input.GalleryExtensions)
}
r.setConfigBool(config.CreateGalleriesFromFolders, input.CreateGalleriesFromFolders)
if input.CreateGalleriesFromFolders != nil {
c.Set(config.CreateGalleriesFromFolders, input.CreateGalleriesFromFolders)
}
if input.CustomPerformerImageLocation != nil {
c.SetString(config.CustomPerformerImageLocation, *input.CustomPerformerImageLocation)
c.Set(config.CustomPerformerImageLocation, *input.CustomPerformerImageLocation)
initCustomPerformerImages(*input.CustomPerformerImageLocation)
}
@@ -402,35 +338,37 @@ func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input ConfigGen
if err := c.ValidateStashBoxes(input.StashBoxes); err != nil {
return nil, err
}
c.SetInterface(config.StashBoxes, input.StashBoxes)
c.Set(config.StashBoxes, input.StashBoxes)
}
if input.PythonPath != nil {
r.setConfigString(config.PythonPath, input.PythonPath)
c.Set(config.PythonPath, input.PythonPath)
}
if input.TranscodeInputArgs != nil {
c.SetInterface(config.TranscodeInputArgs, input.TranscodeInputArgs)
c.Set(config.TranscodeInputArgs, input.TranscodeInputArgs)
}
if input.TranscodeOutputArgs != nil {
c.SetInterface(config.TranscodeOutputArgs, input.TranscodeOutputArgs)
c.Set(config.TranscodeOutputArgs, input.TranscodeOutputArgs)
}
if input.LiveTranscodeInputArgs != nil {
c.SetInterface(config.LiveTranscodeInputArgs, input.LiveTranscodeInputArgs)
c.Set(config.LiveTranscodeInputArgs, input.LiveTranscodeInputArgs)
}
if input.LiveTranscodeOutputArgs != nil {
c.SetInterface(config.LiveTranscodeOutputArgs, input.LiveTranscodeOutputArgs)
c.Set(config.LiveTranscodeOutputArgs, input.LiveTranscodeOutputArgs)
}
r.setConfigBool(config.DrawFunscriptHeatmapRange, input.DrawFunscriptHeatmapRange)
if input.DrawFunscriptHeatmapRange != nil {
c.Set(config.DrawFunscriptHeatmapRange, input.DrawFunscriptHeatmapRange)
}
if input.ScraperPackageSources != nil {
c.SetInterface(config.ScraperPackageSources, input.ScraperPackageSources)
c.Set(config.ScraperPackageSources, input.ScraperPackageSources)
refreshScraperSource = true
}
if input.PluginPackageSources != nil {
c.SetInterface(config.PluginPackageSources, input.PluginPackageSources)
c.Set(config.PluginPackageSources, input.PluginPackageSources)
refreshPluginSource = true
}
@@ -445,12 +383,6 @@ func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input ConfigGen
if refreshPluginCache {
manager.GetInstance().RefreshPluginCache()
}
if refreshFfmpeg {
manager.GetInstance().RefreshFFMpeg(ctx)
// refresh stream manager is required since ffmpeg changed
refreshStreamManager = true
}
if refreshStreamManager {
manager.GetInstance().RefreshStreamManager()
}
@@ -470,77 +402,102 @@ func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input ConfigGen
func (r *mutationResolver) ConfigureInterface(ctx context.Context, input ConfigInterfaceInput) (*ConfigInterfaceResult, error) {
c := config.GetInstance()
r.setConfigBool(config.SFWContentMode, input.SfwContentMode)
if input.MenuItems != nil {
c.SetInterface(config.MenuItems, input.MenuItems)
setBool := func(key string, v *bool) {
if v != nil {
c.Set(key, *v)
}
}
r.setConfigBool(config.SoundOnPreview, input.SoundOnPreview)
r.setConfigBool(config.WallShowTitle, input.WallShowTitle)
setString := func(key string, v *string) {
if v != nil {
c.Set(key, *v)
}
}
r.setConfigBool(config.NoBrowser, input.NoBrowser)
if input.MenuItems != nil {
c.Set(config.MenuItems, input.MenuItems)
}
r.setConfigBool(config.NotificationsEnabled, input.NotificationsEnabled)
setBool(config.SoundOnPreview, input.SoundOnPreview)
setBool(config.WallShowTitle, input.WallShowTitle)
r.setConfigBool(config.ShowScrubber, input.ShowScrubber)
setBool(config.NoBrowser, input.NoBrowser)
r.setConfigString(config.WallPlayback, input.WallPlayback)
r.setConfigInt(config.MaximumLoopDuration, input.MaximumLoopDuration)
r.setConfigBool(config.AutostartVideo, input.AutostartVideo)
r.setConfigBool(config.ShowStudioAsText, input.ShowStudioAsText)
r.setConfigBool(config.AutostartVideoOnPlaySelected, input.AutostartVideoOnPlaySelected)
r.setConfigBool(config.ContinuePlaylistDefault, input.ContinuePlaylistDefault)
setBool(config.NotificationsEnabled, input.NotificationsEnabled)
r.setConfigString(config.Language, input.Language)
setBool(config.ShowScrubber, input.ShowScrubber)
if input.WallPlayback != nil {
c.Set(config.WallPlayback, *input.WallPlayback)
}
if input.MaximumLoopDuration != nil {
c.Set(config.MaximumLoopDuration, *input.MaximumLoopDuration)
}
setBool(config.AutostartVideo, input.AutostartVideo)
setBool(config.ShowStudioAsText, input.ShowStudioAsText)
setBool(config.AutostartVideoOnPlaySelected, input.AutostartVideoOnPlaySelected)
setBool(config.ContinuePlaylistDefault, input.ContinuePlaylistDefault)
if input.Language != nil {
c.Set(config.Language, *input.Language)
}
if input.ImageLightbox != nil {
options := input.ImageLightbox
r.setConfigInt(config.ImageLightboxSlideshowDelay, options.SlideshowDelay)
if options.SlideshowDelay != nil {
c.Set(config.ImageLightboxSlideshowDelay, *options.SlideshowDelay)
}
r.setConfigString(config.ImageLightboxDisplayModeKey, (*string)(options.DisplayMode))
r.setConfigBool(config.ImageLightboxScaleUp, options.ScaleUp)
r.setConfigBool(config.ImageLightboxResetZoomOnNav, options.ResetZoomOnNav)
r.setConfigString(config.ImageLightboxScrollModeKey, (*string)(options.ScrollMode))
setString(config.ImageLightboxDisplayModeKey, (*string)(options.DisplayMode))
setBool(config.ImageLightboxScaleUp, options.ScaleUp)
setBool(config.ImageLightboxResetZoomOnNav, options.ResetZoomOnNav)
setString(config.ImageLightboxScrollModeKey, (*string)(options.ScrollMode))
r.setConfigInt(config.ImageLightboxScrollAttemptsBeforeChange, options.ScrollAttemptsBeforeChange)
r.setConfigBool(config.ImageLightboxDisableAnimation, options.DisableAnimation)
if options.ScrollAttemptsBeforeChange != nil {
c.Set(config.ImageLightboxScrollAttemptsBeforeChange, *options.ScrollAttemptsBeforeChange)
}
}
if input.CSS != nil {
c.SetCSS(*input.CSS)
}
r.setConfigBool(config.CSSEnabled, input.CSSEnabled)
setBool(config.CSSEnabled, input.CSSEnabled)
if input.Javascript != nil {
c.SetJavascript(*input.Javascript)
}
r.setConfigBool(config.JavascriptEnabled, input.JavascriptEnabled)
setBool(config.JavascriptEnabled, input.JavascriptEnabled)
if input.CustomLocales != nil {
c.SetCustomLocales(*input.CustomLocales)
}
r.setConfigBool(config.CustomLocalesEnabled, input.CustomLocalesEnabled)
r.setConfigBool(config.DisableCustomizations, input.DisableCustomizations)
setBool(config.CustomLocalesEnabled, input.CustomLocalesEnabled)
if input.DisableDropdownCreate != nil {
ddc := input.DisableDropdownCreate
r.setConfigBool(config.DisableDropdownCreatePerformer, ddc.Performer)
r.setConfigBool(config.DisableDropdownCreateStudio, ddc.Studio)
r.setConfigBool(config.DisableDropdownCreateTag, ddc.Tag)
r.setConfigBool(config.DisableDropdownCreateMovie, ddc.Movie)
r.setConfigBool(config.DisableDropdownCreateGallery, ddc.Gallery)
setBool(config.DisableDropdownCreatePerformer, ddc.Performer)
setBool(config.DisableDropdownCreateStudio, ddc.Studio)
setBool(config.DisableDropdownCreateTag, ddc.Tag)
setBool(config.DisableDropdownCreateMovie, ddc.Movie)
}
r.setConfigString(config.HandyKey, input.HandyKey)
r.setConfigInt(config.FunscriptOffset, input.FunscriptOffset)
r.setConfigBool(config.UseStashHostedFunscript, input.UseStashHostedFunscript)
if input.HandyKey != nil {
c.Set(config.HandyKey, *input.HandyKey)
}
if input.FunscriptOffset != nil {
c.Set(config.FunscriptOffset, *input.FunscriptOffset)
}
if input.UseStashHostedFunscript != nil {
c.Set(config.UseStashHostedFunscript, *input.UseStashHostedFunscript)
}
if err := c.Write(); err != nil {
return makeConfigInterfaceResult(), err
@@ -552,23 +509,26 @@ func (r *mutationResolver) ConfigureInterface(ctx context.Context, input ConfigI
func (r *mutationResolver) ConfigureDlna(ctx context.Context, input ConfigDLNAInput) (*ConfigDLNAResult, error) {
c := config.GetInstance()
r.setConfigString(config.DLNAServerName, input.ServerName)
if input.WhitelistedIPs != nil {
c.SetInterface(config.DLNADefaultIPWhitelist, input.WhitelistedIPs)
if input.ServerName != nil {
c.Set(config.DLNAServerName, *input.ServerName)
}
r.setConfigString(config.DLNAVideoSortOrder, input.VideoSortOrder)
r.setConfigInt(config.DLNAPort, input.Port)
if input.WhitelistedIPs != nil {
c.Set(config.DLNADefaultIPWhitelist, input.WhitelistedIPs)
}
if input.VideoSortOrder != nil {
c.Set(config.DLNAVideoSortOrder, input.VideoSortOrder)
}
refresh := false
if input.Enabled != nil {
c.SetBool(config.DLNADefaultEnabled, *input.Enabled)
c.Set(config.DLNADefaultEnabled, *input.Enabled)
refresh = true
}
if input.Interfaces != nil {
c.SetInterface(config.DLNAInterfaces, input.Interfaces)
c.Set(config.DLNAInterfaces, input.Interfaces)
}
if err := c.Write(); err != nil {
@@ -587,12 +547,12 @@ func (r *mutationResolver) ConfigureScraping(ctx context.Context, input ConfigSc
refreshScraperCache := false
if input.ScraperUserAgent != nil {
c.SetString(config.ScraperUserAgent, *input.ScraperUserAgent)
c.Set(config.ScraperUserAgent, input.ScraperUserAgent)
refreshScraperCache = true
}
if input.ScraperCDPPath != nil {
c.SetString(config.ScraperCDPPath, *input.ScraperCDPPath)
c.Set(config.ScraperCDPPath, input.ScraperCDPPath)
refreshScraperCache = true
}
@@ -603,10 +563,12 @@ func (r *mutationResolver) ConfigureScraping(ctx context.Context, input ConfigSc
return makeConfigScrapingResult(), fmt.Errorf("tag exclusion pattern '%v' invalid: %w", r, err)
}
}
c.SetInterface(config.ScraperExcludeTagPatterns, input.ExcludeTagPatterns)
c.Set(config.ScraperExcludeTagPatterns, input.ExcludeTagPatterns)
}
r.setConfigBool(config.ScraperCertCheck, input.ScraperCertCheck)
if input.ScraperCertCheck != nil {
c.Set(config.ScraperCertCheck, input.ScraperCertCheck)
}
if refreshScraperCache {
manager.GetInstance().RefreshScraperCache()
@@ -622,25 +584,30 @@ func (r *mutationResolver) ConfigureDefaults(ctx context.Context, input ConfigDe
c := config.GetInstance()
if input.Identify != nil {
c.SetInterface(config.DefaultIdentifySettings, input.Identify)
c.Set(config.DefaultIdentifySettings, input.Identify)
}
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.SetInterface(config.DefaultScanSettings, input.Scan.ScanMetadataOptions)
c.Set(config.DefaultScanSettings, input.Scan.ScanMetadataOptions)
}
if input.AutoTag != nil {
c.SetInterface(config.DefaultAutoTagSettings, input.AutoTag)
c.Set(config.DefaultAutoTagSettings, input.AutoTag)
}
if input.Generate != nil {
c.SetInterface(config.DefaultGenerateSettings, input.Generate)
c.Set(config.DefaultGenerateSettings, input.Generate)
}
r.setConfigBool(config.DeleteFileDefault, input.DeleteFile)
r.setConfigBool(config.DeleteGeneratedDefault, input.DeleteGenerated)
if input.DeleteFile != nil {
c.Set(config.DeleteFileDefault, *input.DeleteFile)
}
if input.DeleteGenerated != nil {
c.Set(config.DeleteGeneratedDefault, *input.DeleteGenerated)
}
if err := c.Write(); err != nil {
return makeConfigDefaultsResult(), err
@@ -664,7 +631,7 @@ func (r *mutationResolver) GenerateAPIKey(ctx context.Context, input GenerateAPI
}
}
c.SetString(config.ApiKey, newAPIKey)
c.Set(config.ApiKey, newAPIKey)
if err := c.Write(); err != nil {
return newAPIKey, err
}
@@ -672,23 +639,9 @@ func (r *mutationResolver) GenerateAPIKey(ctx context.Context, input GenerateAPI
return newAPIKey, nil
}
func (r *mutationResolver) ConfigureUI(ctx context.Context, input map[string]interface{}, partial map[string]interface{}) (map[string]interface{}, error) {
func (r *mutationResolver) ConfigureUI(ctx context.Context, input map[string]interface{}) (map[string]interface{}, error) {
c := config.GetInstance()
if input != nil {
// #5483 - convert JSON numbers to float64 or int64
input = convertMapJSONNumbers(input)
c.SetUIConfiguration(input)
}
if partial != nil {
// #5483 - convert JSON numbers to float64 or int64
partial = convertMapJSONNumbers(partial)
// merge partial into existing config
existing := c.GetUIConfiguration()
utils.MergeMaps(existing, partial)
c.SetUIConfiguration(existing)
}
c.SetUIConfiguration(input)
if err := c.Write(); err != nil {
return c.GetUIConfiguration(), err
@@ -700,25 +653,14 @@ func (r *mutationResolver) ConfigureUI(ctx context.Context, input map[string]int
func (r *mutationResolver) ConfigureUISetting(ctx context.Context, key string, value interface{}) (map[string]interface{}, error) {
c := config.GetInstance()
cfg := utils.NestedMap(c.GetUIConfiguration())
cfg := c.GetUIConfiguration()
cfg[key] = value
// #5483 - convert JSON numbers to float64 or int64
if m, ok := value.(map[string]interface{}); ok {
value = convertMapJSONNumbers(m)
} else if n, ok := value.(json.Number); ok {
value = jsonNumberToNumber(n)
}
cfg.Set(key, value)
return r.ConfigureUI(ctx, cfg, nil)
return r.ConfigureUI(ctx, cfg)
}
func (r *mutationResolver) ConfigurePlugin(ctx context.Context, pluginID string, input map[string]interface{}) (map[string]interface{}, error) {
c := config.GetInstance()
// #5483 - convert JSON numbers to float64 or int64
input = convertMapJSONNumbers(input)
c.SetPluginConfiguration(pluginID, input)
if err := c.Write(); err != nil {

View File

@@ -5,14 +5,10 @@ import (
"fmt"
"strconv"
"github.com/stashapp/stash/internal/desktop"
"github.com/stashapp/stash/internal/manager"
"github.com/stashapp/stash/internal/manager/config"
"github.com/stashapp/stash/pkg/file"
"github.com/stashapp/stash/pkg/fsutil"
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/session"
"github.com/stashapp/stash/pkg/sliceutil/stringslice"
)
@@ -20,7 +16,7 @@ func (r *mutationResolver) MoveFiles(ctx context.Context, input MoveFilesInput)
if err := r.withTxn(ctx, func(ctx context.Context) error {
fileStore := r.repository.File
folderStore := r.repository.Folder
mover := file.NewMover(fileStore, folderStore, manager.GetInstance().Config.GetStashPaths().Paths())
mover := file.NewMover(fileStore, folderStore)
mover.RegisterHooks(ctx)
var (
@@ -58,14 +54,13 @@ func (r *mutationResolver) MoveFiles(ctx context.Context, input MoveFilesInput)
folderPath := *input.DestinationFolder
// ensure folder path is within the library
stashPaths := manager.GetInstance().Config.GetStashPaths()
if err := r.validateFolderPath(stashPaths, folderPath); err != nil {
if err := r.validateFolderPath(folderPath); err != nil {
return err
}
// get or create folder hierarchy
var err error
folder, err = file.GetOrCreateFolderHierarchy(ctx, folderStore, folderPath, stashPaths.Paths())
folder, err = file.GetOrCreateFolderHierarchy(ctx, folderStore, folderPath)
if err != nil {
return fmt.Errorf("getting or creating folder hierarchy: %w", err)
}
@@ -114,7 +109,8 @@ func (r *mutationResolver) MoveFiles(ctx context.Context, input MoveFilesInput)
return true, nil
}
func (r *mutationResolver) validateFolderPath(paths config.StashConfigs, folderPath string) error {
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)
}
@@ -153,9 +149,7 @@ func (r *mutationResolver) DeleteFiles(ctx context.Context, ids []string) (ret b
return false, fmt.Errorf("converting ids: %w", err)
}
trashPath := manager.GetInstance().Config.GetDeleteTrashPath()
fileDeleter := file.NewDeleterWithTrash(trashPath)
fileDeleter := file.NewDeleter()
destroyer := &file.ZipDestroyer{
FileDestroyer: r.repository.File,
FolderDestroyer: r.repository.Folder,
@@ -214,58 +208,6 @@ func (r *mutationResolver) DeleteFiles(ctx context.Context, ids []string) (ret b
return true, nil
}
func (r *mutationResolver) DestroyFiles(ctx context.Context, ids []string) (ret bool, err error) {
fileIDs, err := stringslice.StringSliceToIntSlice(ids)
if err != nil {
return false, fmt.Errorf("converting ids: %w", err)
}
destroyer := &file.ZipDestroyer{
FileDestroyer: r.repository.File,
FolderDestroyer: r.repository.Folder,
}
if err := r.withTxn(ctx, func(ctx context.Context) error {
qb := r.repository.File
for _, fileIDInt := range fileIDs {
fileID := models.FileID(fileIDInt)
f, err := qb.Find(ctx, fileID)
if err != nil {
return err
}
if len(f) == 0 {
return fmt.Errorf("file with id %d not found", fileID)
}
path := f[0].Base().Path
// ensure not a primary file
isPrimary, err := qb.IsPrimary(ctx, fileID)
if err != nil {
return fmt.Errorf("checking if file %s is primary: %w", path, err)
}
if isPrimary {
return fmt.Errorf("cannot destroy primary file entry %s", path)
}
// destroy DB entries only (no filesystem deletion)
const deleteFile = false
if err := destroyer.DestroyZip(ctx, f[0], nil, deleteFile); err != nil {
return fmt.Errorf("destroying file entry %s: %w", path, err)
}
}
return nil
}); err != nil {
return false, err
}
return true, nil
}
func (r *mutationResolver) FileSetFingerprints(ctx context.Context, input FileSetFingerprintsInput) (bool, error) {
fileIDInt, err := strconv.Atoi(input.ID)
if err != nil {
@@ -330,71 +272,3 @@ func (r *mutationResolver) FileSetFingerprints(ctx context.Context, input FileSe
return true, nil
}
func (r *mutationResolver) RevealFileInFileManager(ctx context.Context, id string) (bool, error) {
// disallow if request did not come from localhost
if !session.IsLocalRequest(ctx) {
logger.Warnf("Attempt to reveal file in file manager from non-local request")
return false, fmt.Errorf("access denied")
}
fileIDInt, err := strconv.Atoi(id)
if err != nil {
return false, fmt.Errorf("converting id: %w", err)
}
var filePath string
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
files, err := r.repository.File.Find(ctx, models.FileID(fileIDInt))
if err != nil {
return fmt.Errorf("finding file: %w", err)
}
if len(files) == 0 {
return fmt.Errorf("file with id %d not found", fileIDInt)
}
filePath = files[0].Base().Path
return nil
}); err != nil {
return false, err
}
if err := desktop.RevealInFileManager(filePath); err != nil {
return false, err
}
return true, nil
}
func (r *mutationResolver) RevealFolderInFileManager(ctx context.Context, id string) (bool, error) {
// disallow if request did not come from localhost
if !session.IsLocalRequest(ctx) {
logger.Warnf("Attempt to reveal folder in file manager from non-local request")
return false, fmt.Errorf("access denied")
}
folderIDInt, err := strconv.Atoi(id)
if err != nil {
return false, fmt.Errorf("converting id: %w", err)
}
var folderPath string
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
folder, err := r.repository.Folder.Find(ctx, models.FolderID(folderIDInt))
if err != nil {
return fmt.Errorf("finding folder: %w", err)
}
if folder == nil {
return fmt.Errorf("folder with id %d not found", folderIDInt)
}
folderPath = folder.Path
return nil
}); err != nil {
return false, err
}
if err := desktop.RevealInFileManager(folderPath); err != nil {
return false, err
}
return true, nil
}

View File

@@ -6,7 +6,6 @@ import (
"fmt"
"os"
"strconv"
"strings"
"github.com/stashapp/stash/internal/manager"
"github.com/stashapp/stash/pkg/file"
@@ -42,17 +41,13 @@ func (r *mutationResolver) GalleryCreate(ctx context.Context, input GalleryCreat
}
// Populate a new gallery from the input
newGallery := models.CreateGalleryInput{
Gallery: &models.Gallery{},
}
*newGallery.Gallery = models.NewGallery()
newGallery := models.NewGallery()
newGallery.Title = strings.TrimSpace(input.Title)
newGallery.Title = input.Title
newGallery.Code = translator.string(input.Code)
newGallery.Details = translator.string(input.Details)
newGallery.Photographer = translator.string(input.Photographer)
newGallery.Rating = input.Rating100
newGallery.Organized = translator.bool(input.Organized)
var err error
@@ -79,17 +74,15 @@ func (r *mutationResolver) GalleryCreate(ctx context.Context, input GalleryCreat
}
if input.Urls != nil {
newGallery.URLs = models.NewRelatedStrings(stringslice.TrimSpace(input.Urls))
newGallery.URLs = models.NewRelatedStrings(input.Urls)
} else if input.URL != nil {
newGallery.URLs = models.NewRelatedStrings([]string{strings.TrimSpace(*input.URL)})
newGallery.URLs = models.NewRelatedStrings([]string{*input.URL})
}
newGallery.CustomFields = convertMapJSONNumbers(input.CustomFields)
// Start the transaction and save the gallery
if err := r.withTxn(ctx, func(ctx context.Context) error {
qb := r.repository.Gallery
if err := qb.Create(ctx, &newGallery); err != nil {
if err := qb.Create(ctx, &newGallery, nil); err != nil {
return err
}
@@ -246,10 +239,6 @@ func (r *mutationResolver) galleryUpdate(ctx context.Context, input models.Galle
return nil, fmt.Errorf("converting scene ids: %w", err)
}
if input.CustomFields != nil {
updatedGallery.CustomFields = handleUpdateCustomFields(*input.CustomFields)
}
// gallery scene is set from the scene only
gallery, err := qb.UpdatePartial(ctx, galleryID, updatedGallery)
@@ -302,10 +291,6 @@ func (r *mutationResolver) BulkGalleryUpdate(ctx context.Context, input BulkGall
return nil, fmt.Errorf("converting scene ids: %w", err)
}
if input.CustomFields != nil {
updatedGallery.CustomFields = handleUpdateCustomFields(*input.CustomFields)
}
ret := []*models.Gallery{}
// Start the transaction and save the galleries
@@ -348,18 +333,15 @@ func (r *mutationResolver) GalleryDestroy(ctx context.Context, input models.Gall
return false, fmt.Errorf("converting ids: %w", err)
}
trashPath := manager.GetInstance().Config.GetDeleteTrashPath()
var galleries []*models.Gallery
var imgsDestroyed []*models.Image
fileDeleter := &image.FileDeleter{
Deleter: file.NewDeleterWithTrash(trashPath),
Deleter: file.NewDeleter(),
Paths: manager.GetInstance().Paths,
}
deleteGenerated := utils.IsTrue(input.DeleteGenerated)
deleteFile := utils.IsTrue(input.DeleteFile)
destroyFileEntry := utils.IsTrue(input.DestroyFileEntry)
if err := r.withTxn(ctx, func(ctx context.Context) error {
qb := r.repository.Gallery
@@ -380,7 +362,7 @@ func (r *mutationResolver) GalleryDestroy(ctx context.Context, input models.Gall
galleries = append(galleries, gallery)
imgsDestroyed, err = r.galleryService.Destroy(ctx, gallery, fileDeleter, deleteGenerated, deleteFile, destroyFileEntry)
imgsDestroyed, err = r.galleryService.Destroy(ctx, gallery, fileDeleter, deleteGenerated, deleteFile)
if err != nil {
return err
}
@@ -496,61 +478,6 @@ func (r *mutationResolver) RemoveGalleryImages(ctx context.Context, input Galler
return true, nil
}
func (r *mutationResolver) SetGalleryCover(ctx context.Context, input GallerySetCoverInput) (bool, error) {
galleryID, err := strconv.Atoi(input.GalleryID)
if err != nil {
return false, fmt.Errorf("converting gallery id: %w", err)
}
coverImageID, err := strconv.Atoi(input.CoverImageID)
if err != nil {
return false, fmt.Errorf("converting cover image id: %w", err)
}
if err := r.withTxn(ctx, func(ctx context.Context) error {
qb := r.repository.Gallery
gallery, err := qb.Find(ctx, galleryID)
if err != nil {
return err
}
if gallery == nil {
return fmt.Errorf("gallery with id %d not found", galleryID)
}
return r.galleryService.SetCover(ctx, gallery, coverImageID)
}); err != nil {
return false, err
}
return true, nil
}
func (r *mutationResolver) ResetGalleryCover(ctx context.Context, input GalleryResetCoverInput) (bool, error) {
galleryID, err := strconv.Atoi(input.GalleryID)
if err != nil {
return false, fmt.Errorf("converting gallery id: %w", err)
}
if err := r.withTxn(ctx, func(ctx context.Context) error {
qb := r.repository.Gallery
gallery, err := qb.Find(ctx, galleryID)
if err != nil {
return err
}
if gallery == nil {
return fmt.Errorf("gallery with id %d not found", galleryID)
}
return r.galleryService.ResetCover(ctx, gallery)
}); err != nil {
return false, err
}
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)

View File

@@ -1,437 +0,0 @@
package api
import (
"context"
"fmt"
"strconv"
"strings"
"github.com/stashapp/stash/internal/static"
"github.com/stashapp/stash/pkg/group"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/plugin/hook"
"github.com/stashapp/stash/pkg/sliceutil/stringslice"
"github.com/stashapp/stash/pkg/utils"
)
func groupFromGroupCreateInput(ctx context.Context, input GroupCreateInput) (*models.CreateGroupInput, error) {
translator := changesetTranslator{
inputMap: getUpdateInputMap(ctx),
}
// Populate a new group from the input
newGroupInput := &models.CreateGroupInput{
Group: &models.Group{},
}
*newGroupInput.Group = models.NewGroup()
newGroup := newGroupInput.Group
newGroup.Name = strings.TrimSpace(input.Name)
newGroup.Aliases = translator.string(input.Aliases)
newGroup.Duration = input.Duration
newGroup.Rating = input.Rating100
newGroup.Director = translator.string(input.Director)
newGroup.Synopsis = translator.string(input.Synopsis)
var err error
newGroup.Date, err = translator.datePtr(input.Date)
if err != nil {
return nil, fmt.Errorf("converting date: %w", err)
}
newGroup.StudioID, err = translator.intPtrFromString(input.StudioID)
if err != nil {
return nil, fmt.Errorf("converting studio id: %w", err)
}
newGroup.TagIDs, err = translator.relatedIds(input.TagIds)
if err != nil {
return nil, fmt.Errorf("converting tag ids: %w", err)
}
newGroup.ContainingGroups, err = translator.groupIDDescriptions(input.ContainingGroups)
if err != nil {
return nil, fmt.Errorf("converting containing group ids: %w", err)
}
newGroup.SubGroups, err = translator.groupIDDescriptions(input.SubGroups)
if err != nil {
return nil, fmt.Errorf("converting containing group ids: %w", err)
}
if input.Urls != nil {
newGroup.URLs = models.NewRelatedStrings(stringslice.TrimSpace(input.Urls))
}
newGroupInput.CustomFields = convertMapJSONNumbers(input.CustomFields)
// Process the base 64 encoded image string
if input.FrontImage != nil {
newGroupInput.FrontImageData, err = utils.ProcessImageInput(ctx, *input.FrontImage)
if err != nil {
return nil, fmt.Errorf("processing front image: %w", err)
}
}
// Process the base 64 encoded image string
if input.BackImage != nil {
newGroupInput.BackImageData, err = utils.ProcessImageInput(ctx, *input.BackImage)
if err != nil {
return nil, fmt.Errorf("processing back image: %w", err)
}
}
// HACK: if back image is being set, set the front image to the default.
// This is because we can't have a null front image with a non-null back image.
if len(newGroupInput.FrontImageData) == 0 && len(newGroupInput.BackImageData) != 0 {
newGroupInput.FrontImageData = static.ReadAll(static.DefaultGroupImage)
}
return newGroupInput, nil
}
func (r *mutationResolver) GroupCreate(ctx context.Context, input GroupCreateInput) (*models.Group, error) {
createGroupInput, err := groupFromGroupCreateInput(ctx, input)
if err != nil {
return nil, err
}
// Start the transaction and save the group
if err := r.withTxn(ctx, func(ctx context.Context) error {
if err = r.groupService.Create(ctx, createGroupInput); err != nil {
return err
}
return nil
}); err != nil {
return nil, err
}
// for backwards compatibility - run both movie and group hooks
r.hookExecutor.ExecutePostHooks(ctx, createGroupInput.Group.ID, hook.GroupCreatePost, input, nil)
r.hookExecutor.ExecutePostHooks(ctx, createGroupInput.Group.ID, hook.MovieCreatePost, input, nil)
return r.getGroup(ctx, createGroupInput.Group.ID)
}
func groupPartialFromGroupUpdateInput(translator changesetTranslator, input GroupUpdateInput) (ret models.GroupPartial, err error) {
// Populate group from the input
updatedGroup := models.NewGroupPartial()
updatedGroup.Name = translator.optionalString(input.Name, "name")
updatedGroup.Aliases = translator.optionalString(input.Aliases, "aliases")
updatedGroup.Duration = translator.optionalInt(input.Duration, "duration")
updatedGroup.Rating = translator.optionalInt(input.Rating100, "rating100")
updatedGroup.Director = translator.optionalString(input.Director, "director")
updatedGroup.Synopsis = translator.optionalString(input.Synopsis, "synopsis")
updatedGroup.Date, err = translator.optionalDate(input.Date, "date")
if err != nil {
err = fmt.Errorf("converting date: %w", err)
return
}
updatedGroup.StudioID, err = translator.optionalIntFromString(input.StudioID, "studio_id")
if err != nil {
err = fmt.Errorf("converting studio id: %w", err)
return
}
updatedGroup.TagIDs, err = translator.updateIds(input.TagIds, "tag_ids")
if err != nil {
err = fmt.Errorf("converting tag ids: %w", err)
return
}
updatedGroup.ContainingGroups, err = translator.updateGroupIDDescriptions(input.ContainingGroups, "containing_groups")
if err != nil {
err = fmt.Errorf("converting containing group ids: %w", err)
return
}
updatedGroup.SubGroups, err = translator.updateGroupIDDescriptions(input.SubGroups, "sub_groups")
if err != nil {
err = fmt.Errorf("converting containing group ids: %w", err)
return
}
updatedGroup.URLs = translator.updateStrings(input.Urls, "urls")
if input.CustomFields != nil {
updatedGroup.CustomFields = *input.CustomFields
// convert json.Numbers to int/float
updatedGroup.CustomFields.Full = convertMapJSONNumbers(updatedGroup.CustomFields.Full)
updatedGroup.CustomFields.Partial = convertMapJSONNumbers(updatedGroup.CustomFields.Partial)
}
return updatedGroup, nil
}
func (r *mutationResolver) GroupUpdate(ctx context.Context, input GroupUpdateInput) (*models.Group, error) {
groupID, err := strconv.Atoi(input.ID)
if err != nil {
return nil, fmt.Errorf("converting id: %w", err)
}
translator := changesetTranslator{
inputMap: getUpdateInputMap(ctx),
}
updatedGroup, err := groupPartialFromGroupUpdateInput(translator, input)
if err != nil {
return nil, err
}
var frontimageData []byte
frontImageIncluded := translator.hasField("front_image")
if input.FrontImage != nil {
frontimageData, err = utils.ProcessImageInput(ctx, *input.FrontImage)
if err != nil {
return nil, fmt.Errorf("processing front image: %w", err)
}
}
var backimageData []byte
backImageIncluded := translator.hasField("back_image")
if input.BackImage != nil {
backimageData, err = utils.ProcessImageInput(ctx, *input.BackImage)
if err != nil {
return nil, fmt.Errorf("processing back image: %w", err)
}
}
if err := r.withTxn(ctx, func(ctx context.Context) error {
frontImage := group.ImageInput{
Image: frontimageData,
Set: frontImageIncluded,
}
backImage := group.ImageInput{
Image: backimageData,
Set: backImageIncluded,
}
_, err = r.groupService.UpdatePartial(ctx, groupID, updatedGroup, frontImage, backImage)
if err != nil {
return err
}
return nil
}); err != nil {
return nil, err
}
// for backwards compatibility - run both movie and group hooks
r.hookExecutor.ExecutePostHooks(ctx, groupID, hook.GroupUpdatePost, input, translator.getFields())
r.hookExecutor.ExecutePostHooks(ctx, groupID, hook.MovieUpdatePost, input, translator.getFields())
return r.getGroup(ctx, groupID)
}
func groupPartialFromBulkGroupUpdateInput(translator changesetTranslator, input BulkGroupUpdateInput) (ret models.GroupPartial, err error) {
updatedGroup := models.NewGroupPartial()
updatedGroup.Date, err = translator.optionalDate(input.Date, "date")
if err != nil {
err = fmt.Errorf("converting date: %w", err)
return
}
updatedGroup.Synopsis = translator.optionalString(input.Synopsis, "synopsis")
updatedGroup.Rating = translator.optionalInt(input.Rating100, "rating100")
updatedGroup.Director = translator.optionalString(input.Director, "director")
updatedGroup.StudioID, err = translator.optionalIntFromString(input.StudioID, "studio_id")
if err != nil {
err = fmt.Errorf("converting studio id: %w", err)
return
}
updatedGroup.TagIDs, err = translator.updateIdsBulk(input.TagIds, "tag_ids")
if err != nil {
err = fmt.Errorf("converting tag ids: %w", err)
return
}
updatedGroup.ContainingGroups, err = translator.updateGroupIDDescriptionsBulk(input.ContainingGroups, "containing_groups")
if err != nil {
err = fmt.Errorf("converting containing group ids: %w", err)
return
}
updatedGroup.SubGroups, err = translator.updateGroupIDDescriptionsBulk(input.SubGroups, "sub_groups")
if err != nil {
err = fmt.Errorf("converting containing group ids: %w", err)
return
}
updatedGroup.URLs = translator.optionalURLsBulk(input.Urls, nil)
if input.CustomFields != nil {
updatedGroup.CustomFields = *input.CustomFields
// convert json.Numbers to int/float
updatedGroup.CustomFields.Full = convertMapJSONNumbers(updatedGroup.CustomFields.Full)
updatedGroup.CustomFields.Partial = convertMapJSONNumbers(updatedGroup.CustomFields.Partial)
}
return updatedGroup, nil
}
func (r *mutationResolver) BulkGroupUpdate(ctx context.Context, input BulkGroupUpdateInput) ([]*models.Group, error) {
groupIDs, err := stringslice.StringSliceToIntSlice(input.Ids)
if err != nil {
return nil, fmt.Errorf("converting ids: %w", err)
}
translator := changesetTranslator{
inputMap: getUpdateInputMap(ctx),
}
// Populate group from the input
updatedGroup, err := groupPartialFromBulkGroupUpdateInput(translator, input)
if err != nil {
return nil, err
}
ret := []*models.Group{}
if err := r.withTxn(ctx, func(ctx context.Context) error {
for _, groupID := range groupIDs {
group, err := r.groupService.UpdatePartial(ctx, groupID, updatedGroup, group.ImageInput{}, group.ImageInput{})
if err != nil {
return err
}
ret = append(ret, group)
}
return nil
}); err != nil {
return nil, err
}
var newRet []*models.Group
for _, group := range ret {
// for backwards compatibility - run both movie and group hooks
r.hookExecutor.ExecutePostHooks(ctx, group.ID, hook.GroupUpdatePost, input, translator.getFields())
r.hookExecutor.ExecutePostHooks(ctx, group.ID, hook.MovieUpdatePost, input, translator.getFields())
group, err = r.getGroup(ctx, group.ID)
if err != nil {
return nil, err
}
newRet = append(newRet, group)
}
return newRet, nil
}
func (r *mutationResolver) GroupDestroy(ctx context.Context, input GroupDestroyInput) (bool, error) {
id, err := strconv.Atoi(input.ID)
if err != nil {
return false, fmt.Errorf("converting id: %w", err)
}
if err := r.withTxn(ctx, func(ctx context.Context) error {
return r.repository.Group.Destroy(ctx, id)
}); err != nil {
return false, err
}
// for backwards compatibility - run both movie and group hooks
r.hookExecutor.ExecutePostHooks(ctx, id, hook.GroupDestroyPost, input, nil)
r.hookExecutor.ExecutePostHooks(ctx, id, hook.MovieDestroyPost, input, nil)
return true, nil
}
func (r *mutationResolver) GroupsDestroy(ctx context.Context, groupIDs []string) (bool, error) {
ids, err := stringslice.StringSliceToIntSlice(groupIDs)
if err != nil {
return false, fmt.Errorf("converting ids: %w", err)
}
if err := r.withTxn(ctx, func(ctx context.Context) error {
qb := r.repository.Group
for _, id := range ids {
if err := qb.Destroy(ctx, id); err != nil {
return err
}
}
return nil
}); err != nil {
return false, err
}
for _, id := range ids {
// for backwards compatibility - run both movie and group hooks
r.hookExecutor.ExecutePostHooks(ctx, id, hook.GroupDestroyPost, groupIDs, nil)
r.hookExecutor.ExecutePostHooks(ctx, id, hook.MovieDestroyPost, groupIDs, nil)
}
return true, nil
}
func (r *mutationResolver) AddGroupSubGroups(ctx context.Context, input GroupSubGroupAddInput) (bool, error) {
groupID, err := strconv.Atoi(input.ContainingGroupID)
if err != nil {
return false, fmt.Errorf("converting group id: %w", err)
}
subGroups, err := groupsDescriptionsFromGroupInput(input.SubGroups)
if err != nil {
return false, fmt.Errorf("converting sub group ids: %w", err)
}
if err := r.withTxn(ctx, func(ctx context.Context) error {
return r.groupService.AddSubGroups(ctx, groupID, subGroups, input.InsertIndex)
}); err != nil {
return false, err
}
return true, nil
}
func (r *mutationResolver) RemoveGroupSubGroups(ctx context.Context, input GroupSubGroupRemoveInput) (bool, error) {
groupID, err := strconv.Atoi(input.ContainingGroupID)
if err != nil {
return false, fmt.Errorf("converting group id: %w", err)
}
subGroupIDs, err := stringslice.StringSliceToIntSlice(input.SubGroupIds)
if err != nil {
return false, fmt.Errorf("converting sub group ids: %w", err)
}
if err := r.withTxn(ctx, func(ctx context.Context) error {
return r.groupService.RemoveSubGroups(ctx, groupID, subGroupIDs)
}); err != nil {
return false, err
}
return true, nil
}
func (r *mutationResolver) ReorderSubGroups(ctx context.Context, input ReorderSubGroupsInput) (bool, error) {
groupID, err := strconv.Atoi(input.GroupID)
if err != nil {
return false, fmt.Errorf("converting group id: %w", err)
}
subGroupIDs, err := stringslice.StringSliceToIntSlice(input.SubGroupIds)
if err != nil {
return false, fmt.Errorf("converting sub group ids: %w", err)
}
insertPointID, err := strconv.Atoi(input.InsertAtID)
if err != nil {
return false, fmt.Errorf("converting insert at id: %w", err)
}
insertAfter := utils.IsTrue(input.InsertAfter)
if err := r.withTxn(ctx, func(ctx context.Context) error {
return r.groupService.ReorderSubGroups(ctx, groupID, subGroupIDs, insertPointID, insertAfter)
}); err != nil {
return false, err
}
return true, nil
}

View File

@@ -28,7 +28,7 @@ func (r *mutationResolver) getImage(ctx context.Context, id int) (ret *models.Im
return ret, nil
}
func (r *mutationResolver) ImageUpdate(ctx context.Context, input models.ImageUpdateInput) (ret *models.Image, err error) {
func (r *mutationResolver) ImageUpdate(ctx context.Context, input ImageUpdateInput) (ret *models.Image, err error) {
translator := changesetTranslator{
inputMap: getUpdateInputMap(ctx),
}
@@ -46,7 +46,7 @@ func (r *mutationResolver) ImageUpdate(ctx context.Context, input models.ImageUp
return r.getImage(ctx, ret.ID)
}
func (r *mutationResolver) ImagesUpdate(ctx context.Context, input []*models.ImageUpdateInput) (ret []*models.Image, err error) {
func (r *mutationResolver) ImagesUpdate(ctx context.Context, input []*ImageUpdateInput) (ret []*models.Image, err error) {
inputMaps := getUpdateInputMaps(ctx)
// Start the transaction and save the image
@@ -89,7 +89,7 @@ func (r *mutationResolver) ImagesUpdate(ctx context.Context, input []*models.Ima
return newRet, nil
}
func (r *mutationResolver) imageUpdate(ctx context.Context, input models.ImageUpdateInput, translator changesetTranslator) (*models.Image, error) {
func (r *mutationResolver) imageUpdate(ctx context.Context, input ImageUpdateInput, translator changesetTranslator) (*models.Image, error) {
imageID, err := strconv.Atoi(input.ID)
if err != nil {
return nil, fmt.Errorf("converting id: %w", err)
@@ -177,13 +177,6 @@ func (r *mutationResolver) imageUpdate(ctx context.Context, input models.ImageUp
return nil, fmt.Errorf("converting tag ids: %w", err)
}
if input.CustomFields != nil {
updatedImage.CustomFields = *input.CustomFields
// convert json.Numbers to int/float
updatedImage.CustomFields.Full = convertMapJSONNumbers(updatedImage.CustomFields.Full)
updatedImage.CustomFields.Partial = convertMapJSONNumbers(updatedImage.CustomFields.Partial)
}
qb := r.repository.Image
image, err := qb.UpdatePartial(ctx, imageID, updatedImage)
if err != nil {
@@ -244,13 +237,6 @@ func (r *mutationResolver) BulkImageUpdate(ctx context.Context, input BulkImageU
return nil, fmt.Errorf("converting tag ids: %w", err)
}
if input.CustomFields != nil {
updatedImage.CustomFields = *input.CustomFields
// convert json.Numbers to int/float
updatedImage.CustomFields.Full = convertMapJSONNumbers(updatedImage.CustomFields.Full)
updatedImage.CustomFields.Partial = convertMapJSONNumbers(updatedImage.CustomFields.Partial)
}
// Start the transaction and save the images
if err := r.withTxn(ctx, func(ctx context.Context) error {
var updatedGalleryIDs []int
@@ -322,11 +308,9 @@ func (r *mutationResolver) ImageDestroy(ctx context.Context, input models.ImageD
return false, fmt.Errorf("converting id: %w", err)
}
trashPath := manager.GetInstance().Config.GetDeleteTrashPath()
var i *models.Image
fileDeleter := &image.FileDeleter{
Deleter: file.NewDeleterWithTrash(trashPath),
Deleter: file.NewDeleter(),
Paths: manager.GetInstance().Paths,
}
if err := r.withTxn(ctx, func(ctx context.Context) error {
@@ -339,7 +323,7 @@ func (r *mutationResolver) ImageDestroy(ctx context.Context, input models.ImageD
return fmt.Errorf("image with id %d not found", imageID)
}
return r.imageService.Destroy(ctx, i, fileDeleter, utils.IsTrue(input.DeleteGenerated), utils.IsTrue(input.DeleteFile), utils.IsTrue(input.DestroyFileEntry))
return r.imageService.Destroy(ctx, i, fileDeleter, utils.IsTrue(input.DeleteGenerated), utils.IsTrue(input.DeleteFile))
}); err != nil {
fileDeleter.Rollback()
return false, err
@@ -364,11 +348,9 @@ func (r *mutationResolver) ImagesDestroy(ctx context.Context, input models.Image
return false, fmt.Errorf("converting ids: %w", err)
}
trashPath := manager.GetInstance().Config.GetDeleteTrashPath()
var images []*models.Image
fileDeleter := &image.FileDeleter{
Deleter: file.NewDeleterWithTrash(trashPath),
Deleter: file.NewDeleter(),
Paths: manager.GetInstance().Paths,
}
if err := r.withTxn(ctx, func(ctx context.Context) error {
@@ -386,7 +368,7 @@ func (r *mutationResolver) ImagesDestroy(ctx context.Context, input models.Image
images = append(images, i)
if err := r.imageService.Destroy(ctx, i, fileDeleter, utils.IsTrue(input.DeleteGenerated), utils.IsTrue(input.DeleteFile), utils.IsTrue(input.DestroyFileEntry)); err != nil {
if err := r.imageService.Destroy(ctx, i, fileDeleter, utils.IsTrue(input.DeleteGenerated), utils.IsTrue(input.DeleteFile)); err != nil {
return err
}
}

View File

@@ -122,10 +122,9 @@ func (r *mutationResolver) MigrateHashNaming(ctx context.Context) (string, error
func (r *mutationResolver) BackupDatabase(ctx context.Context, input BackupDatabaseInput) (*string, error) {
// if download is true, then backup to temporary file and return a link
download := input.Download != nil && *input.Download
includeBlobs := input.IncludeBlobs != nil && *input.IncludeBlobs
mgr := manager.GetInstance()
backupPath, backupName, err := mgr.BackupDatabase(download, includeBlobs)
backupPath, backupName, err := mgr.BackupDatabase(download)
if err != nil {
logger.Errorf("Error backing up database: %v", err)
return nil, err

View File

@@ -38,20 +38,3 @@ func (r *mutationResolver) MigrateBlobs(ctx context.Context, input MigrateBlobsI
return strconv.Itoa(jobID), nil
}
func (r *mutationResolver) Migrate(ctx context.Context, input manager.MigrateInput) (string, error) {
mgr := manager.GetInstance()
t := &task.MigrateJob{
BackupPath: input.BackupPath,
Config: mgr.Config,
Database: mgr.Database,
}
if err := t.PreExecute(); err != nil {
return "", err
}
jobID := mgr.JobManager.Add(ctx, "Migrating database...", t)
return strconv.Itoa(jobID), nil
}

View File

@@ -4,7 +4,6 @@ import (
"context"
"fmt"
"strconv"
"strings"
"github.com/stashapp/stash/internal/static"
"github.com/stashapp/stash/pkg/models"
@@ -13,10 +12,10 @@ import (
"github.com/stashapp/stash/pkg/utils"
)
// used to refetch group after hooks run
func (r *mutationResolver) getGroup(ctx context.Context, id int) (ret *models.Group, err error) {
// used to refetch movie after hooks run
func (r *mutationResolver) getMovie(ctx context.Context, id int) (ret *models.Movie, err error) {
if err := r.withTxn(ctx, func(ctx context.Context) error {
ret, err = r.repository.Group.Find(ctx, id)
ret, err = r.repository.Movie.Find(ctx, id)
return err
}); err != nil {
return nil, err
@@ -25,43 +24,33 @@ func (r *mutationResolver) getGroup(ctx context.Context, id int) (ret *models.Gr
return ret, nil
}
func (r *mutationResolver) MovieCreate(ctx context.Context, input MovieCreateInput) (*models.Group, error) {
func (r *mutationResolver) MovieCreate(ctx context.Context, input MovieCreateInput) (*models.Movie, error) {
translator := changesetTranslator{
inputMap: getUpdateInputMap(ctx),
}
// Populate a new group from the input
newGroup := models.NewGroup()
// Populate a new movie from the input
newMovie := models.NewMovie()
newGroup.Name = strings.TrimSpace(input.Name)
newGroup.Aliases = translator.string(input.Aliases)
newGroup.Duration = input.Duration
newGroup.Rating = input.Rating100
newGroup.Director = translator.string(input.Director)
newGroup.Synopsis = translator.string(input.Synopsis)
newMovie.Name = input.Name
newMovie.Aliases = translator.string(input.Aliases)
newMovie.Duration = input.Duration
newMovie.Rating = input.Rating100
newMovie.Director = translator.string(input.Director)
newMovie.Synopsis = translator.string(input.Synopsis)
newMovie.URL = translator.string(input.URL)
var err error
newGroup.Date, err = translator.datePtr(input.Date)
newMovie.Date, err = translator.datePtr(input.Date)
if err != nil {
return nil, fmt.Errorf("converting date: %w", err)
}
newGroup.StudioID, err = translator.intPtrFromString(input.StudioID)
newMovie.StudioID, err = translator.intPtrFromString(input.StudioID)
if err != nil {
return nil, fmt.Errorf("converting studio id: %w", err)
}
newGroup.TagIDs, err = translator.relatedIds(input.TagIds)
if err != nil {
return nil, fmt.Errorf("converting tag ids: %w", err)
}
if input.Urls != nil {
newGroup.URLs = models.NewRelatedStrings(stringslice.TrimSpace(input.Urls))
} else if input.URL != nil {
newGroup.URLs = models.NewRelatedStrings([]string{strings.TrimSpace(*input.URL)})
}
// Process the base 64 encoded image string
var frontimageData []byte
if input.FrontImage != nil {
@@ -83,27 +72,27 @@ func (r *mutationResolver) MovieCreate(ctx context.Context, input MovieCreateInp
// HACK: if back image is being set, set the front image to the default.
// This is because we can't have a null front image with a non-null back image.
if len(frontimageData) == 0 && len(backimageData) != 0 {
frontimageData = static.ReadAll(static.DefaultGroupImage)
frontimageData = static.ReadAll(static.DefaultMovieImage)
}
// Start the transaction and save the group
// Start the transaction and save the movie
if err := r.withTxn(ctx, func(ctx context.Context) error {
qb := r.repository.Group
qb := r.repository.Movie
err = qb.Create(ctx, &newGroup)
err = qb.Create(ctx, &newMovie)
if err != nil {
return err
}
// update image table
if len(frontimageData) > 0 {
if err := qb.UpdateFrontImage(ctx, newGroup.ID, frontimageData); err != nil {
if err := qb.UpdateFrontImage(ctx, newMovie.ID, frontimageData); err != nil {
return err
}
}
if len(backimageData) > 0 {
if err := qb.UpdateBackImage(ctx, newGroup.ID, backimageData); err != nil {
if err := qb.UpdateBackImage(ctx, newMovie.ID, backimageData); err != nil {
return err
}
}
@@ -113,14 +102,12 @@ func (r *mutationResolver) MovieCreate(ctx context.Context, input MovieCreateInp
return nil, err
}
// for backwards compatibility - run both movie and group hooks
r.hookExecutor.ExecutePostHooks(ctx, newGroup.ID, hook.GroupCreatePost, input, nil)
r.hookExecutor.ExecutePostHooks(ctx, newGroup.ID, hook.MovieCreatePost, input, nil)
return r.getGroup(ctx, newGroup.ID)
r.hookExecutor.ExecutePostHooks(ctx, newMovie.ID, hook.MovieCreatePost, input, nil)
return r.getMovie(ctx, newMovie.ID)
}
func (r *mutationResolver) MovieUpdate(ctx context.Context, input MovieUpdateInput) (*models.Group, error) {
groupID, err := strconv.Atoi(input.ID)
func (r *mutationResolver) MovieUpdate(ctx context.Context, input MovieUpdateInput) (*models.Movie, error) {
movieID, err := strconv.Atoi(input.ID)
if err != nil {
return nil, fmt.Errorf("converting id: %w", err)
}
@@ -129,32 +116,26 @@ func (r *mutationResolver) MovieUpdate(ctx context.Context, input MovieUpdateInp
inputMap: getUpdateInputMap(ctx),
}
// Populate group from the input
updatedGroup := models.NewGroupPartial()
// Populate movie from the input
updatedMovie := models.NewMoviePartial()
updatedGroup.Name = translator.optionalString(input.Name, "name")
updatedGroup.Aliases = translator.optionalString(input.Aliases, "aliases")
updatedGroup.Duration = translator.optionalInt(input.Duration, "duration")
updatedGroup.Rating = translator.optionalInt(input.Rating100, "rating100")
updatedGroup.Director = translator.optionalString(input.Director, "director")
updatedGroup.Synopsis = translator.optionalString(input.Synopsis, "synopsis")
updatedMovie.Name = translator.optionalString(input.Name, "name")
updatedMovie.Aliases = translator.optionalString(input.Aliases, "aliases")
updatedMovie.Duration = translator.optionalInt(input.Duration, "duration")
updatedMovie.Rating = translator.optionalInt(input.Rating100, "rating100")
updatedMovie.Director = translator.optionalString(input.Director, "director")
updatedMovie.Synopsis = translator.optionalString(input.Synopsis, "synopsis")
updatedMovie.URL = translator.optionalString(input.URL, "url")
updatedGroup.Date, err = translator.optionalDate(input.Date, "date")
updatedMovie.Date, err = translator.optionalDate(input.Date, "date")
if err != nil {
return nil, fmt.Errorf("converting date: %w", err)
}
updatedGroup.StudioID, err = translator.optionalIntFromString(input.StudioID, "studio_id")
updatedMovie.StudioID, err = translator.optionalIntFromString(input.StudioID, "studio_id")
if err != nil {
return nil, fmt.Errorf("converting studio id: %w", err)
}
updatedGroup.TagIDs, err = translator.updateIds(input.TagIds, "tag_ids")
if err != nil {
return nil, fmt.Errorf("converting tag ids: %w", err)
}
updatedGroup.URLs = translator.optionalURLs(input.Urls, input.URL)
var frontimageData []byte
frontImageIncluded := translator.hasField("front_image")
if input.FrontImage != nil {
@@ -173,24 +154,24 @@ func (r *mutationResolver) MovieUpdate(ctx context.Context, input MovieUpdateInp
}
}
// Start the transaction and save the group
var group *models.Group
// Start the transaction and save the movie
var movie *models.Movie
if err := r.withTxn(ctx, func(ctx context.Context) error {
qb := r.repository.Group
group, err = qb.UpdatePartial(ctx, groupID, updatedGroup)
qb := r.repository.Movie
movie, err = qb.UpdatePartial(ctx, movieID, updatedMovie)
if err != nil {
return err
}
// update image table
if frontImageIncluded {
if err := qb.UpdateFrontImage(ctx, group.ID, frontimageData); err != nil {
if err := qb.UpdateFrontImage(ctx, movie.ID, frontimageData); err != nil {
return err
}
}
if backImageIncluded {
if err := qb.UpdateBackImage(ctx, group.ID, backimageData); err != nil {
if err := qb.UpdateBackImage(ctx, movie.ID, backimageData); err != nil {
return err
}
}
@@ -200,14 +181,12 @@ func (r *mutationResolver) MovieUpdate(ctx context.Context, input MovieUpdateInp
return nil, err
}
// for backwards compatibility - run both movie and group hooks
r.hookExecutor.ExecutePostHooks(ctx, group.ID, hook.GroupUpdatePost, input, translator.getFields())
r.hookExecutor.ExecutePostHooks(ctx, group.ID, hook.MovieUpdatePost, input, translator.getFields())
return r.getGroup(ctx, group.ID)
r.hookExecutor.ExecutePostHooks(ctx, movie.ID, hook.MovieUpdatePost, input, translator.getFields())
return r.getMovie(ctx, movie.ID)
}
func (r *mutationResolver) BulkMovieUpdate(ctx context.Context, input BulkMovieUpdateInput) ([]*models.Group, error) {
groupIDs, err := stringslice.StringSliceToIntSlice(input.Ids)
func (r *mutationResolver) BulkMovieUpdate(ctx context.Context, input BulkMovieUpdateInput) ([]*models.Movie, error) {
movieIDs, err := stringslice.StringSliceToIntSlice(input.Ids)
if err != nil {
return nil, fmt.Errorf("converting ids: %w", err)
}
@@ -216,36 +195,29 @@ func (r *mutationResolver) BulkMovieUpdate(ctx context.Context, input BulkMovieU
inputMap: getUpdateInputMap(ctx),
}
// Populate group from the input
updatedGroup := models.NewGroupPartial()
// Populate movie from the input
updatedMovie := models.NewMoviePartial()
updatedGroup.Rating = translator.optionalInt(input.Rating100, "rating100")
updatedGroup.Director = translator.optionalString(input.Director, "director")
updatedMovie.Rating = translator.optionalInt(input.Rating100, "rating100")
updatedMovie.Director = translator.optionalString(input.Director, "director")
updatedGroup.StudioID, err = translator.optionalIntFromString(input.StudioID, "studio_id")
updatedMovie.StudioID, err = translator.optionalIntFromString(input.StudioID, "studio_id")
if err != nil {
return nil, fmt.Errorf("converting studio id: %w", err)
}
updatedGroup.TagIDs, err = translator.updateIdsBulk(input.TagIds, "tag_ids")
if err != nil {
return nil, fmt.Errorf("converting tag ids: %w", err)
}
updatedGroup.URLs = translator.optionalURLsBulk(input.Urls, nil)
ret := []*models.Group{}
ret := []*models.Movie{}
if err := r.withTxn(ctx, func(ctx context.Context) error {
qb := r.repository.Group
qb := r.repository.Movie
for _, groupID := range groupIDs {
group, err := qb.UpdatePartial(ctx, groupID, updatedGroup)
for _, movieID := range movieIDs {
movie, err := qb.UpdatePartial(ctx, movieID, updatedMovie)
if err != nil {
return err
}
ret = append(ret, group)
ret = append(ret, movie)
}
return nil
@@ -253,18 +225,16 @@ func (r *mutationResolver) BulkMovieUpdate(ctx context.Context, input BulkMovieU
return nil, err
}
var newRet []*models.Group
for _, group := range ret {
// for backwards compatibility - run both movie and group hooks
r.hookExecutor.ExecutePostHooks(ctx, group.ID, hook.GroupUpdatePost, input, translator.getFields())
r.hookExecutor.ExecutePostHooks(ctx, group.ID, hook.MovieUpdatePost, input, translator.getFields())
var newRet []*models.Movie
for _, movie := range ret {
r.hookExecutor.ExecutePostHooks(ctx, movie.ID, hook.MovieUpdatePost, input, translator.getFields())
group, err = r.getGroup(ctx, group.ID)
movie, err = r.getMovie(ctx, movie.ID)
if err != nil {
return nil, err
}
newRet = append(newRet, group)
newRet = append(newRet, movie)
}
return newRet, nil
@@ -277,26 +247,24 @@ func (r *mutationResolver) MovieDestroy(ctx context.Context, input MovieDestroyI
}
if err := r.withTxn(ctx, func(ctx context.Context) error {
return r.repository.Group.Destroy(ctx, id)
return r.repository.Movie.Destroy(ctx, id)
}); err != nil {
return false, err
}
// for backwards compatibility - run both movie and group hooks
r.hookExecutor.ExecutePostHooks(ctx, id, hook.GroupDestroyPost, input, nil)
r.hookExecutor.ExecutePostHooks(ctx, id, hook.MovieDestroyPost, input, nil)
return true, nil
}
func (r *mutationResolver) MoviesDestroy(ctx context.Context, groupIDs []string) (bool, error) {
ids, err := stringslice.StringSliceToIntSlice(groupIDs)
func (r *mutationResolver) MoviesDestroy(ctx context.Context, movieIDs []string) (bool, error) {
ids, err := stringslice.StringSliceToIntSlice(movieIDs)
if err != nil {
return false, fmt.Errorf("converting ids: %w", err)
}
if err := r.withTxn(ctx, func(ctx context.Context) error {
qb := r.repository.Group
qb := r.repository.Movie
for _, id := range ids {
if err := qb.Destroy(ctx, id); err != nil {
return err
@@ -309,9 +277,7 @@ func (r *mutationResolver) MoviesDestroy(ctx context.Context, groupIDs []string)
}
for _, id := range ids {
// for backwards compatibility - run both movie and group hooks
r.hookExecutor.ExecutePostHooks(ctx, id, hook.GroupDestroyPost, groupIDs, nil)
r.hookExecutor.ExecutePostHooks(ctx, id, hook.MovieDestroyPost, groupIDs, nil)
r.hookExecutor.ExecutePostHooks(ctx, id, hook.MovieDestroyPost, movieIDs, nil)
}
return true, nil

View File

@@ -12,10 +12,9 @@ import (
func refreshPackageType(typeArg PackageType) {
mgr := manager.GetInstance()
switch typeArg {
case PackageTypePlugin:
if typeArg == PackageTypePlugin {
mgr.RefreshPluginCache()
case PackageTypeScraper:
} else if typeArg == PackageTypeScraper {
mgr.RefreshScraperCache()
}
}

View File

@@ -2,25 +2,16 @@ package api
import (
"context"
"errors"
"fmt"
"slices"
"strconv"
"strings"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/performer"
"github.com/stashapp/stash/pkg/plugin/hook"
"github.com/stashapp/stash/pkg/sliceutil"
"github.com/stashapp/stash/pkg/sliceutil/stringslice"
"github.com/stashapp/stash/pkg/utils"
)
const (
twitterURL = "https://twitter.com"
instagramURL = "https://instagram.com"
)
// used to refetch performer after hooks run
func (r *mutationResolver) getPerformer(ctx context.Context, id int) (ret *models.Performer, err error) {
if err := r.withTxn(ctx, func(ctx context.Context) error {
@@ -41,9 +32,10 @@ func (r *mutationResolver) PerformerCreate(ctx context.Context, input models.Per
// Populate a new performer from the input
newPerformer := models.NewPerformer()
newPerformer.Name = strings.TrimSpace(input.Name)
newPerformer.Name = input.Name
newPerformer.Disambiguation = translator.string(input.Disambiguation)
newPerformer.Aliases = models.NewRelatedStrings(stringslice.UniqueExcludeFold(stringslice.TrimSpace(input.AliasList), newPerformer.Name))
newPerformer.Aliases = models.NewRelatedStrings(input.AliasList)
newPerformer.URL = translator.string(input.URL)
newPerformer.Gender = input.Gender
newPerformer.Ethnicity = translator.string(input.Ethnicity)
newPerformer.Country = translator.string(input.Country)
@@ -52,8 +44,11 @@ func (r *mutationResolver) PerformerCreate(ctx context.Context, input models.Per
newPerformer.FakeTits = translator.string(input.FakeTits)
newPerformer.PenisLength = input.PenisLength
newPerformer.Circumcised = input.Circumcised
newPerformer.CareerLength = translator.string(input.CareerLength)
newPerformer.Tattoos = translator.string(input.Tattoos)
newPerformer.Piercings = translator.string(input.Piercings)
newPerformer.Twitter = translator.string(input.Twitter)
newPerformer.Instagram = translator.string(input.Instagram)
newPerformer.Favorite = translator.bool(input.Favorite)
newPerformer.Rating = input.Rating100
newPerformer.Details = translator.string(input.Details)
@@ -61,22 +56,7 @@ func (r *mutationResolver) PerformerCreate(ctx context.Context, input models.Per
newPerformer.Height = input.HeightCm
newPerformer.Weight = input.Weight
newPerformer.IgnoreAutoTag = translator.bool(input.IgnoreAutoTag)
newPerformer.StashIDs = models.NewRelatedStashIDs(models.StashIDInputs(input.StashIds).ToStashIDs())
newPerformer.URLs = models.NewRelatedStrings([]string{})
if input.URL != nil {
newPerformer.URLs.Add(strings.TrimSpace(*input.URL))
}
if input.Twitter != nil {
newPerformer.URLs.Add(utils.URLFromHandle(strings.TrimSpace(*input.Twitter), twitterURL))
}
if input.Instagram != nil {
newPerformer.URLs.Add(utils.URLFromHandle(strings.TrimSpace(*input.Instagram), instagramURL))
}
if input.Urls != nil {
newPerformer.URLs.Add(stringslice.TrimSpace(input.Urls)...)
}
newPerformer.StashIDs = models.NewRelatedStashIDs(input.StashIds)
var err error
@@ -89,25 +69,6 @@ func (r *mutationResolver) PerformerCreate(ctx context.Context, input models.Per
return nil, fmt.Errorf("converting death date: %w", err)
}
newPerformer.CareerStart, err = translator.datePtr(input.CareerStart)
if err != nil {
return nil, fmt.Errorf("converting career start: %w", err)
}
newPerformer.CareerEnd, err = translator.datePtr(input.CareerEnd)
if err != nil {
return nil, fmt.Errorf("converting career end: %w", err)
}
// if career_start/career_end not provided, parse deprecated career_length
if newPerformer.CareerStart == nil && newPerformer.CareerEnd == nil && input.CareerLength != nil {
start, end, err := models.ParseYearRangeString(*input.CareerLength)
if err != nil {
return nil, fmt.Errorf("could not parse career_length %q: %w", *input.CareerLength, err)
}
newPerformer.CareerStart = start
newPerformer.CareerEnd = end
}
newPerformer.TagIDs, err = translator.relatedIds(input.TagIds)
if err != nil {
return nil, fmt.Errorf("converting tag ids: %w", err)
@@ -130,13 +91,7 @@ func (r *mutationResolver) PerformerCreate(ctx context.Context, input models.Per
return err
}
i := &models.CreatePerformerInput{
Performer: &newPerformer,
// convert json.Numbers to int/float
CustomFields: convertMapJSONNumbers(input.CustomFields),
}
err = qb.Create(ctx, i)
err = qb.Create(ctx, &newPerformer)
if err != nil {
return err
}
@@ -157,120 +112,22 @@ func (r *mutationResolver) PerformerCreate(ctx context.Context, input models.Per
return r.getPerformer(ctx, newPerformer.ID)
}
func validateNoLegacyURLs(translator changesetTranslator) error {
// ensure url/twitter/instagram are not included in the input
if translator.hasField("url") {
return fmt.Errorf("url field must not be included if urls is included")
}
if translator.hasField("twitter") {
return fmt.Errorf("twitter field must not be included if urls is included")
}
if translator.hasField("instagram") {
return fmt.Errorf("instagram field must not be included if urls is included")
}
return nil
}
func (r *mutationResolver) handleLegacyURLs(ctx context.Context, performerID int, legacyURLs legacyPerformerURLs, updatedPerformer *models.PerformerPartial) error {
qb := r.repository.Performer
// we need to be careful with URL/Twitter/Instagram
// treat URL as replacing the first non-Twitter/Instagram URL in the list
// twitter should replace any existing twitter URL
// instagram should replace any existing instagram URL
p, err := qb.Find(ctx, performerID)
func (r *mutationResolver) PerformerUpdate(ctx context.Context, input models.PerformerUpdateInput) (*models.Performer, error) {
performerID, err := strconv.Atoi(input.ID)
if err != nil {
return err
return nil, fmt.Errorf("converting id: %w", err)
}
if err := p.LoadURLs(ctx, qb); err != nil {
return fmt.Errorf("loading performer URLs: %w", err)
translator := changesetTranslator{
inputMap: getUpdateInputMap(ctx),
}
existingURLs := p.URLs.List()
// performer partial URLs should be empty
if legacyURLs.URL.Set {
replaced := false
for i, url := range existingURLs {
if !performer.IsTwitterURL(url) && !performer.IsInstagramURL(url) {
existingURLs[i] = legacyURLs.URL.Value
replaced = true
break
}
}
if !replaced {
existingURLs = append(existingURLs, legacyURLs.URL.Value)
}
}
if legacyURLs.Twitter.Set {
value := utils.URLFromHandle(legacyURLs.Twitter.Value, twitterURL)
found := false
// find and replace the first twitter URL
for i, url := range existingURLs {
if performer.IsTwitterURL(url) {
existingURLs[i] = value
found = true
break
}
}
if !found {
existingURLs = append(existingURLs, value)
}
}
if legacyURLs.Instagram.Set {
found := false
value := utils.URLFromHandle(legacyURLs.Instagram.Value, instagramURL)
// find and replace the first instagram URL
for i, url := range existingURLs {
if performer.IsInstagramURL(url) {
existingURLs[i] = value
found = true
break
}
}
if !found {
existingURLs = append(existingURLs, value)
}
}
updatedPerformer.URLs = &models.UpdateStrings{
Values: existingURLs,
Mode: models.RelationshipUpdateModeSet,
}
return nil
}
type legacyPerformerURLs struct {
URL models.OptionalString
Twitter models.OptionalString
Instagram models.OptionalString
}
func (u *legacyPerformerURLs) AnySet() bool {
return u.URL.Set || u.Twitter.Set || u.Instagram.Set
}
func legacyPerformerURLsFromInput(input models.PerformerUpdateInput, translator changesetTranslator) legacyPerformerURLs {
return legacyPerformerURLs{
URL: translator.optionalString(input.URL, "url"),
Twitter: translator.optionalString(input.Twitter, "twitter"),
Instagram: translator.optionalString(input.Instagram, "instagram"),
}
}
func performerPartialFromInput(input models.PerformerUpdateInput, translator changesetTranslator) (*models.PerformerPartial, error) {
// Populate performer from the input
updatedPerformer := models.NewPerformerPartial()
updatedPerformer.Name = translator.optionalString(input.Name, "name")
updatedPerformer.Disambiguation = translator.optionalString(input.Disambiguation, "disambiguation")
updatedPerformer.URL = translator.optionalString(input.URL, "url")
updatedPerformer.Gender = translator.optionalString((*string)(input.Gender), "gender")
updatedPerformer.Ethnicity = translator.optionalString(input.Ethnicity, "ethnicity")
updatedPerformer.Country = translator.optionalString(input.Country, "country")
@@ -279,31 +136,11 @@ func performerPartialFromInput(input models.PerformerUpdateInput, translator cha
updatedPerformer.FakeTits = translator.optionalString(input.FakeTits, "fake_tits")
updatedPerformer.PenisLength = translator.optionalFloat64(input.PenisLength, "penis_length")
updatedPerformer.Circumcised = translator.optionalString((*string)(input.Circumcised), "circumcised")
// prefer career_start/career_end over deprecated career_length
if translator.hasField("career_start") || translator.hasField("career_end") {
var err error
updatedPerformer.CareerStart, err = translator.optionalDate(input.CareerStart, "career_start")
if err != nil {
return nil, fmt.Errorf("converting career start: %w", err)
}
updatedPerformer.CareerEnd, err = translator.optionalDate(input.CareerEnd, "career_end")
if err != nil {
return nil, fmt.Errorf("converting career end: %w", err)
}
} else if translator.hasField("career_length") && input.CareerLength != nil {
start, end, err := models.ParseYearRangeString(*input.CareerLength)
if err != nil {
return nil, fmt.Errorf("could not parse career_length %q: %w", *input.CareerLength, err)
}
if start != nil {
updatedPerformer.CareerStart = models.NewOptionalDate(*start)
}
if end != nil {
updatedPerformer.CareerEnd = models.NewOptionalDate(*end)
}
}
updatedPerformer.CareerLength = translator.optionalString(input.CareerLength, "career_length")
updatedPerformer.Tattoos = translator.optionalString(input.Tattoos, "tattoos")
updatedPerformer.Piercings = translator.optionalString(input.Piercings, "piercings")
updatedPerformer.Twitter = translator.optionalString(input.Twitter, "twitter")
updatedPerformer.Instagram = translator.optionalString(input.Instagram, "instagram")
updatedPerformer.Favorite = translator.optionalBool(input.Favorite, "favorite")
updatedPerformer.Rating = translator.optionalInt(input.Rating100, "rating100")
updatedPerformer.Details = translator.optionalString(input.Details, "details")
@@ -312,17 +149,6 @@ func performerPartialFromInput(input models.PerformerUpdateInput, translator cha
updatedPerformer.IgnoreAutoTag = translator.optionalBool(input.IgnoreAutoTag, "ignore_auto_tag")
updatedPerformer.StashIDs = translator.updateStashIDs(input.StashIds, "stash_ids")
var err error
if translator.hasField("urls") {
// ensure url/twitter/instagram are not included in the input
if err := validateNoLegacyURLs(translator); err != nil {
return nil, err
}
updatedPerformer.URLs = translator.updateStrings(input.Urls, "urls")
}
updatedPerformer.Birthdate, err = translator.optionalDate(input.Birthdate, "birthdate")
if err != nil {
return nil, fmt.Errorf("converting birthdate: %w", err)
@@ -347,28 +173,6 @@ func performerPartialFromInput(input models.PerformerUpdateInput, translator cha
return nil, fmt.Errorf("converting tag ids: %w", err)
}
updatedPerformer.CustomFields = handleUpdateCustomFields(input.CustomFields)
return &updatedPerformer, nil
}
func (r *mutationResolver) PerformerUpdate(ctx context.Context, input models.PerformerUpdateInput) (*models.Performer, error) {
performerID, err := strconv.Atoi(input.ID)
if err != nil {
return nil, fmt.Errorf("converting id: %w", err)
}
translator := changesetTranslator{
inputMap: getUpdateInputMap(ctx),
}
updatedPerformer, err := performerPartialFromInput(input, translator)
if err != nil {
return nil, err
}
legacyURLs := legacyPerformerURLsFromInput(input, translator)
var imageData []byte
imageIncluded := translator.hasField("image")
if input.Image != nil {
@@ -382,38 +186,11 @@ func (r *mutationResolver) PerformerUpdate(ctx context.Context, input models.Per
if err := r.withTxn(ctx, func(ctx context.Context) error {
qb := r.repository.Performer
if legacyURLs.AnySet() {
if err := r.handleLegacyURLs(ctx, performerID, legacyURLs, updatedPerformer); err != nil {
return err
}
}
if updatedPerformer.Aliases != nil {
p, err := qb.Find(ctx, performerID)
if err != nil {
return err
}
if p != nil {
if err := p.LoadAliases(ctx, qb); err != nil {
return err
}
effectiveAliases := updatedPerformer.Aliases.Apply(p.Aliases.List())
name := p.Name
if updatedPerformer.Name.Set {
name = updatedPerformer.Name.Value
}
sanitized := stringslice.UniqueExcludeFold(effectiveAliases, name)
updatedPerformer.Aliases.Values = sanitized
updatedPerformer.Aliases.Mode = models.RelationshipUpdateModeSet
}
}
if err := performer.ValidateUpdate(ctx, performerID, *updatedPerformer, qb); err != nil {
if err := performer.ValidateUpdate(ctx, performerID, updatedPerformer, qb); err != nil {
return err
}
_, err = qb.UpdatePartial(ctx, performerID, *updatedPerformer)
_, err = qb.UpdatePartial(ctx, performerID, updatedPerformer)
if err != nil {
return err
}
@@ -448,7 +225,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.Gender = translator.optionalString((*string)(input.Gender), "gender")
updatedPerformer.Ethnicity = translator.optionalString(input.Ethnicity, "ethnicity")
updatedPerformer.Country = translator.optionalString(input.Country, "country")
@@ -457,31 +234,11 @@ func (r *mutationResolver) BulkPerformerUpdate(ctx context.Context, input BulkPe
updatedPerformer.FakeTits = translator.optionalString(input.FakeTits, "fake_tits")
updatedPerformer.PenisLength = translator.optionalFloat64(input.PenisLength, "penis_length")
updatedPerformer.Circumcised = translator.optionalString((*string)(input.Circumcised), "circumcised")
// prefer career_start/career_end over deprecated career_length
if translator.hasField("career_start") || translator.hasField("career_end") {
updatedPerformer.CareerStart, err = translator.optionalDate(input.CareerStart, "career_start")
if err != nil {
return nil, fmt.Errorf("converting career start: %w", err)
}
updatedPerformer.CareerEnd, err = translator.optionalDate(input.CareerEnd, "career_end")
if err != nil {
return nil, fmt.Errorf("converting career end: %w", err)
}
} else if translator.hasField("career_length") && input.CareerLength != nil {
start, end, err := models.ParseYearRangeString(*input.CareerLength)
if err != nil {
return nil, fmt.Errorf("could not parse career_length %q: %w", *input.CareerLength, err)
}
if start != nil {
updatedPerformer.CareerStart = models.NewOptionalDate(*start)
}
if end != nil {
updatedPerformer.CareerEnd = models.NewOptionalDate(*end)
}
}
updatedPerformer.CareerLength = translator.optionalString(input.CareerLength, "career_length")
updatedPerformer.Tattoos = translator.optionalString(input.Tattoos, "tattoos")
updatedPerformer.Piercings = translator.optionalString(input.Piercings, "piercings")
updatedPerformer.Twitter = translator.optionalString(input.Twitter, "twitter")
updatedPerformer.Instagram = translator.optionalString(input.Instagram, "instagram")
updatedPerformer.Favorite = translator.optionalBool(input.Favorite, "favorite")
updatedPerformer.Rating = translator.optionalInt(input.Rating100, "rating100")
updatedPerformer.Details = translator.optionalString(input.Details, "details")
@@ -489,21 +246,6 @@ 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("urls") {
// ensure url/twitter/instagram are not included in the input
if err := validateNoLegacyURLs(translator); err != nil {
return nil, err
}
updatedPerformer.URLs = translator.updateStringsBulk(input.Urls, "urls")
}
legacyURLs := legacyPerformerURLs{
URL: translator.optionalString(input.URL, "url"),
Twitter: translator.optionalString(input.Twitter, "twitter"),
Instagram: translator.optionalString(input.Instagram, "instagram"),
}
updatedPerformer.Birthdate, err = translator.optionalDate(input.Birthdate, "birthdate")
if err != nil {
return nil, fmt.Errorf("converting birthdate: %w", err)
@@ -528,10 +270,6 @@ func (r *mutationResolver) BulkPerformerUpdate(ctx context.Context, input BulkPe
return nil, fmt.Errorf("converting tag ids: %w", err)
}
if input.CustomFields != nil {
updatedPerformer.CustomFields = handleUpdateCustomFields(*input.CustomFields)
}
ret := []*models.Performer{}
// Start the transaction and save the performers
@@ -539,12 +277,6 @@ func (r *mutationResolver) BulkPerformerUpdate(ctx context.Context, input BulkPe
qb := r.repository.Performer
for _, performerID := range performerIDs {
if legacyURLs.AnySet() {
if err := r.handleLegacyURLs(ctx, performerID, legacyURLs, &updatedPerformer); err != nil {
return err
}
}
if err := performer.ValidateUpdate(ctx, performerID, updatedPerformer, qb); err != nil {
return err
}
@@ -620,87 +352,3 @@ func (r *mutationResolver) PerformersDestroy(ctx context.Context, performerIDs [
return true, nil
}
func (r *mutationResolver) PerformerMerge(ctx context.Context, input PerformerMergeInput) (*models.Performer, error) {
srcIDs, err := stringslice.StringSliceToIntSlice(input.Source)
if err != nil {
return nil, fmt.Errorf("converting source ids: %w", err)
}
// ensure source ids are unique
srcIDs = sliceutil.AppendUniques(nil, srcIDs)
destID, err := strconv.Atoi(input.Destination)
if err != nil {
return nil, fmt.Errorf("converting destination id: %w", err)
}
// ensure destination is not in source list
if slices.Contains(srcIDs, destID) {
return nil, errors.New("destination performer cannot be in source list")
}
var values *models.PerformerPartial
var imageData []byte
if input.Values != nil {
translator := changesetTranslator{
inputMap: getNamedUpdateInputMap(ctx, "input.values"),
}
values, err = performerPartialFromInput(*input.Values, translator)
if err != nil {
return nil, err
}
legacyURLs := legacyPerformerURLsFromInput(*input.Values, translator)
if legacyURLs.AnySet() {
return nil, errors.New("merging legacy performer URLs is not supported")
}
if input.Values.Image != nil {
var err error
imageData, err = utils.ProcessImageInput(ctx, *input.Values.Image)
if err != nil {
return nil, fmt.Errorf("processing cover image: %w", err)
}
}
} else {
v := models.NewPerformerPartial()
values = &v
}
var dest *models.Performer
if err := r.withTxn(ctx, func(ctx context.Context) error {
qb := r.repository.Performer
dest, err = qb.Find(ctx, destID)
if err != nil {
return fmt.Errorf("finding destination performer ID %d: %w", destID, err)
}
// ensure source performers exist
if _, err := qb.FindMany(ctx, srcIDs); err != nil {
return fmt.Errorf("finding source performers: %w", err)
}
if _, err := qb.UpdatePartial(ctx, destID, *values); err != nil {
return fmt.Errorf("updating performer: %w", err)
}
if err := qb.Merge(ctx, srcIDs, destID); err != nil {
return fmt.Errorf("merging performers: %w", err)
}
if len(imageData) > 0 {
if err := qb.UpdateImage(ctx, destID, imageData); err != nil {
return err
}
}
return nil
}); err != nil {
return nil, err
}
return dest, nil
}

View File

@@ -103,7 +103,7 @@ func (r *mutationResolver) SetPluginsEnabled(ctx context.Context, enabledMap map
}
}
c.SetInterface(config.DisabledPlugins, newDisabled)
c.Set(config.DisabledPlugins, newDisabled)
if err := c.Write(); err != nil {
return false, err

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