mirror of
https://github.com/stashapp/stash.git
synced 2026-06-10 21:42:03 -05:00
Compare commits
4 Commits
latest_dev
...
issue-5298
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef5b52e80d | ||
|
|
6cb579eeba | ||
|
|
a22f5ac64a | ||
|
|
ed034d0175 |
21
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
21
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -4,17 +4,8 @@ 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
|
||||
value: |
|
||||
Thanks for taking the time to fill out this bug report!
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
@@ -56,13 +47,13 @@ body:
|
||||
placeholder: (e.g. v0.28.1)
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
- type: input
|
||||
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
|
||||
If this is an issue that occurs when using the Stash interface, please provide details of the device/browser used which presents the reported issue.
|
||||
placeholder: (e.g. Firefox 97 (64-bit) on Windows 11)
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
@@ -70,4 +61,4 @@ body:
|
||||
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
|
||||
render: shell
|
||||
3
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
3
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
@@ -2,9 +2,6 @@ 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:
|
||||
|
||||
18
.github/PULL_REQUEST_TEMPLATE/BugFix.md
vendored
Normal file
18
.github/PULL_REQUEST_TEMPLATE/BugFix.md
vendored
Normal 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
|
||||
|
||||
17
.github/PULL_REQUEST_TEMPLATE/Feature.md
vendored
Normal file
17
.github/PULL_REQUEST_TEMPLATE/Feature.md
vendored
Normal 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
|
||||
39
.github/pull_request_template.md
vendored
39
.github/pull_request_template.md
vendored
@@ -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. -->
|
||||
|
||||
28
.github/workflows/build-compiler.yml
vendored
28
.github/workflows/build-compiler.yml
vendored
@@ -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
|
||||
280
.github/workflows/build.yml
vendored
280
.github/workflows/build.yml
vendored
@@ -2,7 +2,7 @@ name: Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
branches:
|
||||
- develop
|
||||
- master
|
||||
- 'releases/**'
|
||||
@@ -15,175 +15,50 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
COMPILER_IMAGE: ghcr.io/stashapp/compiler:14
|
||||
COMPILER_IMAGE: stashapp/compiler:12
|
||||
|
||||
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-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
- name: Checkout
|
||||
run: git fetch --prune --unshallow --tags
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v6
|
||||
uses: actions/setup-go@v5
|
||||
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
|
||||
- name: Pull compiler image
|
||||
run: docker pull $COMPILER_IMAGE
|
||||
|
||||
- name: Cache node modules
|
||||
uses: actions/cache@v3
|
||||
env:
|
||||
cache-name: cache-node_modules
|
||||
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/pnpm-lock.yaml') }}
|
||||
|
||||
- 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/pnpm-lock.yaml', '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,50 +67,45 @@ 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 CI=1 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: Zip UI
|
||||
run: docker exec -t build /bin/bash -c "make zip-ui"
|
||||
|
||||
- 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
|
||||
@@ -246,7 +116,7 @@ jobs:
|
||||
- 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@v4
|
||||
with:
|
||||
name: stash-win.exe
|
||||
path: dist/stash-win.exe
|
||||
@@ -254,23 +124,15 @@ 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@v4
|
||||
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@v4
|
||||
with:
|
||||
name: stash-linux
|
||||
path: dist/stash-linux
|
||||
@@ -278,14 +140,14 @@ jobs:
|
||||
- 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
|
||||
uses: actions/upload-artifact@v4
|
||||
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' }}
|
||||
@@ -335,7 +197,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 +213,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
|
||||
|
||||
61
.github/workflows/golangci-lint.yml
vendored
61
.github/workflows/golangci-lint.yml
vendored
@@ -9,24 +9,65 @@ on:
|
||||
- 'releases/**'
|
||||
pull_request:
|
||||
|
||||
env:
|
||||
COMPILER_IMAGE: stashapp/compiler:12
|
||||
|
||||
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
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
- name: Checkout
|
||||
run: git fetch --prune --unshallow --tags
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: 'go.mod'
|
||||
|
||||
# generate-backend runs natively (just go generate + touch-ui) — no Docker needed
|
||||
- name: Generate Backend
|
||||
run: make generate-backend
|
||||
- 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
|
||||
|
||||
- name: 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@v6
|
||||
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
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -63,4 +63,4 @@ node_modules
|
||||
/phasher
|
||||
dist
|
||||
.DS_Store
|
||||
/.local*
|
||||
/.local*
|
||||
|
||||
169
.golangci.yml
169
.golangci.yml
@@ -1,100 +1,87 @@
|
||||
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
|
||||
- copyloopvar
|
||||
- dogsled
|
||||
- errchkjson
|
||||
- errorlint
|
||||
# - exhaustive
|
||||
- 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
|
||||
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
|
||||
|
||||
64
Makefile
64
Makefile
@@ -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,7 +275,7 @@ generate: generate-backend generate-ui
|
||||
|
||||
.PHONY: generate-ui
|
||||
generate-ui:
|
||||
cd ui/v2.5 && pnpm run gqlgen
|
||||
cd ui/v2.5 && npm run gqlgen
|
||||
|
||||
.PHONY: generate-backend
|
||||
generate-backend: touch-ui
|
||||
@@ -370,6 +357,9 @@ 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
|
||||
@@ -379,7 +369,7 @@ ui: ui-only generate-login-locale
|
||||
|
||||
.PHONY: ui-only
|
||||
ui-only: ui-env
|
||||
cd ui/v2.5 && pnpm run build
|
||||
cd ui/v2.5 && npm run build
|
||||
|
||||
.PHONY: zip-ui
|
||||
zip-ui:
|
||||
@@ -388,23 +378,23 @@ zip-ui:
|
||||
|
||||
.PHONY: ui-start
|
||||
ui-start: ui-env
|
||||
cd ui/v2.5 && pnpm run start --host
|
||||
cd ui/v2.5 && npm run start -- --host
|
||||
|
||||
.PHONY: fmt-ui
|
||||
fmt-ui:
|
||||
cd ui/v2.5 && pnpm run format
|
||||
cd ui/v2.5 && npm run format
|
||||
|
||||
# runs all of the frontend PR-acceptance steps
|
||||
.PHONY: validate-ui
|
||||
validate-ui:
|
||||
cd ui/v2.5 && pnpm run validate
|
||||
cd ui/v2.5 && npm 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; \
|
||||
npm run prettier -- --write $$files; \
|
||||
fi
|
||||
|
||||
# does not run tsc checks, as they are slow
|
||||
@@ -413,9 +403,9 @@ validate-ui-quick:
|
||||
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
|
||||
if [ -n "$$tsfiles" ]; then npm run eslint -- $$tsfiles; fi && \
|
||||
if [ -n "$$scssfiles" ]; then npm run stylelint -- $$scssfiles; fi && \
|
||||
if [ -n "$$prettyfiles" ]; then npm run prettier -- --check $$prettyfiles; fi
|
||||
|
||||
# runs all of the backend PR-acceptance steps
|
||||
.PHONY: validate-backend
|
||||
@@ -446,14 +436,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
|
||||
111
README.md
111
README.md
@@ -1,44 +1,40 @@
|
||||
# Stash
|
||||
|
||||
[](https://github.com/stashapp/stash/actions/workflows/build.yml)
|
||||
[](https://hub.docker.com/r/stashapp/stash 'DockerHub')
|
||||
[](https://hub.docker.com/r/stashapp/stash 'DockerHub')
|
||||
[](https://github.com/sponsors/stashapp)
|
||||
[](https://opencollective.com/stashapp)
|
||||
[](https://goreportcard.com/report/github.com/stashapp/stash)
|
||||
[](https://discord.gg/2TsNFKt)
|
||||
[](https://github.com/stashapp/stash/releases/latest)
|
||||
[](https://translate.codeberg.org/engage/stash/)
|
||||
[](https://github.com/stashapp/stash/labels/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 diverse content collection, catering to both your SFW and NSFW needs.**
|
||||
|
||||

|
||||
|
||||
- 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 access the in-app manual from within the application (also available at [docs.stashapp.cc/in-app-manual](https://docs.stashapp.cc/in-app-manual)).
|
||||
|
||||
## Installing Stash
|
||||
# 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.
|
||||
#### 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.
|
||||
|
||||
#### Mac Users:
|
||||
|
||||
As of version 0.29.0, Stash requires _macOS 11 Big Sur_ or later.
|
||||
Stash can still be run through docker on older versions of macOS.
|
||||
|
||||
<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
|
||||
:---:|:---:|:---:|:---:
|
||||
@@ -46,7 +42,7 @@ Step-by-step instructions are available at [docs.stashapp.cc/installation](https
|
||||
|
||||
Download links for other platforms and architectures are available on the [Releases](https://github.com/stashapp/stash/releases) page.
|
||||
|
||||
### First Run
|
||||
## First Run
|
||||
|
||||
#### Windows/macOS Users: Security Prompt
|
||||
|
||||
@@ -59,9 +55,9 @@ On Windows or macOS, running the app might present a security prompt since the a
|
||||
|
||||
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
|
||||
## Quickstart Guide
|
||||
|
||||
Stash is a web-based application. Once the application is running, the interface is available (by default) from `http://localhost:9999`.
|
||||
|
||||
@@ -69,51 +65,46 @@ On first run, Stash will prompt you for some configuration options and media dir
|
||||
|
||||
Stash can pull metadata (performers, tags, descriptions, studios, and more) directly from many sites through the use of [scrapers](https://github.com/stashapp/stash/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.
|
||||
- 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
|
||||
|
||||
[](https://translate.codeberg.org/engage/stash/)
|
||||
|
||||
Stash is available in 32 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, you can make an account at [Codeberg's Weblate](https://translate.codeberg.org/projects/stash/stash/) to contribute to new or existing languages. Thanks!
|
||||
|
||||
The badge below shows the current translation status of Stash across all supported languages:
|
||||
|
||||
[](https://translate.codeberg.org/engage/stash/)
|
||||
|
||||
# Support & Resources
|
||||
|
||||
Need help or want to get involved? Start with the documentation, then reach out to the community if you need further assistance.
|
||||
|
||||
### Documentation
|
||||
|
||||
- [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-to’s and tips.
|
||||
- Documentation
|
||||
- Official docs: https://docs.stashapp.cc - official guides guides and troubleshooting.
|
||||
- In-app manual: press <kbd>Shift</kbd> + <kbd>?</kbd> in the app or view the manual online: https://docs.stashapp.cc/in-app-manual.
|
||||
- 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-to’s and tips.
|
||||
|
||||
### Community & Discussion
|
||||
- Community & discussion
|
||||
- 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 - Reddit-style community space.
|
||||
|
||||
- [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.
|
||||
- Community scrapers & plugins
|
||||
- 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/
|
||||
|
||||
### Community Scrapers & Plugins
|
||||
# For Developers
|
||||
|
||||
- [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/)
|
||||
Pull requests are welcome!
|
||||
|
||||
## Architecture
|
||||
|
||||
You can find an overview of Stash's architecture in the [ARCHITECTURE.md](docs/ARCHITECTURE.md) document.
|
||||
|
||||
## Contributing
|
||||
|
||||
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!
|
||||
|
||||
[](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.
|
||||
|
||||
@@ -5,39 +5,20 @@ 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 {
|
||||
ffvideoFile, err := ffp.NewVideoFile(inputfile)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -65,24 +46,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")
|
||||
@@ -104,7 +67,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)
|
||||
}
|
||||
@@ -124,5 +87,4 @@ func main() {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -76,10 +76,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))
|
||||
@@ -148,7 +144,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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ ARG STASH_VERSION
|
||||
RUN BUILD_DATE=$(date +"%Y-%m-%d %H:%M:%S") make ui-only
|
||||
|
||||
# Build Backend
|
||||
FROM golang:1.25.9-alpine AS backend
|
||||
FROM golang:1.24.3-alpine AS backend
|
||||
RUN apk add --no-cache make alpine-sdk
|
||||
WORKDIR /stash
|
||||
COPY ./go* ./*.go Makefile gqlgen.yml .gqlgenc.yml /stash/
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# This dockerfile should be built with `make docker-cuda-build` from the stash root.
|
||||
ARG CUDA_VERSION=13.2.1
|
||||
ARG CUDA_VERSION=12.8.0
|
||||
|
||||
# Build Frontend
|
||||
FROM node:24-alpine AS frontend
|
||||
FROM node:20-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/
|
||||
@@ -19,7 +19,7 @@ ARG STASH_VERSION
|
||||
RUN BUILD_DATE=$(date +"%Y-%m-%d %H:%M:%S") make ui-only
|
||||
|
||||
# Build Backend
|
||||
FROM golang:1.25.9-bullseye AS backend
|
||||
FROM golang:1.24.3-bullseye AS backend
|
||||
RUN apt update && apt install -y build-essential golang
|
||||
WORKDIR /stash
|
||||
COPY ./go* ./*.go Makefile gqlgen.yml .gqlgenc.yml /stash/
|
||||
|
||||
@@ -12,7 +12,7 @@ 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 \
|
||||
RUN apk add --no-cache ca-certificates python3 py3-requests py3-requests-toolbelt py3-lxml py3-pip ffmpeg tzdata vips vips-tools \
|
||||
&& pip install --break-system-packages mechanicalsoup cloudscraper stashapp-tools
|
||||
ENV STASH_CONFIG_FILE=/root/.stash/config.yml
|
||||
|
||||
|
||||
1
docker/compiler/.gitignore
vendored
Normal file
1
docker/compiler/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
*.sdk.tar.*
|
||||
@@ -1,86 +1,82 @@
|
||||
### 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.24.3
|
||||
|
||||
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 {} + && \
|
||||
ln -s libc++.a libstdc++.a && \
|
||||
ln -s libc++.so libstdc++.so
|
||||
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_24.x nodistro main" | tee /etc/apt/sources.list.d/nodesource.list
|
||||
|
||||
### BUILDER
|
||||
FROM golang:1.25.9 AS builder
|
||||
ENV PATH=/opt/osx-ndk-x86/bin:$PATH
|
||||
|
||||
# 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 apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
git make tar bash nodejs 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/*;
|
||||
|
||||
# pnpm install with npm
|
||||
RUN npm install -g pnpm
|
||||
|
||||
# git for getting hash
|
||||
# make and bash for building
|
||||
# FreeBSD cross-compilation setup
|
||||
# https://github.com/smartmontools/docker-build/blob/6b8c92560d17d325310ba02d9f5a4b250cb0764a/Dockerfile#L66
|
||||
ENV FREEBSD_VERSION 13.4
|
||||
ENV FREEBSD_DOWNLOAD_URL http://ftp.plusline.de/FreeBSD/releases/amd64/${FREEBSD_VERSION}-RELEASE/base.txz
|
||||
ENV FREEBSD_SHA 8e13b0a93daba349b8d28ad246d7beb327659b2ef4fe44d89f447392daec5a7c
|
||||
|
||||
# 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/*;
|
||||
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
|
||||
|
||||
# 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
|
||||
|
||||
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
|
||||
|
||||
ENV PATH /opt/osx-ndk-x86/bin:$PATH
|
||||
|
||||
RUN mkdir -p /root/.ssh && \
|
||||
chmod 0700 /root/.ssh && \
|
||||
ssh-keyscan github.com > /root/.ssh/known_hosts
|
||||
|
||||
# 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
|
||||
|
||||
@@ -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=12
|
||||
|
||||
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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -7,7 +7,7 @@ Only `docker` is required. For the most part your understanding of the technolog
|
||||
Installation instructions are available below, and if your distributions'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.
|
||||
On some distributions, `docker compose` is shipped seperately, usually as `docker-cli-compose`. docker-compose is not recommended.
|
||||
|
||||
### Get the docker-compose.yml 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.
|
||||
@@ -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)
|
||||
@@ -1,48 +1,35 @@
|
||||
# Contributing to Stash
|
||||
## Goals and design vision
|
||||
|
||||
## AI Usage Policy
|
||||
The goal of stash is to be:
|
||||
- an application for organising and viewing adult 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
|
||||
|
||||
Please see our [AI Usage Policy](/docs/AI_POLICY.md) for guidelines on the use of AI in contributions to this project.
|
||||
|
||||
## Issues
|
||||
|
||||
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
|
||||
The core stash system is not intended for:
|
||||
- managing downloading of content
|
||||
- managing content on external websites
|
||||
- publically 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.
|
||||
- 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.
|
||||
- features are easy to add and difficult to remove. Large superfluous features should be scrutinised and avoided where possible (eg DLNA, filename parser). Such features should be considered for third-party plugins instead.
|
||||
|
||||
## 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.
|
||||
|
||||
## 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.
|
||||
|
||||
### 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.
|
||||
|
||||
**In case you were unable to follow any of the above include an explanation as to why not in your Pull Request.**
|
||||
|
||||
@@ -118,8 +118,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/`.
|
||||
|
||||
|
||||
52
go.mod
52
go.mod
@@ -1,23 +1,20 @@
|
||||
module github.com/stashapp/stash
|
||||
|
||||
go 1.25.0
|
||||
go 1.24.3
|
||||
|
||||
require (
|
||||
github.com/99designs/gqlgen v0.17.73
|
||||
github.com/WithoutPants/sortorder v0.0.0-20230616003020-921c9ef69552
|
||||
github.com/Yamashou/gqlgenc v0.32.1
|
||||
github.com/anacrolix/dms v1.2.2
|
||||
github.com/antchfx/htmlquery v1.3.5
|
||||
github.com/antchfx/htmlquery v1.3.0
|
||||
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/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/cors v1.2.1
|
||||
github.com/go-chi/httplog v0.3.1
|
||||
@@ -33,6 +30,7 @@ require (
|
||||
github.com/jinzhu/copier v0.4.0
|
||||
github.com/jmoiron/sqlx v1.4.0
|
||||
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
|
||||
@@ -46,11 +44,10 @@ require (
|
||||
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/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/stretchr/testify v1.10.0
|
||||
github.com/tidwall/gjson v1.16.0
|
||||
github.com/vearutop/statigz v1.4.0
|
||||
github.com/vektah/dataloaden v0.3.0
|
||||
@@ -58,12 +55,12 @@ require (
|
||||
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/crypto v0.45.0
|
||||
golang.org/x/image v0.18.0
|
||||
golang.org/x/net v0.47.0
|
||||
golang.org/x/sys v0.38.0
|
||||
golang.org/x/term v0.37.0
|
||||
golang.org/x/text v0.31.0
|
||||
golang.org/x/time v0.10.0
|
||||
gopkg.in/guregu/null.v4 v4.0.0
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
||||
@@ -72,26 +69,20 @@ require (
|
||||
|
||||
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/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/chromedp/sysutil v1.0.0 // indirect
|
||||
github.com/coder/websocket v1.8.12 // indirect
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.7 // 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/gobwas/httphead v0.1.0 // indirect
|
||||
github.com/gobwas/pool v0.2.1 // indirect
|
||||
github.com/gobwas/ws v1.4.0 // indirect
|
||||
github.com/gobwas/ws v1.3.0 // indirect
|
||||
github.com/goccy/go-yaml v1.18.0 // indirect
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
||||
github.com/google/pprof v0.0.0-20230207041349-798e818bf904 // indirect
|
||||
@@ -99,9 +90,10 @@ require (
|
||||
github.com/hashicorp/go-multierror v1.1.1 // 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/josharian/intern v1.0.0 // indirect
|
||||
github.com/knadh/koanf/maps v0.1.2 // indirect
|
||||
github.com/magiconair/properties v1.8.7 // indirect
|
||||
github.com/mailru/easyjson v0.7.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
|
||||
@@ -114,9 +106,6 @@ 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
|
||||
@@ -129,13 +118,12 @@ require (
|
||||
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
|
||||
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.29.0 // indirect
|
||||
golang.org/x/sync v0.18.0 // indirect
|
||||
golang.org/x/tools v0.38.0 // indirect
|
||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
159
go.sum
159
go.sum
@@ -81,15 +81,14 @@ 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/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/cCs=
|
||||
github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
|
||||
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/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=
|
||||
@@ -117,12 +116,13 @@ 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=
|
||||
@@ -174,18 +174,6 @@ github.com/dop251/goja_nodejs v0.0.0-20211022123610-8dd9abb0616d/go.mod h1:DngW8
|
||||
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,8 +188,6 @@ 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/fsnotify/fsnotify v1.5.1/go.mod h1:T3375wBYaZdLLcVNkcVbzGHY7f1l/uK5T5Ai1i3InKU=
|
||||
@@ -220,8 +206,6 @@ 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=
|
||||
@@ -240,8 +224,9 @@ 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/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/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/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
@@ -301,7 +286,6 @@ 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/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
@@ -395,6 +379,8 @@ 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/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,12 +390,12 @@ 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/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=
|
||||
@@ -446,6 +432,8 @@ github.com/lyft/protoc-gen-star v0.5.3/go.mod h1:V0xaHgaf5oCCqmcxYcWiDfTiKsZsRc8
|
||||
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/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=
|
||||
@@ -532,19 +520,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/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/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
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=
|
||||
@@ -558,8 +540,6 @@ github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd h1:CmH9+J6ZSsIjUK
|
||||
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=
|
||||
@@ -610,8 +590,8 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
|
||||
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/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/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
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=
|
||||
@@ -635,14 +615,10 @@ github.com/vektah/gqlparser/v2 v2.5.27/go.mod h1:D1/VCZtV3LPnQrcPBeR/q5jkSQIPti0
|
||||
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/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,8 +643,6 @@ 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=
|
||||
@@ -690,12 +664,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.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
||||
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
||||
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 +679,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.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ=
|
||||
golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E=
|
||||
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=
|
||||
@@ -737,12 +707,8 @@ 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.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/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/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
|
||||
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
|
||||
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 +757,9 @@ 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.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.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws=
|
||||
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
||||
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=
|
||||
@@ -828,13 +789,8 @@ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJ
|
||||
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/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.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
||||
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
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=
|
||||
@@ -913,27 +869,16 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||
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.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.4.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.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
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.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.4.0/go.mod h1:9P2UbLfCdcvo3p/nzKvsmas4TnlujnuoV9hGgYzW1lQ=
|
||||
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
|
||||
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
|
||||
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=
|
||||
@@ -944,14 +889,9 @@ 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.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/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/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
||||
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
||||
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=
|
||||
@@ -1016,11 +956,8 @@ 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.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/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/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
|
||||
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
|
||||
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=
|
||||
|
||||
@@ -140,8 +140,4 @@ models:
|
||||
fields:
|
||||
plugins:
|
||||
resolver: true
|
||||
Performer:
|
||||
fields:
|
||||
career_length:
|
||||
resolver: true
|
||||
|
||||
|
||||
@@ -51,7 +51,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"
|
||||
@@ -166,12 +165,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!
|
||||
@@ -374,7 +367,6 @@ 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
|
||||
@@ -423,14 +415,8 @@ 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!
|
||||
@@ -584,8 +570,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!
|
||||
|
||||
@@ -184,18 +184,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 {
|
||||
@@ -299,16 +287,6 @@ type ConfigGeneralResult {
|
||||
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 +319,6 @@ input ConfigDisableDropdownCreateInput {
|
||||
tag: Boolean
|
||||
studio: Boolean
|
||||
movie: Boolean
|
||||
gallery: Boolean
|
||||
}
|
||||
|
||||
enum ImageLightboxDisplayMode {
|
||||
@@ -417,9 +394,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,7 +419,6 @@ type ConfigDisableDropdownCreate {
|
||||
tag: Boolean!
|
||||
studio: Boolean!
|
||||
movie: Boolean!
|
||||
gallery: Boolean!
|
||||
}
|
||||
|
||||
type ConfigInterfaceResult {
|
||||
@@ -494,9 +467,6 @@ type ConfigInterfaceResult {
|
||||
customLocales: String
|
||||
customLocalesEnabled: Boolean
|
||||
|
||||
"When true, disables all customizations (plugins, CSS, JavaScript, locales) for troubleshooting"
|
||||
disableCustomizations: Boolean
|
||||
|
||||
"Interface language"
|
||||
language: String
|
||||
|
||||
|
||||
@@ -6,19 +6,13 @@ 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!]!
|
||||
|
||||
mod_time: Time!
|
||||
|
||||
created_at: Time!
|
||||
@@ -93,8 +87,6 @@ type VideoFile implements BaseFile {
|
||||
frame_rate: Float!
|
||||
bit_rate: Int!
|
||||
|
||||
scenes: [Scene!]!
|
||||
|
||||
created_at: Time!
|
||||
updated_at: Time!
|
||||
}
|
||||
@@ -120,8 +112,6 @@ type ImageFile implements BaseFile {
|
||||
width: Int!
|
||||
height: Int!
|
||||
|
||||
images: [Image!]!
|
||||
|
||||
created_at: Time!
|
||||
updated_at: Time!
|
||||
}
|
||||
@@ -145,8 +135,6 @@ type GalleryFile implements BaseFile {
|
||||
fingerprint(type: String!): String
|
||||
fingerprints: [Fingerprint!]!
|
||||
|
||||
galleries: [Gallery!]!
|
||||
|
||||
created_at: Time!
|
||||
updated_at: Time!
|
||||
}
|
||||
@@ -165,7 +153,7 @@ input MoveFilesInput {
|
||||
|
||||
input SetFingerprintsInput {
|
||||
type: String!
|
||||
"a null value will remove the fingerprint"
|
||||
"an null value will remove the fingerprint"
|
||||
value: String
|
||||
}
|
||||
|
||||
|
||||
@@ -75,45 +75,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]
|
||||
stash_id: String
|
||||
modifier: CriterionModifier!
|
||||
}
|
||||
|
||||
@@ -152,15 +126,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,8 +146,6 @@ 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"
|
||||
@@ -189,9 +156,6 @@ input PerformerFilterType {
|
||||
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"
|
||||
@@ -222,8 +186,6 @@ input PerformerFilterType {
|
||||
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"
|
||||
@@ -249,9 +211,9 @@ input SceneMarkerFilterType {
|
||||
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
|
||||
@@ -286,8 +248,8 @@ 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"
|
||||
@@ -330,11 +292,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"
|
||||
@@ -375,8 +332,6 @@ input SceneFilterType {
|
||||
markers_filter: SceneMarkerFilterType
|
||||
"Filter by related files that meet this criteria"
|
||||
files_filter: FileFilterType
|
||||
|
||||
custom_fields: [CustomFieldCriterionInput!]
|
||||
}
|
||||
|
||||
input MovieFilterType {
|
||||
@@ -459,16 +414,11 @@ input GroupFilterType {
|
||||
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,9 +432,6 @@ 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"
|
||||
@@ -499,8 +446,6 @@ input StudioFilterType {
|
||||
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"
|
||||
@@ -511,22 +456,16 @@ 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 {
|
||||
@@ -603,10 +542,6 @@ input GalleryFilterType {
|
||||
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 {
|
||||
@@ -665,41 +600,24 @@ 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 +632,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"
|
||||
@@ -773,8 +689,6 @@ input ImageFilterType {
|
||||
tags_filter: TagFilterType
|
||||
"Filter by related files that meet this criteria"
|
||||
files_filter: FileFilterType
|
||||
"Filter by custom fields"
|
||||
custom_fields: [CustomFieldCriterionInput!]
|
||||
}
|
||||
|
||||
input FileFilterType {
|
||||
@@ -792,8 +706,8 @@ input FileFilterType {
|
||||
"Filter by modification time"
|
||||
mod_time: TimestampCriterionInput
|
||||
|
||||
"Filter files by duplication criteria (only phash applies to files)"
|
||||
duplicated: FileDuplicationCriterionInput
|
||||
"Filter files that have an exact match available"
|
||||
duplicated: PHashDuplicationCriterionInput
|
||||
|
||||
"find files based on hash"
|
||||
hashes: [FingerprintFilterInput!]
|
||||
@@ -824,7 +738,6 @@ input FolderFilterType {
|
||||
NOT: FolderFilterType
|
||||
|
||||
path: StringCriterionInput
|
||||
basename: StringCriterionInput
|
||||
|
||||
parent_folder: HierarchicalMultiCriterionInput
|
||||
zip_file: MultiCriterionInput
|
||||
@@ -933,7 +846,7 @@ input GenderCriterionInput {
|
||||
}
|
||||
|
||||
input CircumcisionCriterionInput {
|
||||
value: [CircumcisedEnum!]
|
||||
value: [CircumisedEnum!]
|
||||
modifier: CriterionModifier!
|
||||
}
|
||||
|
||||
|
||||
@@ -32,7 +32,6 @@ type Gallery {
|
||||
cover: Image
|
||||
|
||||
paths: GalleryPathsType! # Resolver
|
||||
custom_fields: Map!
|
||||
image(index: Int!): Image!
|
||||
}
|
||||
|
||||
@@ -51,8 +50,6 @@ input GalleryCreateInput {
|
||||
studio_id: ID
|
||||
tag_ids: [ID!]
|
||||
performer_ids: [ID!]
|
||||
|
||||
custom_fields: Map
|
||||
}
|
||||
|
||||
input GalleryUpdateInput {
|
||||
@@ -74,8 +71,6 @@ input GalleryUpdateInput {
|
||||
performer_ids: [ID!]
|
||||
|
||||
primary_file_id: ID
|
||||
|
||||
custom_fields: CustomFieldsInput
|
||||
}
|
||||
|
||||
input BulkGalleryUpdateInput {
|
||||
@@ -94,8 +89,6 @@ input BulkGalleryUpdateInput {
|
||||
studio_id: ID
|
||||
tag_ids: BulkUpdateIds
|
||||
performer_ids: BulkUpdateIds
|
||||
|
||||
custom_fields: CustomFieldsInput
|
||||
}
|
||||
|
||||
input GalleryDestroyInput {
|
||||
@@ -107,8 +100,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 {
|
||||
|
||||
@@ -31,7 +31,6 @@ type Group {
|
||||
sub_group_count(depth: Int): Int! # Resolver
|
||||
scenes: [Scene!]!
|
||||
o_counter: Int # Resolver
|
||||
custom_fields: Map!
|
||||
}
|
||||
|
||||
input GroupDescriptionInput {
|
||||
@@ -60,8 +59,6 @@ input GroupCreateInput {
|
||||
front_image: String
|
||||
"This should be a URL or a base64 encoded data URL"
|
||||
back_image: String
|
||||
|
||||
custom_fields: Map
|
||||
}
|
||||
|
||||
input GroupUpdateInput {
|
||||
@@ -85,8 +82,6 @@ input GroupUpdateInput {
|
||||
front_image: String
|
||||
"This should be a URL or a base64 encoded data URL"
|
||||
back_image: String
|
||||
|
||||
custom_fields: CustomFieldsInput
|
||||
}
|
||||
|
||||
input BulkUpdateGroupDescriptionsInput {
|
||||
@@ -99,8 +94,6 @@ input BulkGroupUpdateInput {
|
||||
ids: [ID!]
|
||||
# rating expressed as 1-100
|
||||
rating100: Int
|
||||
date: String
|
||||
synopsis: String
|
||||
studio_id: ID
|
||||
director: String
|
||||
urls: BulkUpdateStrings
|
||||
@@ -108,8 +101,6 @@ input BulkGroupUpdateInput {
|
||||
|
||||
containing_groups: BulkUpdateGroupDescriptionsInput
|
||||
sub_groups: BulkUpdateGroupDescriptionsInput
|
||||
|
||||
custom_fields: CustomFieldsInput
|
||||
}
|
||||
|
||||
input GroupDestroyInput {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
@@ -94,10 +85,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"
|
||||
@@ -118,10 +107,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 +118,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 +204,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 +249,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"
|
||||
@@ -335,8 +310,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 {
|
||||
|
||||
@@ -7,7 +7,7 @@ enum GenderEnum {
|
||||
NON_BINARY
|
||||
}
|
||||
|
||||
enum CircumcisedEnum {
|
||||
enum CircumisedEnum {
|
||||
CUT
|
||||
UNCUT
|
||||
}
|
||||
@@ -29,10 +29,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!]!
|
||||
@@ -78,13 +76,10 @@ 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")
|
||||
@@ -119,13 +114,10 @@ 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")
|
||||
@@ -165,13 +157,10 @@ 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")
|
||||
@@ -196,10 +185,3 @@ type FindPerformersResultType {
|
||||
count: Int!
|
||||
performers: [Performer!]!
|
||||
}
|
||||
|
||||
input PerformerMergeInput {
|
||||
source: [ID!]!
|
||||
destination: ID!
|
||||
# values defined here will override values in the destination
|
||||
values: PerformerUpdateInput
|
||||
}
|
||||
|
||||
@@ -79,8 +79,6 @@ type Scene {
|
||||
performers: [Performer!]!
|
||||
stash_ids: [StashID!]!
|
||||
|
||||
custom_fields: Map!
|
||||
|
||||
"Return valid stream paths"
|
||||
sceneStreams: [SceneStreamEndpoint!]!
|
||||
}
|
||||
@@ -122,8 +120,6 @@ input SceneCreateInput {
|
||||
Files must not already be primary for another scene.
|
||||
"""
|
||||
file_ids: [ID!]
|
||||
|
||||
custom_fields: Map
|
||||
}
|
||||
|
||||
input SceneUpdateInput {
|
||||
@@ -162,8 +158,6 @@ input SceneUpdateInput {
|
||||
)
|
||||
|
||||
primary_file_id: ID
|
||||
|
||||
custom_fields: CustomFieldsInput
|
||||
}
|
||||
|
||||
enum BulkUpdateIdMode {
|
||||
@@ -196,24 +190,18 @@ input BulkSceneUpdateInput {
|
||||
tag_ids: BulkUpdateIds
|
||||
group_ids: BulkUpdateIds
|
||||
movie_ids: BulkUpdateIds @deprecated(reason: "Use group_ids")
|
||||
|
||||
custom_fields: CustomFieldsInput
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@@ -18,9 +18,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
|
||||
@@ -56,9 +54,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
|
||||
|
||||
@@ -71,9 +71,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
|
||||
}
|
||||
@@ -201,13 +198,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
|
||||
@@ -302,8 +292,6 @@ input StashBoxBatchTagInput {
|
||||
stash_box_endpoint: String
|
||||
"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?"
|
||||
|
||||
@@ -8,7 +8,6 @@ type Studio {
|
||||
aliases: [String!]!
|
||||
tags: [Tag!]!
|
||||
ignore_auto_tag: Boolean!
|
||||
organized: Boolean!
|
||||
|
||||
image_path: String # Resolver
|
||||
scene_count(depth: Int): Int! # Resolver
|
||||
@@ -27,8 +26,6 @@ type Studio {
|
||||
groups: [Group!]!
|
||||
movies: [Movie!]! @deprecated(reason: "use groups instead")
|
||||
o_counter: Int
|
||||
|
||||
custom_fields: Map!
|
||||
}
|
||||
|
||||
input StudioCreateInput {
|
||||
@@ -36,7 +33,6 @@ input StudioCreateInput {
|
||||
url: String @deprecated(reason: "Use urls")
|
||||
urls: [String!]
|
||||
parent_id: ID
|
||||
child_ids: [ID!]
|
||||
"This should be a URL or a base64 encoded data URL"
|
||||
image: String
|
||||
stash_ids: [StashIDInput!]
|
||||
@@ -44,13 +40,9 @@ input StudioCreateInput {
|
||||
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 {
|
||||
@@ -59,7 +51,6 @@ input StudioUpdateInput {
|
||||
url: String @deprecated(reason: "Use urls")
|
||||
urls: [String!]
|
||||
parent_id: ID
|
||||
child_ids: [ID!]
|
||||
"This should be a URL or a base64 encoded data URL"
|
||||
image: String
|
||||
stash_ids: [StashIDInput!]
|
||||
@@ -67,13 +58,9 @@ input StudioUpdateInput {
|
||||
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 {
|
||||
@@ -87,7 +74,6 @@ input BulkStudioUpdateInput {
|
||||
details: String
|
||||
tag_ids: BulkUpdateIds
|
||||
ignore_auto_tag: Boolean
|
||||
organized: Boolean
|
||||
}
|
||||
|
||||
input StudioDestroyInput {
|
||||
|
||||
@@ -24,7 +24,6 @@ type Tag {
|
||||
|
||||
parent_count: Int! # Resolver
|
||||
child_count: Int! # Resolver
|
||||
custom_fields: Map!
|
||||
}
|
||||
|
||||
input TagCreateInput {
|
||||
@@ -32,7 +31,6 @@ input TagCreateInput {
|
||||
"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
|
||||
@@ -42,8 +40,6 @@ input TagCreateInput {
|
||||
|
||||
parent_ids: [ID!]
|
||||
child_ids: [ID!]
|
||||
|
||||
custom_fields: Map
|
||||
}
|
||||
|
||||
input TagUpdateInput {
|
||||
@@ -52,7 +48,6 @@ input TagUpdateInput {
|
||||
"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
|
||||
@@ -62,8 +57,6 @@ input TagUpdateInput {
|
||||
|
||||
parent_ids: [ID!]
|
||||
child_ids: [ID!]
|
||||
|
||||
custom_fields: CustomFieldsInput
|
||||
}
|
||||
|
||||
input TagDestroyInput {
|
||||
@@ -78,14 +71,11 @@ 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
|
||||
|
||||
@@ -29,13 +29,6 @@ fragment StudioFragment on Studio {
|
||||
fragment TagFragment on Tag {
|
||||
name
|
||||
id
|
||||
description
|
||||
aliases
|
||||
category {
|
||||
id
|
||||
name
|
||||
description
|
||||
}
|
||||
}
|
||||
|
||||
fragment MeasurementsFragment on Measurements {
|
||||
@@ -127,6 +120,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 +170,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)
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ 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 (
|
||||
@@ -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,17 +40,6 @@ 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) {
|
||||
|
||||
@@ -148,12 +148,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 +161,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)
|
||||
}
|
||||
|
||||
@@ -295,10 +295,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)
|
||||
|
||||
@@ -5,7 +5,7 @@ package api
|
||||
type key int
|
||||
|
||||
const (
|
||||
galleryKey key = iota
|
||||
galleryKey key = 0
|
||||
performerKey
|
||||
sceneKey
|
||||
studioKey
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -11,9 +11,9 @@
|
||||
//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 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 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 CustomFieldsLoader int github.com/stashapp/stash/pkg/models.CustomFieldMap
|
||||
//go:generate go run github.com/vektah/dataloaden SceneOCountLoader int int
|
||||
//go:generate go run github.com/vektah/dataloaden ScenePlayCountLoader int int
|
||||
@@ -42,43 +42,28 @@ 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
|
||||
GalleryByID *GalleryLoader
|
||||
ImageByID *ImageLoader
|
||||
|
||||
PerformerByID *PerformerLoader
|
||||
PerformerCustomFields *CustomFieldsLoader
|
||||
|
||||
StudioByID *StudioLoader
|
||||
StudioCustomFields *CustomFieldsLoader
|
||||
|
||||
TagByID *TagLoader
|
||||
TagCustomFields *CustomFieldsLoader
|
||||
|
||||
GroupByID *GroupLoader
|
||||
GroupCustomFields *CustomFieldsLoader
|
||||
|
||||
FileByID *FileLoader
|
||||
|
||||
FolderByID *FolderLoader
|
||||
FolderParentFolderIDs *FolderRelatedFolderIDsLoader
|
||||
FolderSubFolderIDs *FolderRelatedFolderIDsLoader
|
||||
StudioByID *StudioLoader
|
||||
TagByID *TagLoader
|
||||
GroupByID *GroupLoader
|
||||
FileByID *FileLoader
|
||||
FolderByID *FolderLoader
|
||||
}
|
||||
|
||||
type Middleware struct {
|
||||
@@ -94,41 +79,16 @@ 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,
|
||||
@@ -139,16 +99,6 @@ func (m Middleware) Middleware(next http.Handler) http.Handler {
|
||||
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,21 +109,11 @@ func (m Middleware) Middleware(next http.Handler) http.Handler {
|
||||
maxBatch: maxBatch,
|
||||
fetch: m.fetchTags(ctx),
|
||||
},
|
||||
TagCustomFields: &CustomFieldsLoader{
|
||||
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),
|
||||
},
|
||||
FileByID: &FileLoader{
|
||||
wait: wait,
|
||||
maxBatch: maxBatch,
|
||||
@@ -184,27 +124,17 @@ func (m Middleware) Middleware(next http.Handler) http.Handler {
|
||||
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 +194,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 +206,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 +218,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 {
|
||||
@@ -380,18 +253,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,42 +264,6 @@ 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) {
|
||||
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 {
|
||||
@@ -472,28 +297,6 @@ func (m Middleware) fetchFolders(ctx context.Context) func(keys []models.FolderI
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -9,10 +9,10 @@ import (
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
)
|
||||
|
||||
// FolderParentFolderIDsLoaderConfig captures the config to create a new FolderParentFolderIDsLoader
|
||||
type FolderParentFolderIDsLoaderConfig 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.FolderID, []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 FolderParentFolderIDsLoaderConfig struct {
|
||||
MaxBatch int
|
||||
}
|
||||
|
||||
// NewFolderParentFolderIDsLoader creates a new FolderParentFolderIDsLoader given a fetch, wait, and maxBatch
|
||||
func NewFolderParentFolderIDsLoader(config FolderParentFolderIDsLoaderConfig) *FolderRelatedFolderIDsLoader {
|
||||
return &FolderRelatedFolderIDsLoader{
|
||||
// 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,
|
||||
}
|
||||
}
|
||||
|
||||
// FolderRelatedFolderIDsLoader batches and caches requests
|
||||
type FolderRelatedFolderIDsLoader struct {
|
||||
// SceneFileIDsLoader batches and caches requests
|
||||
type SceneFileIDsLoader struct {
|
||||
// this method provides the data for the loader
|
||||
fetch func(keys []models.FolderID) ([][]models.FolderID, []error)
|
||||
fetch func(keys []int) ([][]models.FileID, []error)
|
||||
|
||||
// how long to done before sending a batch
|
||||
wait time.Duration
|
||||
@@ -44,51 +44,51 @@ type FolderRelatedFolderIDsLoader struct {
|
||||
// INTERNAL
|
||||
|
||||
// lazily created cache
|
||||
cache map[models.FolderID][]models.FolderID
|
||||
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 *folderParentFolderIDsLoaderBatch
|
||||
batch *sceneFileIDsLoaderBatch
|
||||
|
||||
// mutex to prevent races
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
type folderParentFolderIDsLoaderBatch struct {
|
||||
keys []models.FolderID
|
||||
data [][]models.FolderID
|
||||
type sceneFileIDsLoaderBatch struct {
|
||||
keys []int
|
||||
data [][]models.FileID
|
||||
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) {
|
||||
// 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 FolderID.
|
||||
// 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 *FolderRelatedFolderIDsLoader) LoadThunk(key models.FolderID) func() ([]models.FolderID, 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.FolderID, error) {
|
||||
return func() ([]models.FileID, error) {
|
||||
return it, nil
|
||||
}
|
||||
}
|
||||
if l.batch == nil {
|
||||
l.batch = &folderParentFolderIDsLoaderBatch{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.FolderID, error) {
|
||||
return func() ([]models.FileID, error) {
|
||||
<-batch.done
|
||||
|
||||
var data []models.FolderID
|
||||
var data []models.FileID
|
||||
if pos < len(batch.data) {
|
||||
data = batch.data[pos]
|
||||
}
|
||||
@@ -113,49 +113,49 @@ func (l *FolderRelatedFolderIDsLoader) LoadThunk(key models.FolderID) func() ([]
|
||||
|
||||
// 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))
|
||||
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)
|
||||
}
|
||||
|
||||
folderIDs := make([][]models.FolderID, len(keys))
|
||||
fileIDs := make([][]models.FileID, len(keys))
|
||||
errors := make([]error, len(keys))
|
||||
for i, thunk := range results {
|
||||
folderIDs[i], errors[i] = thunk()
|
||||
fileIDs[i], errors[i] = thunk()
|
||||
}
|
||||
return folderIDs, errors
|
||||
return fileIDs, errors
|
||||
}
|
||||
|
||||
// LoadAllThunk returns a function that when called will block waiting for a FolderIDs.
|
||||
// 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 *FolderRelatedFolderIDsLoader) LoadAllThunk(keys []models.FolderID) func() ([][]models.FolderID, []error) {
|
||||
results := make([]func() ([]models.FolderID, 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.FolderID, []error) {
|
||||
folderIDs := make([][]models.FolderID, len(keys))
|
||||
return func() ([][]models.FileID, []error) {
|
||||
fileIDs := make([][]models.FileID, len(keys))
|
||||
errors := make([]error, len(keys))
|
||||
for i, thunk := range results {
|
||||
folderIDs[i], errors[i] = thunk()
|
||||
fileIDs[i], errors[i] = thunk()
|
||||
}
|
||||
return folderIDs, 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 *FolderRelatedFolderIDsLoader) Prime(key models.FolderID, value []models.FolderID) 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 := make([]models.FolderID, len(value))
|
||||
cpy := make([]models.FileID, len(value))
|
||||
copy(cpy, value)
|
||||
l.unsafeSet(key, cpy)
|
||||
}
|
||||
@@ -164,22 +164,22 @@ func (l *FolderRelatedFolderIDsLoader) Prime(key models.FolderID, value []models
|
||||
}
|
||||
|
||||
// Clear the value at key from the cache, if it exists
|
||||
func (l *FolderRelatedFolderIDsLoader) Clear(key models.FolderID) {
|
||||
func (l *SceneFileIDsLoader) Clear(key int) {
|
||||
l.mu.Lock()
|
||||
delete(l.cache, key)
|
||||
l.mu.Unlock()
|
||||
}
|
||||
|
||||
func (l *FolderRelatedFolderIDsLoader) unsafeSet(key models.FolderID, value []models.FolderID) {
|
||||
func (l *SceneFileIDsLoader) unsafeSet(key int, value []models.FileID) {
|
||||
if l.cache == nil {
|
||||
l.cache = map[models.FolderID][]models.FolderID{}
|
||||
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 *folderParentFolderIDsLoaderBatch) keyIndex(l *FolderRelatedFolderIDsLoader, key models.FolderID) int {
|
||||
func (b *sceneFileIDsLoaderBatch) keyIndex(l *SceneFileIDsLoader, key int) int {
|
||||
for i, existingKey := range b.keys {
|
||||
if key == existingKey {
|
||||
return i
|
||||
@@ -203,7 +203,7 @@ func (b *folderParentFolderIDsLoaderBatch) keyIndex(l *FolderRelatedFolderIDsLoa
|
||||
return pos
|
||||
}
|
||||
|
||||
func (b *folderParentFolderIDsLoaderBatch) startTimer(l *FolderRelatedFolderIDsLoader) {
|
||||
func (b *sceneFileIDsLoaderBatch) startTimer(l *SceneFileIDsLoader) {
|
||||
time.Sleep(l.wait)
|
||||
l.mu.Lock()
|
||||
|
||||
@@ -219,7 +219,7 @@ func (b *folderParentFolderIDsLoaderBatch) startTimer(l *FolderRelatedFolderIDsL
|
||||
b.end(l)
|
||||
}
|
||||
|
||||
func (b *folderParentFolderIDsLoaderBatch) end(l *FolderRelatedFolderIDsLoader) {
|
||||
func (b *sceneFileIDsLoaderBatch) end(l *SceneFileIDsLoader) {
|
||||
b.data, b.error = l.fetch(b.keys)
|
||||
close(b.done)
|
||||
}
|
||||
@@ -41,8 +41,7 @@ func convertBaseFile(f models.File) BaseFile {
|
||||
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}
|
||||
return &BasicFile{BaseFile: f}
|
||||
default:
|
||||
panic("unknown file type")
|
||||
}
|
||||
@@ -58,6 +57,8 @@ type GalleryFile struct {
|
||||
|
||||
func (GalleryFile) IsBaseFile() {}
|
||||
|
||||
func (GalleryFile) IsVisualFile() {}
|
||||
|
||||
func (f *GalleryFile) Fingerprints() []models.Fingerprint {
|
||||
return f.BaseFile.Fingerprints
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ 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"
|
||||
@@ -146,13 +145,6 @@ 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 *queryResolver) MarkerWall(ctx context.Context, q *string) (ret []*models.SceneMarker, err error) {
|
||||
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
|
||||
ret, err = r.repository.SceneMarker.Wall(ctx, q)
|
||||
|
||||
@@ -40,32 +40,10 @@ func (r *imageFileResolver) ParentFolder(ctx context.Context, obj *ImageFile) (*
|
||||
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
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -89,17 +67,6 @@ func (r *galleryFileResolver) ZipFile(ctx context.Context, obj *GalleryFile) (*B
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -2,77 +2,19 @@ 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)
|
||||
}
|
||||
|
||||
@@ -216,16 +216,3 @@ func (r *galleryResolver) Image(ctx context.Context, obj *models.Gallery, index
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -161,12 +161,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
|
||||
}
|
||||
|
||||
@@ -215,16 +215,3 @@ func (r *groupResolver) OCounter(ctx context.Context, obj *models.Group) (ret *i
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -109,31 +109,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()
|
||||
|
||||
@@ -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{
|
||||
@@ -319,25 +294,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 +410,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
|
||||
}
|
||||
|
||||
@@ -207,19 +207,6 @@ 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)
|
||||
|
||||
@@ -181,16 +181,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
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
@@ -86,8 +85,6 @@ func (r *mutationResolver) setConfigFloat(key string, value *float64) {
|
||||
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,12 +97,8 @@ 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
|
||||
}
|
||||
}
|
||||
@@ -294,11 +287,6 @@ func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input ConfigGen
|
||||
if input.PreviewPreset != nil {
|
||||
c.SetString(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.MaxTranscodeSize != nil {
|
||||
@@ -527,15 +515,12 @@ func (r *mutationResolver) ConfigureInterface(ctx context.Context, input ConfigI
|
||||
|
||||
r.setConfigBool(config.CustomLocalesEnabled, input.CustomLocalesEnabled)
|
||||
|
||||
r.setConfigBool(config.DisableCustomizations, input.DisableCustomizations)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
r.setConfigString(config.HandyKey, input.HandyKey)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -214,58 +210,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 +274,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
|
||||
}
|
||||
|
||||
@@ -42,17 +42,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.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
|
||||
|
||||
@@ -84,12 +80,10 @@ func (r *mutationResolver) GalleryCreate(ctx context.Context, input GalleryCreat
|
||||
newGallery.URLs = models.NewRelatedStrings([]string{strings.TrimSpace(*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 +240,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 +292,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
|
||||
@@ -359,7 +345,6 @@ func (r *mutationResolver) GalleryDestroy(ctx context.Context, input models.Gall
|
||||
|
||||
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 +365,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
|
||||
}
|
||||
|
||||
@@ -14,17 +14,13 @@ import (
|
||||
"github.com/stashapp/stash/pkg/utils"
|
||||
)
|
||||
|
||||
func groupFromGroupCreateInput(ctx context.Context, input GroupCreateInput) (*models.CreateGroupInput, error) {
|
||||
func groupFromGroupCreateInput(ctx context.Context, input GroupCreateInput) (*models.Group, 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 := models.NewGroup()
|
||||
|
||||
newGroup.Name = strings.TrimSpace(input.Name)
|
||||
newGroup.Aliases = translator.string(input.Aliases)
|
||||
@@ -63,19 +59,28 @@ func groupFromGroupCreateInput(ctx context.Context, input GroupCreateInput) (*mo
|
||||
newGroup.URLs = models.NewRelatedStrings(stringslice.TrimSpace(input.Urls))
|
||||
}
|
||||
|
||||
newGroupInput.CustomFields = convertMapJSONNumbers(input.CustomFields)
|
||||
return &newGroup, nil
|
||||
}
|
||||
|
||||
func (r *mutationResolver) GroupCreate(ctx context.Context, input GroupCreateInput) (*models.Group, error) {
|
||||
newGroup, err := groupFromGroupCreateInput(ctx, input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Process the base 64 encoded image string
|
||||
var frontimageData []byte
|
||||
if input.FrontImage != nil {
|
||||
newGroupInput.FrontImageData, err = utils.ProcessImageInput(ctx, *input.FrontImage)
|
||||
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
|
||||
var backimageData []byte
|
||||
if input.BackImage != nil {
|
||||
newGroupInput.BackImageData, err = utils.ProcessImageInput(ctx, *input.BackImage)
|
||||
backimageData, err = utils.ProcessImageInput(ctx, *input.BackImage)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("processing back image: %w", err)
|
||||
}
|
||||
@@ -83,22 +88,13 @@ func groupFromGroupCreateInput(ctx context.Context, input GroupCreateInput) (*mo
|
||||
|
||||
// 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
|
||||
if len(frontimageData) == 0 && len(backimageData) != 0 {
|
||||
frontimageData = static.ReadAll(static.DefaultGroupImage)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
if err = r.groupService.Create(ctx, newGroup, frontimageData, backimageData); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -108,9 +104,9 @@ func (r *mutationResolver) GroupCreate(ctx context.Context, input GroupCreateInp
|
||||
}
|
||||
|
||||
// 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)
|
||||
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)
|
||||
}
|
||||
|
||||
func groupPartialFromGroupUpdateInput(translator changesetTranslator, input GroupUpdateInput) (ret models.GroupPartial, err error) {
|
||||
@@ -154,12 +150,6 @@ func groupPartialFromGroupUpdateInput(translator changesetTranslator, input Grou
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -227,12 +217,6 @@ func (r *mutationResolver) GroupUpdate(ctx context.Context, input GroupUpdateInp
|
||||
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")
|
||||
|
||||
@@ -262,13 +246,6 @@ func groupPartialFromBulkGroupUpdateInput(translator changesetTranslator, input
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -339,7 +325,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
|
||||
@@ -386,7 +372,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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -47,10 +47,6 @@ func (r *mutationResolver) Migrate(ctx context.Context, input manager.MigrateInp
|
||||
Database: mgr.Database,
|
||||
}
|
||||
|
||||
if err := t.PreExecute(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
jobID := mgr.JobManager.Add(ctx, "Migrating database...", t)
|
||||
|
||||
return strconv.Itoa(jobID), nil
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,16 +2,13 @@ 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"
|
||||
)
|
||||
@@ -43,7 +40,7 @@ func (r *mutationResolver) PerformerCreate(ctx context.Context, input models.Per
|
||||
|
||||
newPerformer.Name = strings.TrimSpace(input.Name)
|
||||
newPerformer.Disambiguation = translator.string(input.Disambiguation)
|
||||
newPerformer.Aliases = models.NewRelatedStrings(stringslice.UniqueExcludeFold(stringslice.TrimSpace(input.AliasList), newPerformer.Name))
|
||||
newPerformer.Aliases = models.NewRelatedStrings(stringslice.TrimSpace(input.AliasList))
|
||||
newPerformer.Gender = input.Gender
|
||||
newPerformer.Ethnicity = translator.string(input.Ethnicity)
|
||||
newPerformer.Country = translator.string(input.Country)
|
||||
@@ -52,6 +49,7 @@ 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.Favorite = translator.bool(input.Favorite)
|
||||
@@ -89,25 +87,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)
|
||||
@@ -157,7 +136,7 @@ func (r *mutationResolver) PerformerCreate(ctx context.Context, input models.Per
|
||||
return r.getPerformer(ctx, newPerformer.ID)
|
||||
}
|
||||
|
||||
func validateNoLegacyURLs(translator changesetTranslator) error {
|
||||
func (r *mutationResolver) 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")
|
||||
@@ -172,7 +151,7 @@ func validateNoLegacyURLs(translator changesetTranslator) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *mutationResolver) handleLegacyURLs(ctx context.Context, performerID int, legacyURLs legacyPerformerURLs, updatedPerformer *models.PerformerPartial) error {
|
||||
func (r *mutationResolver) handleLegacyURLs(ctx context.Context, performerID int, legacyURL, legacyTwitter, legacyInstagram models.OptionalString, updatedPerformer *models.PerformerPartial) error {
|
||||
qb := r.repository.Performer
|
||||
|
||||
// we need to be careful with URL/Twitter/Instagram
|
||||
@@ -191,23 +170,23 @@ func (r *mutationResolver) handleLegacyURLs(ctx context.Context, performerID int
|
||||
existingURLs := p.URLs.List()
|
||||
|
||||
// performer partial URLs should be empty
|
||||
if legacyURLs.URL.Set {
|
||||
if legacyURL.Set {
|
||||
replaced := false
|
||||
for i, url := range existingURLs {
|
||||
if !performer.IsTwitterURL(url) && !performer.IsInstagramURL(url) {
|
||||
existingURLs[i] = legacyURLs.URL.Value
|
||||
existingURLs[i] = legacyURL.Value
|
||||
replaced = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !replaced {
|
||||
existingURLs = append(existingURLs, legacyURLs.URL.Value)
|
||||
existingURLs = append(existingURLs, legacyURL.Value)
|
||||
}
|
||||
}
|
||||
|
||||
if legacyURLs.Twitter.Set {
|
||||
value := utils.URLFromHandle(legacyURLs.Twitter.Value, twitterURL)
|
||||
if legacyTwitter.Set {
|
||||
value := utils.URLFromHandle(legacyTwitter.Value, twitterURL)
|
||||
found := false
|
||||
// find and replace the first twitter URL
|
||||
for i, url := range existingURLs {
|
||||
@@ -222,9 +201,9 @@ func (r *mutationResolver) handleLegacyURLs(ctx context.Context, performerID int
|
||||
existingURLs = append(existingURLs, value)
|
||||
}
|
||||
}
|
||||
if legacyURLs.Instagram.Set {
|
||||
if legacyInstagram.Set {
|
||||
found := false
|
||||
value := utils.URLFromHandle(legacyURLs.Instagram.Value, instagramURL)
|
||||
value := utils.URLFromHandle(legacyInstagram.Value, instagramURL)
|
||||
// find and replace the first instagram URL
|
||||
for i, url := range existingURLs {
|
||||
if performer.IsInstagramURL(url) {
|
||||
@@ -247,25 +226,16 @@ func (r *mutationResolver) handleLegacyURLs(ctx context.Context, performerID int
|
||||
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 (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),
|
||||
}
|
||||
}
|
||||
|
||||
func performerPartialFromInput(input models.PerformerUpdateInput, translator changesetTranslator) (*models.PerformerPartial, error) {
|
||||
// Populate performer from the input
|
||||
updatedPerformer := models.NewPerformerPartial()
|
||||
|
||||
@@ -279,29 +249,7 @@ 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.Favorite = translator.optionalBool(input.Favorite, "favorite")
|
||||
@@ -312,17 +260,19 @@ 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 {
|
||||
if err := r.validateNoLegacyURLs(translator); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
updatedPerformer.URLs = translator.updateStrings(input.Urls, "urls")
|
||||
}
|
||||
|
||||
legacyURL := translator.optionalString(input.URL, "url")
|
||||
legacyTwitter := translator.optionalString(input.Twitter, "twitter")
|
||||
legacyInstagram := translator.optionalString(input.Instagram, "instagram")
|
||||
|
||||
updatedPerformer.Birthdate, err = translator.optionalDate(input.Birthdate, "birthdate")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("converting birthdate: %w", err)
|
||||
@@ -349,26 +299,6 @@ func performerPartialFromInput(input models.PerformerUpdateInput, translator cha
|
||||
|
||||
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 +312,17 @@ 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 {
|
||||
if legacyURL.Set || legacyTwitter.Set || legacyInstagram.Set {
|
||||
if err := r.handleLegacyURLs(ctx, performerID, legacyURL, legacyTwitter, legacyInstagram, &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
|
||||
}
|
||||
@@ -457,28 +366,7 @@ 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")
|
||||
|
||||
@@ -491,18 +379,16 @@ func (r *mutationResolver) BulkPerformerUpdate(ctx context.Context, input BulkPe
|
||||
|
||||
if translator.hasField("urls") {
|
||||
// ensure url/twitter/instagram are not included in the input
|
||||
if err := validateNoLegacyURLs(translator); err != nil {
|
||||
if err := r.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"),
|
||||
}
|
||||
legacyURL := translator.optionalString(input.URL, "url")
|
||||
legacyTwitter := translator.optionalString(input.Twitter, "twitter")
|
||||
legacyInstagram := translator.optionalString(input.Instagram, "instagram")
|
||||
|
||||
updatedPerformer.Birthdate, err = translator.optionalDate(input.Birthdate, "birthdate")
|
||||
if err != nil {
|
||||
@@ -539,8 +425,8 @@ 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 {
|
||||
if legacyURL.Set || legacyTwitter.Set || legacyInstagram.Set {
|
||||
if err := r.handleLegacyURLs(ctx, performerID, legacyURL, legacyTwitter, legacyInstagram, &updatedPerformer); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -620,87 +506,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
|
||||
}
|
||||
|
||||
@@ -103,15 +103,8 @@ func (r *mutationResolver) SceneCreate(ctx context.Context, input models.SceneCr
|
||||
}
|
||||
}
|
||||
|
||||
customFields := convertMapJSONNumbers(input.CustomFields)
|
||||
|
||||
if err := r.withTxn(ctx, func(ctx context.Context) error {
|
||||
ret, err = r.Resolver.sceneService.Create(ctx, models.CreateSceneInput{
|
||||
Scene: &newScene,
|
||||
FileIDs: fileIDs,
|
||||
CoverImage: coverImageData,
|
||||
CustomFields: customFields,
|
||||
})
|
||||
ret, err = r.Resolver.sceneService.Create(ctx, &newScene, fileIDs, coverImageData)
|
||||
return err
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
@@ -304,7 +297,6 @@ func (r *mutationResolver) sceneUpdate(ctx context.Context, input models.SceneUp
|
||||
}
|
||||
|
||||
var coverImageData []byte
|
||||
coverImageIncluded := translator.hasField("cover_image")
|
||||
if input.CoverImage != nil {
|
||||
var err error
|
||||
coverImageData, err = utils.ProcessImageInput(ctx, *input.CoverImage)
|
||||
@@ -313,41 +305,26 @@ func (r *mutationResolver) sceneUpdate(ctx context.Context, input models.SceneUp
|
||||
}
|
||||
}
|
||||
|
||||
var customFields *models.CustomFieldsInput
|
||||
if input.CustomFields != nil {
|
||||
cfCopy := *input.CustomFields
|
||||
customFields = &cfCopy
|
||||
// convert json.Numbers to int/float
|
||||
customFields.Full = convertMapJSONNumbers(customFields.Full)
|
||||
customFields.Partial = convertMapJSONNumbers(customFields.Partial)
|
||||
}
|
||||
|
||||
scene, err := qb.UpdatePartial(ctx, sceneID, *updatedScene)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if coverImageIncluded {
|
||||
if err := r.sceneUpdateCoverImage(ctx, scene, coverImageData); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if customFields != nil {
|
||||
if err := qb.SetCustomFields(ctx, scene.ID, *customFields); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := r.sceneUpdateCoverImage(ctx, scene, coverImageData); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return scene, nil
|
||||
}
|
||||
|
||||
func (r *mutationResolver) sceneUpdateCoverImage(ctx context.Context, s *models.Scene, coverImageData []byte) error {
|
||||
qb := r.repository.Scene
|
||||
if len(coverImageData) > 0 {
|
||||
qb := r.repository.Scene
|
||||
|
||||
// update cover table - empty data will clear the cover
|
||||
if err := qb.UpdateCover(ctx, s.ID, coverImageData); err != nil {
|
||||
return err
|
||||
// update cover table
|
||||
if err := qb.UpdateCover(ctx, s.ID, coverImageData); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -409,12 +386,6 @@ func (r *mutationResolver) BulkSceneUpdate(ctx context.Context, input BulkSceneU
|
||||
}
|
||||
}
|
||||
|
||||
var customFields *models.CustomFieldsInput
|
||||
if input.CustomFields != nil {
|
||||
cf := handleUpdateCustomFields(*input.CustomFields)
|
||||
customFields = &cf
|
||||
}
|
||||
|
||||
ret := []*models.Scene{}
|
||||
|
||||
// Start the transaction and save the scenes
|
||||
@@ -427,12 +398,6 @@ func (r *mutationResolver) BulkSceneUpdate(ctx context.Context, input BulkSceneU
|
||||
return err
|
||||
}
|
||||
|
||||
if customFields != nil {
|
||||
if err := qb.SetCustomFields(ctx, scene.ID, *customFields); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
ret = append(ret, scene)
|
||||
}
|
||||
|
||||
@@ -475,7 +440,6 @@ func (r *mutationResolver) SceneDestroy(ctx context.Context, input models.SceneD
|
||||
|
||||
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.Scene
|
||||
@@ -492,7 +456,7 @@ func (r *mutationResolver) SceneDestroy(ctx context.Context, input models.SceneD
|
||||
// kill any running encoders
|
||||
manager.KillRunningStreams(s, fileNamingAlgo)
|
||||
|
||||
return r.sceneService.Destroy(ctx, s, fileDeleter, deleteGenerated, deleteFile, destroyFileEntry)
|
||||
return r.sceneService.Destroy(ctx, s, fileDeleter, deleteGenerated, deleteFile)
|
||||
}); err != nil {
|
||||
fileDeleter.Rollback()
|
||||
return false, err
|
||||
@@ -530,7 +494,6 @@ func (r *mutationResolver) ScenesDestroy(ctx context.Context, input models.Scene
|
||||
|
||||
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.Scene
|
||||
@@ -549,7 +512,7 @@ func (r *mutationResolver) ScenesDestroy(ctx context.Context, input models.Scene
|
||||
// kill any running encoders
|
||||
manager.KillRunningStreams(scene, fileNamingAlgo)
|
||||
|
||||
if err := r.sceneService.Destroy(ctx, scene, fileDeleter, deleteGenerated, deleteFile, destroyFileEntry); err != nil {
|
||||
if err := r.sceneService.Destroy(ctx, scene, fileDeleter, deleteGenerated, deleteFile); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -609,7 +572,6 @@ func (r *mutationResolver) SceneMerge(ctx context.Context, input SceneMergeInput
|
||||
|
||||
var values *models.ScenePartial
|
||||
var coverImageData []byte
|
||||
var customFields *models.CustomFieldsInput
|
||||
|
||||
if input.Values != nil {
|
||||
translator := changesetTranslator{
|
||||
@@ -628,11 +590,6 @@ func (r *mutationResolver) SceneMerge(ctx context.Context, input SceneMergeInput
|
||||
return nil, fmt.Errorf("processing cover image: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if input.Values.CustomFields != nil {
|
||||
cf := handleUpdateCustomFields(*input.Values.CustomFields)
|
||||
customFields = &cf
|
||||
}
|
||||
} else {
|
||||
v := models.NewScenePartial()
|
||||
values = &v
|
||||
@@ -664,20 +621,7 @@ func (r *mutationResolver) SceneMerge(ctx context.Context, input SceneMergeInput
|
||||
return fmt.Errorf("scene with id %d not found", destID)
|
||||
}
|
||||
|
||||
// only update cover image if one was provided
|
||||
if len(coverImageData) > 0 {
|
||||
if err := r.sceneUpdateCoverImage(ctx, ret, coverImageData); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if customFields != nil {
|
||||
if err := r.Resolver.repository.Scene.SetCustomFields(ctx, ret.ID, *customFields); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
return r.sceneUpdateCoverImage(ctx, ret, coverImageData)
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -58,16 +58,6 @@ func (r *mutationResolver) StashBoxBatchStudioTag(ctx context.Context, input man
|
||||
return strconv.Itoa(jobID), nil
|
||||
}
|
||||
|
||||
func (r *mutationResolver) StashBoxBatchTagTag(ctx context.Context, input manager.StashBoxBatchTagInput) (string, error) {
|
||||
b, err := resolveStashBoxBatchTagInput(input.Endpoint, input.StashBoxEndpoint) //nolint:staticcheck
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
jobID := manager.GetInstance().StashBoxBatchTagTag(ctx, b, input)
|
||||
return strconv.Itoa(jobID), nil
|
||||
}
|
||||
|
||||
func (r *mutationResolver) SubmitStashBoxSceneDraft(ctx context.Context, input StashBoxDraftSubmissionInput) (*string, error) {
|
||||
b, err := resolveStashBox(input.StashBoxIndex, input.StashBoxEndpoint)
|
||||
if err != nil {
|
||||
|
||||
@@ -14,63 +14,6 @@ import (
|
||||
)
|
||||
|
||||
// used to refetch studio after hooks run
|
||||
|
||||
func clearRemovedChildStudios(ctx context.Context, qb models.StudioReaderWriter, parentStudioID int, childStudioIDs []int) error {
|
||||
currentChildren, err := qb.FindChildren(ctx, parentStudioID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
newChildStudioIDs := make(map[int]struct{}, len(childStudioIDs))
|
||||
for _, childStudioID := range childStudioIDs {
|
||||
newChildStudioIDs[childStudioID] = struct{}{}
|
||||
}
|
||||
|
||||
for _, currentChild := range currentChildren {
|
||||
if _, keep := newChildStudioIDs[currentChild.ID]; keep {
|
||||
continue
|
||||
}
|
||||
|
||||
clearParentPartial := models.NewStudioPartial()
|
||||
clearParentPartial.ID = currentChild.ID
|
||||
clearParentPartial.ParentID = models.NewOptionalIntPtr(nil)
|
||||
|
||||
if _, err := qb.UpdatePartial(ctx, clearParentPartial); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func setChildStudios(ctx context.Context, qb models.StudioReaderWriter, parentStudioID int, childStudioIDs []int) error {
|
||||
if err := clearRemovedChildStudios(ctx, qb, parentStudioID, childStudioIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
newChildStudioIDs := make(map[int]struct{}, len(childStudioIDs))
|
||||
for _, childStudioID := range childStudioIDs {
|
||||
if _, found := newChildStudioIDs[childStudioID]; found {
|
||||
continue
|
||||
}
|
||||
newChildStudioIDs[childStudioID] = struct{}{}
|
||||
|
||||
childPartial := models.NewStudioPartial()
|
||||
childPartial.ID = childStudioID
|
||||
childPartial.ParentID = models.NewOptionalInt(parentStudioID)
|
||||
|
||||
if err := studio.ValidateModify(ctx, childPartial, qb); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := qb.UpdatePartial(ctx, childPartial); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *mutationResolver) getStudio(ctx context.Context, id int) (ret *models.Studio, err error) {
|
||||
if err := r.withTxn(ctx, func(ctx context.Context) error {
|
||||
ret, err = r.repository.Studio.Find(ctx, id)
|
||||
@@ -88,15 +31,14 @@ func (r *mutationResolver) StudioCreate(ctx context.Context, input models.Studio
|
||||
}
|
||||
|
||||
// Populate a new studio from the input
|
||||
newStudio := models.NewCreateStudioInput()
|
||||
newStudio := models.NewStudio()
|
||||
|
||||
newStudio.Name = strings.TrimSpace(input.Name)
|
||||
newStudio.Rating = input.Rating100
|
||||
newStudio.Favorite = translator.bool(input.Favorite)
|
||||
newStudio.Details = translator.string(input.Details)
|
||||
newStudio.IgnoreAutoTag = translator.bool(input.IgnoreAutoTag)
|
||||
newStudio.Organized = translator.bool(input.Organized)
|
||||
newStudio.Aliases = models.NewRelatedStrings(stringslice.UniqueExcludeFold(stringslice.TrimSpace(input.Aliases), newStudio.Name))
|
||||
newStudio.Aliases = models.NewRelatedStrings(stringslice.TrimSpace(input.Aliases))
|
||||
newStudio.StashIDs = models.NewRelatedStashIDs(models.StashIDInputs(input.StashIds).ToStashIDs())
|
||||
|
||||
var err error
|
||||
@@ -120,12 +62,6 @@ func (r *mutationResolver) StudioCreate(ctx context.Context, input models.Studio
|
||||
return nil, fmt.Errorf("converting tag ids: %w", err)
|
||||
}
|
||||
|
||||
childStudioIDs, err := stringslice.StringSliceToIntSlice(input.ChildIds)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("converting child ids: %w", err)
|
||||
}
|
||||
newStudio.CustomFields = convertMapJSONNumbers(input.CustomFields)
|
||||
|
||||
// Process the base 64 encoded image string
|
||||
var imageData []byte
|
||||
if input.Image != nil {
|
||||
@@ -155,12 +91,6 @@ func (r *mutationResolver) StudioCreate(ctx context.Context, input models.Studio
|
||||
}
|
||||
}
|
||||
|
||||
if input.ChildIds != nil {
|
||||
if err := setChildStudios(ctx, qb, newStudio.ID, childStudioIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
@@ -189,7 +119,6 @@ func (r *mutationResolver) StudioUpdate(ctx context.Context, input models.Studio
|
||||
updatedStudio.Rating = translator.optionalInt(input.Rating100, "rating100")
|
||||
updatedStudio.Favorite = translator.optionalBool(input.Favorite, "favorite")
|
||||
updatedStudio.IgnoreAutoTag = translator.optionalBool(input.IgnoreAutoTag, "ignore_auto_tag")
|
||||
updatedStudio.Organized = translator.optionalBool(input.Organized, "organized")
|
||||
updatedStudio.Aliases = translator.updateStrings(input.Aliases, "aliases")
|
||||
updatedStudio.StashIDs = translator.updateStashIDs(input.StashIds, "stash_ids")
|
||||
|
||||
@@ -203,14 +132,9 @@ func (r *mutationResolver) StudioUpdate(ctx context.Context, input models.Studio
|
||||
return nil, fmt.Errorf("converting tag ids: %w", err)
|
||||
}
|
||||
|
||||
childStudioIDs, err := stringslice.StringSliceToIntSlice(input.ChildIds)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("converting child ids: %w", err)
|
||||
}
|
||||
|
||||
if translator.hasField("urls") {
|
||||
// ensure url not included in the input
|
||||
if err := validateNoLegacyURLs(translator); err != nil {
|
||||
if err := r.validateNoLegacyURLs(translator); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -228,11 +152,6 @@ func (r *mutationResolver) StudioUpdate(ctx context.Context, input models.Studio
|
||||
}
|
||||
}
|
||||
|
||||
updatedStudio.CustomFields = input.CustomFields
|
||||
// convert json.Numbers to int/float
|
||||
updatedStudio.CustomFields.Full = convertMapJSONNumbers(updatedStudio.CustomFields.Full)
|
||||
updatedStudio.CustomFields.Partial = convertMapJSONNumbers(updatedStudio.CustomFields.Partial)
|
||||
|
||||
// Process the base 64 encoded image string
|
||||
var imageData []byte
|
||||
imageIncluded := translator.hasField("image")
|
||||
@@ -248,34 +167,6 @@ func (r *mutationResolver) StudioUpdate(ctx context.Context, input models.Studio
|
||||
if err := r.withTxn(ctx, func(ctx context.Context) error {
|
||||
qb := r.repository.Studio
|
||||
|
||||
if updatedStudio.Aliases != nil {
|
||||
s, err := qb.Find(ctx, studioID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if s != nil {
|
||||
if err := s.LoadAliases(ctx, qb); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
effectiveAliases := updatedStudio.Aliases.Apply(s.Aliases.List())
|
||||
name := s.Name
|
||||
if updatedStudio.Name.Set {
|
||||
name = updatedStudio.Name.Value
|
||||
}
|
||||
|
||||
sanitized := stringslice.UniqueExcludeFold(effectiveAliases, name)
|
||||
updatedStudio.Aliases.Values = sanitized
|
||||
updatedStudio.Aliases.Mode = models.RelationshipUpdateModeSet
|
||||
}
|
||||
}
|
||||
|
||||
if translator.hasField("child_ids") {
|
||||
if err := clearRemovedChildStudios(ctx, qb, studioID, childStudioIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := studio.ValidateModify(ctx, updatedStudio, qb); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -291,12 +182,6 @@ func (r *mutationResolver) StudioUpdate(ctx context.Context, input models.Studio
|
||||
}
|
||||
}
|
||||
|
||||
if translator.hasField("child_ids") {
|
||||
if err := setChildStudios(ctx, qb, studioID, childStudioIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
@@ -326,7 +211,7 @@ func (r *mutationResolver) BulkStudioUpdate(ctx context.Context, input BulkStudi
|
||||
|
||||
if translator.hasField("urls") {
|
||||
// ensure url/twitter/instagram are not included in the input
|
||||
if err := validateNoLegacyURLs(translator); err != nil {
|
||||
if err := r.validateNoLegacyURLs(translator); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -348,7 +233,6 @@ func (r *mutationResolver) BulkStudioUpdate(ctx context.Context, input BulkStudi
|
||||
partial.Rating = translator.optionalInt(input.Rating100, "rating100")
|
||||
partial.Details = translator.optionalString(input.Details, "details")
|
||||
partial.IgnoreAutoTag = translator.optionalBool(input.IgnoreAutoTag, "ignore_auto_tag")
|
||||
partial.Organized = translator.optionalBool(input.Organized, "organized")
|
||||
|
||||
partial.TagIDs, err = translator.updateIdsBulk(input.TagIds, "tag_ids")
|
||||
if err != nil {
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/stashapp/stash/pkg/logger"
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stashapp/stash/pkg/plugin/hook"
|
||||
"github.com/stashapp/stash/pkg/sliceutil/stringslice"
|
||||
@@ -30,14 +31,11 @@ func (r *mutationResolver) TagCreate(ctx context.Context, input TagCreateInput)
|
||||
}
|
||||
|
||||
// Populate a new tag from the input
|
||||
newTag := models.CreateTagInput{
|
||||
Tag: &models.Tag{},
|
||||
}
|
||||
*newTag.Tag = models.NewTag()
|
||||
newTag := models.NewTag()
|
||||
|
||||
newTag.Name = strings.TrimSpace(input.Name)
|
||||
newTag.SortName = translator.string(input.SortName)
|
||||
newTag.Aliases = models.NewRelatedStrings(stringslice.UniqueExcludeFold(stringslice.TrimSpace(input.Aliases), newTag.Name))
|
||||
newTag.Aliases = models.NewRelatedStrings(stringslice.TrimSpace(input.Aliases))
|
||||
newTag.Favorite = translator.bool(input.Favorite)
|
||||
newTag.Description = translator.string(input.Description)
|
||||
newTag.IgnoreAutoTag = translator.bool(input.IgnoreAutoTag)
|
||||
@@ -62,8 +60,6 @@ func (r *mutationResolver) TagCreate(ctx context.Context, input TagCreateInput)
|
||||
return nil, fmt.Errorf("converting child tag ids: %w", err)
|
||||
}
|
||||
|
||||
newTag.CustomFields = convertMapJSONNumbers(input.CustomFields)
|
||||
|
||||
// Process the base 64 encoded image string
|
||||
var imageData []byte
|
||||
if input.Image != nil {
|
||||
@@ -77,7 +73,7 @@ func (r *mutationResolver) TagCreate(ctx context.Context, input TagCreateInput)
|
||||
if err := r.withTxn(ctx, func(ctx context.Context) error {
|
||||
qb := r.repository.Tag
|
||||
|
||||
if err := tag.ValidateCreate(ctx, *newTag.Tag, qb); err != nil {
|
||||
if err := tag.ValidateCreate(ctx, newTag, qb); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -102,7 +98,17 @@ func (r *mutationResolver) TagCreate(ctx context.Context, input TagCreateInput)
|
||||
return r.getTag(ctx, newTag.ID)
|
||||
}
|
||||
|
||||
func tagPartialFromInput(input TagUpdateInput, translator changesetTranslator) (*models.TagPartial, error) {
|
||||
func (r *mutationResolver) TagUpdate(ctx context.Context, input TagUpdateInput) (*models.Tag, error) {
|
||||
tagID, err := strconv.Atoi(input.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("converting id: %w", err)
|
||||
}
|
||||
|
||||
translator := changesetTranslator{
|
||||
inputMap: getUpdateInputMap(ctx),
|
||||
}
|
||||
|
||||
// Populate tag from the input
|
||||
updatedTag := models.NewTagPartial()
|
||||
|
||||
updatedTag.Name = translator.optionalString(input.Name, "name")
|
||||
@@ -121,7 +127,6 @@ func tagPartialFromInput(input TagUpdateInput, translator changesetTranslator) (
|
||||
}
|
||||
updatedTag.StashIDs = translator.updateStashIDs(updateStashIDInputs, "stash_ids")
|
||||
|
||||
var err error
|
||||
updatedTag.ParentIDs, err = translator.updateIds(input.ParentIds, "parent_ids")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("converting parent tag ids: %w", err)
|
||||
@@ -132,32 +137,6 @@ func tagPartialFromInput(input TagUpdateInput, translator changesetTranslator) (
|
||||
return nil, fmt.Errorf("converting child tag ids: %w", err)
|
||||
}
|
||||
|
||||
if input.CustomFields != nil {
|
||||
updatedTag.CustomFields = *input.CustomFields
|
||||
// convert json.Numbers to int/float
|
||||
updatedTag.CustomFields.Full = convertMapJSONNumbers(updatedTag.CustomFields.Full)
|
||||
updatedTag.CustomFields.Partial = convertMapJSONNumbers(updatedTag.CustomFields.Partial)
|
||||
}
|
||||
|
||||
return &updatedTag, nil
|
||||
}
|
||||
|
||||
func (r *mutationResolver) TagUpdate(ctx context.Context, input TagUpdateInput) (*models.Tag, error) {
|
||||
tagID, err := strconv.Atoi(input.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("converting id: %w", err)
|
||||
}
|
||||
|
||||
translator := changesetTranslator{
|
||||
inputMap: getUpdateInputMap(ctx),
|
||||
}
|
||||
|
||||
// Populate tag from the input
|
||||
updatedTag, err := tagPartialFromInput(input, translator)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var imageData []byte
|
||||
imageIncluded := translator.hasField("image")
|
||||
if input.Image != nil {
|
||||
@@ -172,33 +151,11 @@ func (r *mutationResolver) TagUpdate(ctx context.Context, input TagUpdateInput)
|
||||
if err := r.withTxn(ctx, func(ctx context.Context) error {
|
||||
qb := r.repository.Tag
|
||||
|
||||
if updatedTag.Aliases != nil {
|
||||
t, err := qb.Find(ctx, tagID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if t != nil {
|
||||
if err := t.LoadAliases(ctx, qb); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
newAliases := updatedTag.Aliases.Apply(t.Aliases.List())
|
||||
name := t.Name
|
||||
if updatedTag.Name.Set {
|
||||
name = updatedTag.Name.Value
|
||||
}
|
||||
|
||||
sanitized := stringslice.UniqueExcludeFold(newAliases, name)
|
||||
updatedTag.Aliases.Values = sanitized
|
||||
updatedTag.Aliases.Mode = models.RelationshipUpdateModeSet
|
||||
}
|
||||
}
|
||||
|
||||
if err := tag.ValidateUpdate(ctx, tagID, *updatedTag, qb); err != nil {
|
||||
if err := tag.ValidateUpdate(ctx, tagID, updatedTag, qb); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
t, err = qb.UpdatePartial(ctx, tagID, *updatedTag)
|
||||
t, err = qb.UpdatePartial(ctx, tagID, updatedTag)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -346,31 +303,6 @@ func (r *mutationResolver) TagsMerge(ctx context.Context, input TagsMergeInput)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var values *models.TagPartial
|
||||
var imageData []byte
|
||||
|
||||
if input.Values != nil {
|
||||
translator := changesetTranslator{
|
||||
inputMap: getNamedUpdateInputMap(ctx, "input.values"),
|
||||
}
|
||||
|
||||
values, err = tagPartialFromInput(*input.Values, translator)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
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.NewTagPartial()
|
||||
values = &v
|
||||
}
|
||||
|
||||
var t *models.Tag
|
||||
if err := r.withTxn(ctx, func(ctx context.Context) error {
|
||||
qb := r.repository.Tag
|
||||
@@ -385,22 +317,28 @@ func (r *mutationResolver) TagsMerge(ctx context.Context, input TagsMergeInput)
|
||||
return fmt.Errorf("tag with id %d not found", destination)
|
||||
}
|
||||
|
||||
parents, children, err := tag.MergeHierarchy(ctx, destination, source, qb)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = qb.Merge(ctx, source, destination); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := tag.ValidateUpdate(ctx, destination, *values, qb); err != nil {
|
||||
err = qb.UpdateParentTags(ctx, destination, parents)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = qb.UpdateChildTags(ctx, destination, children)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := qb.UpdatePartial(ctx, destination, *values); err != nil {
|
||||
return fmt.Errorf("updating tag: %w", err)
|
||||
}
|
||||
|
||||
if len(imageData) > 0 {
|
||||
if err := qb.UpdateImage(ctx, destination, imageData); err != nil {
|
||||
return err
|
||||
}
|
||||
err = tag.ValidateHierarchyExisting(ctx, t, parents, children, qb)
|
||||
if err != nil {
|
||||
logger.Errorf("Error merging tag: %s", err)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -96,11 +96,6 @@ func makeConfigGeneralResult() *ConfigGeneralResult {
|
||||
CalculateMd5: config.IsCalculateMD5(),
|
||||
VideoFileNamingAlgorithm: config.GetVideoFileNamingAlgorithm(),
|
||||
ParallelTasks: config.GetParallelTasks(),
|
||||
UseCustomSpriteInterval: config.GetUseCustomSpriteInterval(),
|
||||
SpriteInterval: config.GetSpriteInterval(),
|
||||
SpriteScreenshotSize: config.GetSpriteScreenshotSize(),
|
||||
MinimumSprites: config.GetMinimumSprites(),
|
||||
MaximumSprites: config.GetMaximumSprites(),
|
||||
PreviewAudio: config.GetPreviewAudio(),
|
||||
PreviewSegments: config.GetPreviewSegments(),
|
||||
PreviewSegmentDuration: config.GetPreviewSegmentDuration(),
|
||||
@@ -161,7 +156,6 @@ func makeConfigInterfaceResult() *ConfigInterfaceResult {
|
||||
javascriptEnabled := config.GetJavascriptEnabled()
|
||||
customLocales := config.GetCustomLocales()
|
||||
customLocalesEnabled := config.GetCustomLocalesEnabled()
|
||||
disableCustomizations := config.GetDisableCustomizations()
|
||||
language := config.GetLanguage()
|
||||
handyKey := config.GetHandyKey()
|
||||
scriptOffset := config.GetFunscriptOffset()
|
||||
@@ -189,7 +183,6 @@ func makeConfigInterfaceResult() *ConfigInterfaceResult {
|
||||
JavascriptEnabled: &javascriptEnabled,
|
||||
CustomLocales: &customLocales,
|
||||
CustomLocalesEnabled: &customLocalesEnabled,
|
||||
DisableCustomizations: &disableCustomizations,
|
||||
Language: &language,
|
||||
|
||||
ImageLightbox: &imageLightboxOptions,
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"strconv"
|
||||
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stashapp/stash/pkg/sliceutil/stringslice"
|
||||
)
|
||||
|
||||
func (r *queryResolver) FindFolder(ctx context.Context, id *string, path *string) (*models.Folder, error) {
|
||||
@@ -48,7 +49,7 @@ func (r *queryResolver) FindFolders(
|
||||
) (ret *FindFoldersResultType, err error) {
|
||||
var folderIDs []models.FolderID
|
||||
if len(ids) > 0 {
|
||||
folderIDsInt, err := handleIDList(ids, "ids")
|
||||
folderIDsInt, err := stringslice.StringSliceToIntSlice(ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"strconv"
|
||||
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stashapp/stash/pkg/sliceutil/stringslice"
|
||||
)
|
||||
|
||||
func (r *queryResolver) FindGallery(ctx context.Context, id string) (ret *models.Gallery, err error) {
|
||||
@@ -24,7 +25,7 @@ func (r *queryResolver) FindGallery(ctx context.Context, id string) (ret *models
|
||||
}
|
||||
|
||||
func (r *queryResolver) FindGalleries(ctx context.Context, galleryFilter *models.GalleryFilterType, filter *models.FindFilterType, ids []string) (ret *FindGalleriesResultType, err error) {
|
||||
idInts, err := handleIDList(ids, "ids")
|
||||
idInts, err := stringslice.StringSliceToIntSlice(ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"strconv"
|
||||
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stashapp/stash/pkg/sliceutil/stringslice"
|
||||
)
|
||||
|
||||
func (r *queryResolver) FindGroup(ctx context.Context, id string) (ret *models.Group, err error) {
|
||||
@@ -24,7 +25,7 @@ func (r *queryResolver) FindGroup(ctx context.Context, id string) (ret *models.G
|
||||
}
|
||||
|
||||
func (r *queryResolver) FindGroups(ctx context.Context, groupFilter *models.GroupFilterType, filter *models.FindFilterType, ids []string) (ret *FindGroupsResultType, err error) {
|
||||
idInts, err := handleIDList(ids, "ids")
|
||||
idInts, err := stringslice.StringSliceToIntSlice(ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
"github.com/99designs/gqlgen/graphql"
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stashapp/stash/pkg/sliceutil/stringslice"
|
||||
)
|
||||
|
||||
func (r *queryResolver) FindImage(ctx context.Context, id *string, checksum *string) (*models.Image, error) {
|
||||
@@ -54,7 +55,7 @@ func (r *queryResolver) FindImages(
|
||||
filter *models.FindFilterType,
|
||||
) (ret *FindImagesResultType, err error) {
|
||||
if len(ids) > 0 {
|
||||
imageIds, err = handleIDList(ids, "ids")
|
||||
imageIds, err = stringslice.StringSliceToIntSlice(ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"strconv"
|
||||
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stashapp/stash/pkg/sliceutil/stringslice"
|
||||
)
|
||||
|
||||
func (r *queryResolver) FindMovie(ctx context.Context, id string) (ret *models.Group, err error) {
|
||||
@@ -24,7 +25,7 @@ func (r *queryResolver) FindMovie(ctx context.Context, id string) (ret *models.G
|
||||
}
|
||||
|
||||
func (r *queryResolver) FindMovies(ctx context.Context, movieFilter *models.GroupFilterType, filter *models.FindFilterType, ids []string) (ret *FindMoviesResultType, err error) {
|
||||
idInts, err := handleIDList(ids, "ids")
|
||||
idInts, err := stringslice.StringSliceToIntSlice(ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"strconv"
|
||||
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stashapp/stash/pkg/sliceutil/stringslice"
|
||||
)
|
||||
|
||||
func (r *queryResolver) FindPerformer(ctx context.Context, id string) (ret *models.Performer, err error) {
|
||||
@@ -25,7 +26,7 @@ func (r *queryResolver) FindPerformer(ctx context.Context, id string) (ret *mode
|
||||
|
||||
func (r *queryResolver) FindPerformers(ctx context.Context, performerFilter *models.PerformerFilterType, filter *models.FindFilterType, performerIDs []int, ids []string) (ret *FindPerformersResultType, err error) {
|
||||
if len(ids) > 0 {
|
||||
performerIDs, err = handleIDList(ids, "ids")
|
||||
performerIDs, err = stringslice.StringSliceToIntSlice(ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stashapp/stash/pkg/scene"
|
||||
"github.com/stashapp/stash/pkg/sliceutil/stringslice"
|
||||
)
|
||||
|
||||
func (r *queryResolver) FindScene(ctx context.Context, id *string, checksum *string) (*models.Scene, error) {
|
||||
@@ -82,7 +83,7 @@ func (r *queryResolver) FindScenes(
|
||||
filter *models.FindFilterType,
|
||||
) (ret *FindScenesResultType, err error) {
|
||||
if len(ids) > 0 {
|
||||
sceneIDs, err = handleIDList(ids, "ids")
|
||||
sceneIDs, err = stringslice.StringSliceToIntSlice(ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -227,7 +228,7 @@ func (r *queryResolver) ParseSceneFilenames(ctx context.Context, filter *models.
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func (r *queryResolver) FindDuplicateScenes(ctx context.Context, distance *int, durationDiff *float64, sceneFilter *models.SceneFilterType) (ret [][]*models.Scene, err error) {
|
||||
func (r *queryResolver) FindDuplicateScenes(ctx context.Context, distance *int, durationDiff *float64) (ret [][]*models.Scene, err error) {
|
||||
dist := 0
|
||||
durDiff := -1.
|
||||
if distance != nil {
|
||||
@@ -237,7 +238,7 @@ func (r *queryResolver) FindDuplicateScenes(ctx context.Context, distance *int,
|
||||
durDiff = *durationDiff
|
||||
}
|
||||
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
|
||||
ret, err = r.repository.Scene.FindDuplicates(ctx, dist, durDiff, sceneFilter)
|
||||
ret, err = r.repository.Scene.FindDuplicates(ctx, dist, durDiff)
|
||||
return err
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -4,10 +4,11 @@ import (
|
||||
"context"
|
||||
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stashapp/stash/pkg/sliceutil/stringslice"
|
||||
)
|
||||
|
||||
func (r *queryResolver) FindSceneMarkers(ctx context.Context, sceneMarkerFilter *models.SceneMarkerFilterType, filter *models.FindFilterType, ids []string) (ret *FindSceneMarkersResultType, err error) {
|
||||
idInts, err := handleIDList(ids, "ids")
|
||||
idInts, err := stringslice.StringSliceToIntSlice(ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"strconv"
|
||||
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stashapp/stash/pkg/sliceutil/stringslice"
|
||||
)
|
||||
|
||||
func (r *queryResolver) FindStudio(ctx context.Context, id string) (ret *models.Studio, err error) {
|
||||
@@ -25,7 +26,7 @@ func (r *queryResolver) FindStudio(ctx context.Context, id string) (ret *models.
|
||||
}
|
||||
|
||||
func (r *queryResolver) FindStudios(ctx context.Context, studioFilter *models.StudioFilterType, filter *models.FindFilterType, ids []string) (ret *FindStudiosResultType, err error) {
|
||||
idInts, err := handleIDList(ids, "ids")
|
||||
idInts, err := stringslice.StringSliceToIntSlice(ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"strconv"
|
||||
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stashapp/stash/pkg/sliceutil/stringslice"
|
||||
)
|
||||
|
||||
func (r *queryResolver) FindTag(ctx context.Context, id string) (ret *models.Tag, err error) {
|
||||
@@ -24,7 +25,7 @@ func (r *queryResolver) FindTag(ctx context.Context, id string) (ret *models.Tag
|
||||
}
|
||||
|
||||
func (r *queryResolver) FindTags(ctx context.Context, tagFilter *models.TagFilterType, filter *models.FindFilterType, ids []string) (ret *FindTagsResultType, err error) {
|
||||
idInts, err := handleIDList(ids, "ids")
|
||||
idInts, err := stringslice.StringSliceToIntSlice(ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -33,26 +33,15 @@ func (r *queryResolver) FindJob(ctx context.Context, input FindJobInput) (*Job,
|
||||
}
|
||||
|
||||
func jobToJobModel(j job.Job) *Job {
|
||||
subTasks := make([]string, len(j.Details))
|
||||
for i, t := range j.Details {
|
||||
subTasks[i] = sanitiseWebsocketString(t)
|
||||
}
|
||||
|
||||
var jobError *string
|
||||
if j.Error != nil {
|
||||
s := sanitiseWebsocketString(*j.Error)
|
||||
jobError = &s
|
||||
}
|
||||
|
||||
ret := &Job{
|
||||
ID: strconv.Itoa(j.ID),
|
||||
Status: JobStatus(j.Status),
|
||||
Description: sanitiseWebsocketString(j.Description),
|
||||
SubTasks: subTasks,
|
||||
Description: j.Description,
|
||||
SubTasks: j.Details,
|
||||
StartTime: j.StartTime,
|
||||
EndTime: j.EndTime,
|
||||
AddTime: j.AddTime,
|
||||
Error: jobError,
|
||||
Error: j.Error,
|
||||
}
|
||||
|
||||
if j.Progress != -1 {
|
||||
|
||||
@@ -8,8 +8,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 (r *queryResolver) SceneStreams(ctx context.Context, id *string) ([]*manager.SceneStreamEndpoint, error) {
|
||||
@@ -41,22 +39,7 @@ func (r *queryResolver) SceneStreams(ctx context.Context, id *string) ([]*manage
|
||||
|
||||
baseURL, _ := ctx.Value(BaseURLCtxKey).(string)
|
||||
builder := urlbuilders.NewSceneURLBuilder(baseURL, scene)
|
||||
apiKey := config.GetAPIKey()
|
||||
|
||||
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(scene, streamURL, config.GetMaxStreamingTranscodeSize())
|
||||
return manager.GetSceneStreamPaths(scene, builder.GetStreamURL(apiKey), config.GetMaxStreamingTranscodeSize())
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/stashapp/stash/pkg/match"
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
@@ -351,63 +350,7 @@ func (r *queryResolver) ScrapeSingleStudio(ctx context.Context, source scraper.S
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return nil, errors.New("stash_box_endpoint must be set")
|
||||
}
|
||||
|
||||
func (r *queryResolver) ScrapeSingleTag(ctx context.Context, source scraper.Source, input ScrapeSingleTagInput) ([]*models.ScrapedTag, error) {
|
||||
if source.StashBoxIndex != nil || source.StashBoxEndpoint != nil {
|
||||
b, err := resolveStashBox(source.StashBoxIndex, source.StashBoxEndpoint)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
client := r.newStashBoxClient(*b)
|
||||
|
||||
var ret []*models.ScrapedTag
|
||||
query := *input.Query
|
||||
out, err := client.QueryTag(ctx, query)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if out != nil {
|
||||
ret = append(ret, out...)
|
||||
}
|
||||
|
||||
if len(ret) > 0 {
|
||||
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
|
||||
for _, tag := range ret {
|
||||
if err := match.ScrapedTag(ctx, r.repository.Tag, tag, b.Endpoint); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// tag name query returns results that may not match the query exactly.
|
||||
// if there is an exact match, it should be first
|
||||
if query != "" {
|
||||
for i, result := range ret {
|
||||
if strings.EqualFold(result.Name, query) {
|
||||
// prepend exact match to the front of the slice
|
||||
if i != 0 {
|
||||
ret = append([]*models.ScrapedTag{result}, append(ret[:i], ret[i+1:]...)...)
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return nil, errors.New("stash_box_endpoint must be set")
|
||||
return nil, errors.New("stash_box_index must be set")
|
||||
}
|
||||
|
||||
func (r *queryResolver) ScrapeSinglePerformer(ctx context.Context, source scraper.Source, input ScrapeSinglePerformerInput) ([]*models.ScrapedPerformer, error) {
|
||||
|
||||
@@ -2,19 +2,11 @@ package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/stashapp/stash/internal/log"
|
||||
"github.com/stashapp/stash/internal/manager"
|
||||
)
|
||||
|
||||
// sanitiseWebsocketString is used to ensure that any strings sent over the websocket are valid UTF-8.
|
||||
// Any invalid UTF-8 sequences will be replaced with the Unicode replacement character (U+FFFD).
|
||||
// Invalid UTF-8 sequences can cause the websocket connection to be closed.
|
||||
func sanitiseWebsocketString(s string) string {
|
||||
return strings.ToValidUTF8(s, "\uFFFD")
|
||||
}
|
||||
|
||||
func getLogLevel(logType string) LogLevel {
|
||||
switch logType {
|
||||
case "progress":
|
||||
@@ -41,7 +33,7 @@ func logEntriesFromLogItems(logItems []log.LogItem) []*LogEntry {
|
||||
ret[i] = &LogEntry{
|
||||
Time: entry.Time,
|
||||
Level: getLogLevel(entry.Type),
|
||||
Message: sanitiseWebsocketString(entry.Message),
|
||||
Message: entry.Message,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ import (
|
||||
|
||||
"github.com/stashapp/stash/internal/manager"
|
||||
"github.com/stashapp/stash/internal/manager/config"
|
||||
"github.com/stashapp/stash/internal/static"
|
||||
"github.com/stashapp/stash/pkg/ffmpeg"
|
||||
"github.com/stashapp/stash/pkg/file/video"
|
||||
"github.com/stashapp/stash/pkg/fsutil"
|
||||
@@ -244,12 +243,6 @@ func (rs sceneRoutes) streamSegment(w http.ResponseWriter, r *http.Request, stre
|
||||
}
|
||||
|
||||
func (rs sceneRoutes) Screenshot(w http.ResponseWriter, r *http.Request) {
|
||||
// if default flag is set, return the default image
|
||||
if r.URL.Query().Get("default") == "true" {
|
||||
utils.ServeImage(w, r, static.ReadAll(static.DefaultSceneImage))
|
||||
return
|
||||
}
|
||||
|
||||
scene := r.Context().Value(sceneKey).(*models.Scene)
|
||||
|
||||
ss := manager.SceneServer{
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"runtime/debug"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -256,9 +255,6 @@ func Initialize() (*Server, error) {
|
||||
staticUI = statigz.FileServer(ui.UIBox.(fs.ReadDirFS))
|
||||
}
|
||||
|
||||
// handle favicon override
|
||||
r.HandleFunc("/favicon.ico", handleFavicon(staticUI))
|
||||
|
||||
// Serve the web app
|
||||
r.HandleFunc("/*", func(w http.ResponseWriter, r *http.Request) {
|
||||
ext := path.Ext(r.URL.Path)
|
||||
@@ -299,31 +295,6 @@ func Initialize() (*Server, error) {
|
||||
return server, nil
|
||||
}
|
||||
|
||||
func handleFavicon(staticUI *statigz.Server) func(w http.ResponseWriter, r *http.Request) {
|
||||
mgr := manager.GetInstance()
|
||||
cfg := mgr.Config
|
||||
|
||||
// check if favicon.ico exists in the config directory
|
||||
// if so, use that
|
||||
// otherwise, use the embedded one
|
||||
iconPath := filepath.Join(cfg.GetConfigPath(), "favicon.ico")
|
||||
exists, _ := fsutil.FileExists(iconPath)
|
||||
|
||||
if exists {
|
||||
logger.Debugf("Using custom favicon at %s", iconPath)
|
||||
}
|
||||
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
|
||||
if exists {
|
||||
http.ServeFile(w, r, iconPath)
|
||||
} else {
|
||||
staticUI.ServeHTTP(w, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Start starts the server. It listens on the configured address and port.
|
||||
// It calls ListenAndServeTLS if TLS is configured, otherwise it calls ListenAndServe.
|
||||
// Calls to Start are blocked until the server is shutdown.
|
||||
@@ -450,7 +421,7 @@ func cssHandler(c *config.Config) func(w http.ResponseWriter, r *http.Request) {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var paths []string
|
||||
|
||||
if c.GetCSSEnabled() && !c.GetDisableCustomizations() {
|
||||
if c.GetCSSEnabled() {
|
||||
// search for custom.css in current directory, then $HOME/.stash
|
||||
fn := c.GetCSSPath()
|
||||
exists, _ := fsutil.FileExists(fn)
|
||||
@@ -468,7 +439,7 @@ func javascriptHandler(c *config.Config) func(w http.ResponseWriter, r *http.Req
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
var paths []string
|
||||
|
||||
if c.GetJavascriptEnabled() && !c.GetDisableCustomizations() {
|
||||
if c.GetJavascriptEnabled() {
|
||||
// search for custom.js in current directory, then $HOME/.stash
|
||||
fn := c.GetJavascriptPath()
|
||||
exists, _ := fsutil.FileExists(fn)
|
||||
@@ -486,7 +457,7 @@ func customLocalesHandler(c *config.Config) func(w http.ResponseWriter, r *http.
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
buffer := bytes.Buffer{}
|
||||
|
||||
if c.GetCustomLocalesEnabled() && !c.GetDisableCustomizations() {
|
||||
if c.GetCustomLocalesEnabled() {
|
||||
// search for custom-locales.json in current directory, then $HOME/.stash
|
||||
path := c.GetCustomLocalesPath()
|
||||
exists, _ := fsutil.FileExists(path)
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/stashapp/stash/internal/manager/config"
|
||||
"github.com/stashapp/stash/pkg/signedurl"
|
||||
)
|
||||
|
||||
// userSigningKey returns the HMAC signing key for a given user.
|
||||
func userSigningKey(c *config.Config, _ string) []byte {
|
||||
return c.GetJWTSignKey()
|
||||
}
|
||||
|
||||
// signedParams generates signed URL query parameters for the given path prefix and user.
|
||||
func signedParams(c *config.Config, userID string, prefix string) url.Values {
|
||||
secret := userSigningKey(c, userID)
|
||||
cid := signedurl.GenerateCredentialID(secret, userID)
|
||||
expires := time.Now().Add(c.GetSignedURLExpiry())
|
||||
return signedurl.SignPrefix(prefix, secret, cid, expires)
|
||||
}
|
||||
|
||||
// resolveCredentialID maps a credential ID back to a username and their signing key.
|
||||
func resolveCredentialID(c *config.Config, cid string) (string, []byte, bool) {
|
||||
username := c.GetUsername()
|
||||
secret := userSigningKey(c, username)
|
||||
if signedurl.GenerateCredentialID(secret, username) == cid {
|
||||
return username, secret, true
|
||||
}
|
||||
return "", nil, false
|
||||
}
|
||||
@@ -24,30 +24,17 @@ func NewImageURLBuilder(baseURL string, image *models.Image) ImageURLBuilder {
|
||||
}
|
||||
}
|
||||
|
||||
// cacheBuster returns the value used to bust client-side caches. The image
|
||||
// content is immutable for a given file, so the checksum of the primary file
|
||||
// is preferred: this keeps the URL stable across metadata edits (rating,
|
||||
// o-counter, tags, etc.) so the browser can reuse its cached copy. When no
|
||||
// primary file is present the checksum is empty, in which case we fall back to
|
||||
// the updated timestamp.
|
||||
func (b ImageURLBuilder) cacheBuster() string {
|
||||
if b.Checksum != "" {
|
||||
return b.Checksum
|
||||
}
|
||||
return b.UpdatedAt
|
||||
}
|
||||
|
||||
func (b ImageURLBuilder) GetImageURL() string {
|
||||
return b.BaseURL + "/image/" + b.ImageID + "/image?t=" + b.cacheBuster()
|
||||
return b.BaseURL + "/image/" + b.ImageID + "/image?t=" + b.UpdatedAt
|
||||
}
|
||||
|
||||
func (b ImageURLBuilder) GetThumbnailURL() string {
|
||||
return b.BaseURL + "/image/" + b.ImageID + "/thumbnail?t=" + b.cacheBuster()
|
||||
return b.BaseURL + "/image/" + b.ImageID + "/thumbnail?t=" + b.UpdatedAt
|
||||
}
|
||||
|
||||
func (b ImageURLBuilder) GetPreviewURL() string {
|
||||
if exists, err := fsutil.FileExists(manager.GetInstance().Paths.Generated.GetClipPreviewPath(b.Checksum, models.DefaultGthumbWidth)); exists && err == nil {
|
||||
return b.BaseURL + "/image/" + b.ImageID + "/preview?t=" + b.cacheBuster()
|
||||
return b.BaseURL + "/image/" + b.ImageID + "/preview?" + b.UpdatedAt
|
||||
} else {
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
package urlbuilders
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestImageURLBuilder_cacheBuster(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
checksum string
|
||||
updatedAt string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "prefers checksum when present",
|
||||
checksum: "abc123",
|
||||
updatedAt: "1700000000",
|
||||
want: "abc123",
|
||||
},
|
||||
{
|
||||
name: "falls back to updatedAt when checksum is empty",
|
||||
checksum: "",
|
||||
updatedAt: "1700000000",
|
||||
want: "1700000000",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
b := ImageURLBuilder{Checksum: tt.checksum, UpdatedAt: tt.updatedAt}
|
||||
if got := b.cacheBuster(); got != tt.want {
|
||||
t.Errorf("cacheBuster() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageURLBuilder_GetImageURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
checksum string
|
||||
updatedAt string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "uses checksum so the URL is stable across metadata edits",
|
||||
checksum: "abc123",
|
||||
updatedAt: "1700000000",
|
||||
want: "http://localhost:9999/image/42/image?t=abc123",
|
||||
},
|
||||
{
|
||||
name: "falls back to updatedAt when there is no primary file",
|
||||
checksum: "",
|
||||
updatedAt: "1700000000",
|
||||
want: "http://localhost:9999/image/42/image?t=1700000000",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
b := ImageURLBuilder{
|
||||
BaseURL: "http://localhost:9999",
|
||||
ImageID: "42",
|
||||
Checksum: tt.checksum,
|
||||
UpdatedAt: tt.updatedAt,
|
||||
}
|
||||
if got := b.GetImageURL(); got != tt.want {
|
||||
t.Errorf("GetImageURL() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageURLBuilder_GetThumbnailURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
checksum string
|
||||
updatedAt string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "uses checksum so the URL is stable across metadata edits",
|
||||
checksum: "abc123",
|
||||
updatedAt: "1700000000",
|
||||
want: "http://localhost:9999/image/42/thumbnail?t=abc123",
|
||||
},
|
||||
{
|
||||
name: "falls back to updatedAt when there is no primary file",
|
||||
checksum: "",
|
||||
updatedAt: "1700000000",
|
||||
want: "http://localhost:9999/image/42/thumbnail?t=1700000000",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
b := ImageURLBuilder{
|
||||
BaseURL: "http://localhost:9999",
|
||||
ImageID: "42",
|
||||
Checksum: tt.checksum,
|
||||
UpdatedAt: tt.updatedAt,
|
||||
}
|
||||
if got := b.GetThumbnailURL(); got != tt.want {
|
||||
t.Errorf("GetThumbnailURL() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -57,28 +57,12 @@ func (b SceneURLBuilder) GetScreenshotURL() string {
|
||||
return b.BaseURL + "/scene/" + b.SceneID + "/screenshot?t=" + b.UpdatedAt
|
||||
}
|
||||
|
||||
func (b SceneURLBuilder) GetFunscriptURL(apiKey string) *url.URL {
|
||||
u, err := url.Parse(fmt.Sprintf("%s/scene/%s/funscript", b.BaseURL, b.SceneID))
|
||||
if err != nil {
|
||||
// shouldn't happen
|
||||
panic(err)
|
||||
}
|
||||
|
||||
if apiKey != "" {
|
||||
v := u.Query()
|
||||
v.Set("apikey", apiKey)
|
||||
u.RawQuery = v.Encode()
|
||||
}
|
||||
|
||||
return u
|
||||
}
|
||||
|
||||
func (b SceneURLBuilder) GetCaptionPath() string {
|
||||
return "/scene/" + b.SceneID + "/caption"
|
||||
func (b SceneURLBuilder) GetFunscriptURL() string {
|
||||
return b.BaseURL + "/scene/" + b.SceneID + "/funscript"
|
||||
}
|
||||
|
||||
func (b SceneURLBuilder) GetCaptionURL() string {
|
||||
return b.BaseURL + b.GetCaptionPath()
|
||||
return b.BaseURL + "/scene/" + b.SceneID + "/caption"
|
||||
}
|
||||
|
||||
func (b SceneURLBuilder) GetInteractiveHeatmapURL() string {
|
||||
|
||||
@@ -101,15 +101,16 @@ func createPerformer(ctx context.Context, pqb models.PerformerWriter) error {
|
||||
|
||||
func createStudio(ctx context.Context, qb models.StudioWriter, name string) (*models.Studio, error) {
|
||||
// create the studio
|
||||
studio := models.NewCreateStudioInput()
|
||||
studio.Name = name
|
||||
studio := models.Studio{
|
||||
Name: name,
|
||||
}
|
||||
|
||||
err := qb.Create(ctx, &studio)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return studio.Studio, nil
|
||||
return &studio, nil
|
||||
}
|
||||
|
||||
func createTag(ctx context.Context, qb models.TagWriter) error {
|
||||
@@ -118,7 +119,7 @@ func createTag(ctx context.Context, qb models.TagWriter) error {
|
||||
Name: testName,
|
||||
}
|
||||
|
||||
err := qb.Create(ctx, &models.CreateTagInput{Tag: &tag})
|
||||
err := qb.Create(ctx, &tag)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -365,10 +366,7 @@ func makeImage(expectedResult bool) *models.Image {
|
||||
}
|
||||
|
||||
func createImage(ctx context.Context, w models.ImageWriter, o *models.Image, f *models.ImageFile) error {
|
||||
err := w.Create(ctx, &models.CreateImageInput{
|
||||
Image: o,
|
||||
FileIDs: []models.FileID{f.ID},
|
||||
})
|
||||
err := w.Create(ctx, o, []models.FileID{f.ID})
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to create image with path '%s': %s", f.Path, err.Error())
|
||||
@@ -471,10 +469,7 @@ func makeGallery(expectedResult bool) *models.Gallery {
|
||||
}
|
||||
|
||||
func createGallery(ctx context.Context, w models.GalleryWriter, o *models.Gallery, f *models.BaseFile) error {
|
||||
err := w.Create(ctx, &models.CreateGalleryInput{
|
||||
Gallery: o,
|
||||
FileIDs: []models.FileID{f.ID},
|
||||
})
|
||||
err := w.Create(ctx, o, []models.FileID{f.ID})
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to create gallery with path '%s': %s", f.Path, err.Error())
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
package desktop
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
@@ -18,16 +17,6 @@ import (
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
var isDesktop bool
|
||||
|
||||
// InitIsDesktop sets the value of isDesktop.
|
||||
// Changed IsDesktop to be evaluated once at startup because if it is
|
||||
// checked while there are open terminal sessions (such as the ffmpeg hardware
|
||||
// encoding checks), it may return false.
|
||||
func InitIsDesktop() {
|
||||
isDesktop = isDesktopCheck()
|
||||
}
|
||||
|
||||
type FaviconProvider interface {
|
||||
GetFavicon() []byte
|
||||
GetFaviconPng() []byte
|
||||
@@ -70,33 +59,22 @@ func SendNotification(title string, text string) {
|
||||
}
|
||||
|
||||
func IsDesktop() bool {
|
||||
return isDesktop
|
||||
}
|
||||
|
||||
// isDesktop tries to determine if the application is running in a desktop environment
|
||||
// where desktop features like system tray and notifications should be enabled.
|
||||
func isDesktopCheck() bool {
|
||||
if isDoubleClickLaunched() {
|
||||
logger.Debug("Detected double-click launch")
|
||||
return true
|
||||
}
|
||||
|
||||
// Check if running under root
|
||||
if os.Getuid() == 0 {
|
||||
logger.Debug("Running as root, disabling desktop features")
|
||||
return false
|
||||
}
|
||||
// Check if stdin is a terminal
|
||||
if term.IsTerminal(int(os.Stdin.Fd())) {
|
||||
logger.Debug("Running in terminal, disabling desktop features")
|
||||
return false
|
||||
}
|
||||
if isService() {
|
||||
logger.Debug("Running as a service, disabling desktop features")
|
||||
return false
|
||||
}
|
||||
if IsServerDockerized() {
|
||||
logger.Debug("Running in docker, disabling desktop features")
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -156,17 +134,15 @@ func getIconPath() string {
|
||||
return path.Join(config.GetInstance().GetConfigPath(), "icon.png")
|
||||
}
|
||||
|
||||
func RevealInFileManager(path string) error {
|
||||
info, err := os.Stat(path)
|
||||
func RevealInFileManager(path string) {
|
||||
exists, err := fsutil.FileExists(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error checking path: %w", err)
|
||||
logger.Errorf("Error checking file: %s", err)
|
||||
return
|
||||
}
|
||||
|
||||
absPath, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error getting absolute path: %w", err)
|
||||
if exists && IsDesktop() {
|
||||
revealInFileManager(path)
|
||||
}
|
||||
return revealInFileManager(absPath, info)
|
||||
}
|
||||
|
||||
func getServerURL(path string) string {
|
||||
|
||||
@@ -4,11 +4,9 @@
|
||||
package desktop
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
|
||||
gosxnotifier "github.com/feederbox826/gosx-notifier"
|
||||
"github.com/kermieisinthehouse/gosx-notifier"
|
||||
"github.com/stashapp/stash/pkg/logger"
|
||||
)
|
||||
|
||||
@@ -34,11 +32,8 @@ func sendNotification(notificationTitle string, notificationText string) {
|
||||
}
|
||||
}
|
||||
|
||||
func revealInFileManager(path string, _ os.FileInfo) error {
|
||||
if err := exec.Command(`open`, `-R`, path).Run(); err != nil {
|
||||
return fmt.Errorf("error revealing path in Finder: %w", err)
|
||||
}
|
||||
return nil
|
||||
func revealInFileManager(path string) {
|
||||
exec.Command(`open`, `-R`, path)
|
||||
}
|
||||
|
||||
func isDoubleClickLaunched() bool {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user