Compare commits

..

1 Commits

Author SHA1 Message Date
DogmaDragon
fe7155b001 Fix capitalization in custom localisation heading [skip-ci] 2026-02-25 21:55:57 +02:00
579 changed files with 14433 additions and 23981 deletions

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

@@ -63,4 +63,4 @@ node_modules
/phasher
dist
.DS_Store
/.local*
/.local*

View File

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

View File

@@ -10,12 +10,10 @@ ifdef IS_WIN_SHELL
RM := del /s /q
RMDIR := rmdir /s /q
NOOP := @@
PREFIX := $(USERPROFILE)\\bin
else
RM := rm -f
RMDIR := rm -rf
NOOP := @:
PREFIX := $(HOME)/.local
endif
# set LDFLAGS environment variable to any extra ldflags required
@@ -42,6 +40,9 @@ GO_BUILD_FLAGS := $(GO_BUILD_FLAGS)
GO_BUILD_TAGS := $(GO_BUILD_TAGS)
GO_BUILD_TAGS += sqlite_stat4 sqlite_math_functions
# set STASH_NOLEGACY environment variable or uncomment to disable legacy browser support
# STASH_NOLEGACY := true
# set STASH_SOURCEMAPS environment variable or uncomment to enable UI sourcemaps
# STASH_SOURCEMAPS := true
@@ -49,17 +50,11 @@ export CGO_ENABLED := 1
# define COMPILER_IMAGE for cross-compilation docker container
ifndef COMPILER_IMAGE
COMPILER_IMAGE := ghcr.io/stashapp/compiler:latest
COMPILER_IMAGE := stashapp/compiler:latest
endif
# cannot really parallelise the release target
# generate requires pre-ui, ui requires generate and build-release requires ui, so they must be run sequentially
.PHONY: release
release:
$(MAKE) pre-ui
$(MAKE) generate
$(MAKE) ui
$(MAKE) build-release
release: pre-ui generate ui build-release
# targets to set various build flags
# use combinations on the make command-line to configure a build, e.g.:
@@ -103,7 +98,7 @@ flags-static-windows:
.PHONY: build-info
build-info:
ifndef BUILD_DATE
$(eval BUILD_DATE := $(shell GOOS=$$(go env GOHOSTOS) GOARCH=$$(go env GOHOSTARCH) go run scripts/getDate.go))
$(eval BUILD_DATE := $(shell go run scripts/getDate.go))
endif
ifndef GITHASH
$(eval GITHASH := $(shell git rev-parse --short HEAD))
@@ -134,7 +129,7 @@ phasher: build-flags
# builds dynamically-linked debug binaries
.PHONY: build
build: stash
build: stash phasher
# builds dynamically-linked PIE release binaries
.PHONY: build-release
@@ -192,6 +187,8 @@ build-cc-macos:
# Combine into universal binaries
lipo -create -output dist/stash-macos dist/stash-macos-intel dist/stash-macos-arm
rm dist/stash-macos-intel dist/stash-macos-arm
lipo -create -output dist/phasher-macos dist/phasher-macos-intel dist/phasher-macos-arm
rm dist/phasher-macos-intel dist/phasher-macos-arm
# Place into bundle and zip up
rm -rf dist/Stash.app
@@ -201,16 +198,6 @@ build-cc-macos:
cd dist && rm -f Stash.app.zip && zip -r Stash.app.zip Stash.app
rm -rf dist/Stash.app
.PHONY: build-cc-macos-phasher
build-cc-macos-phasher:
make build-cc-macos-arm
make build-cc-macos-intel
# Combine into universal binaries
lipo -create -output dist/phasher-macos dist/phasher-macos-intel dist/phasher-macos-arm
rm dist/phasher-macos-intel dist/phasher-macos-arm
# do not bundle phasher
.PHONY: build-cc-freebsd
build-cc-freebsd: export GOOS := freebsd
build-cc-freebsd: export GOARCH := amd64
@@ -288,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
View File

@@ -1,44 +1,40 @@
# Stash
[![Build](https://github.com/stashapp/stash/actions/workflows/build.yml/badge.svg?branch=develop&event=push)](https://github.com/stashapp/stash/actions/workflows/build.yml)
[![Docker pulls](https://img.shields.io/docker/pulls/stashapp/stash?logo=docker)](https://hub.docker.com/r/stashapp/stash 'DockerHub')
[![Docker pulls](https://img.shields.io/docker/pulls/stashapp/stash.svg)](https://hub.docker.com/r/stashapp/stash 'DockerHub')
[![GitHub Sponsors](https://img.shields.io/github/sponsors/stashapp?logo=github)](https://github.com/sponsors/stashapp)
[![Open Collective backers](https://img.shields.io/opencollective/backers/stashapp?logo=opencollective)](https://opencollective.com/stashapp)
[![Go Report Card](https://goreportcard.com/badge/github.com/stashapp/stash)](https://goreportcard.com/report/github.com/stashapp/stash)
[![Discord](https://img.shields.io/discord/559159668438728723.svg?logo=discord)](https://discord.gg/2TsNFKt)
[![GitHub release (latest by date)](https://img.shields.io/github/v/release/stashapp/stash?logo=github)](https://github.com/stashapp/stash/releases/latest)
[![Codeberg Translate](https://img.shields.io/weblate/progress/stash?server=https%3A%2F%2Ftranslate.codeberg.org&logo=weblate)](https://translate.codeberg.org/engage/stash/)
[![GitHub issues by-label](https://img.shields.io/github/issues-raw/stashapp/stash/bounty?logo=github)](https://github.com/stashapp/stash/labels/bounty)
[![GitHub issues by-label](https://img.shields.io/github/issues-raw/stashapp/stash/bounty)](https://github.com/stashapp/stash/labels/bounty)
<h3>Stash is a self-hosted webapp written in Go which organizes and serves your diverse content collection, catering to both your SFW and NSFW needs.</h3>
### **Stash is a self-hosted webapp written in Go which organizes and serves your diverse content collection, catering to both your SFW and NSFW needs.**
![Screenshot of Stash web application interface](docs/readme_assets/demo_image.png)
- Stash gathers information about videos in your collection from the internet, and is extensible through the use of community-built plugins for a large number of content producers and sites.
- Stash supports a wide variety of both video and image formats.
- You can tag videos and find them later.
- Stash provides statistics about performers, tags, studios and more.
* Stash gathers information about videos in your collection from the internet, and is extensible through the use of community-built plugins for a large number of content producers and sites.
* Stash supports a wide variety of both video and image formats.
* You can tag videos and find them later.
* Stash provides statistics about performers, tags, studios and more.
You can [watch a SFW demo video](https://vimeo.com/545323354) to see it in action.
For further information see [Support & Resources](#support--resources) section.
For further information you can consult the [documentation](https://docs.stashapp.cc) or 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
[![Translate](https://translate.codeberg.org/widget/stash/stash/svg-badge.svg)](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:
[![Translation status](https://translate.codeberg.org/widget/stash/stash/multi-auto.svg)](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-tos 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-tos 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!
[![Translation status](https://translate.codeberg.org/widget/stash/stash/multi-auto.svg)](https://translate.codeberg.org/engage/stash/)
See [Development](docs/DEVELOPMENT.md) and [Contributing](docs/CONTRIBUTING.md) for information on working with the codebase, getting a local development setup, and contributing changes.

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

@@ -1,22 +1,16 @@
host=ghcr.io
user=stashapp
repo=compiler
version=14
VERSION_IMAGE = ${host}/${user}/${repo}:${version}
LATEST_IMAGE = ${host}/${user}/${repo}:latest
version=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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,48 +1,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.**

View File

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

39
go.mod
View File

@@ -1,6 +1,6 @@
module github.com/stashapp/stash
go 1.25.0
go 1.24.3
require (
github.com/99designs/gqlgen v0.17.73
@@ -15,9 +15,6 @@ require (
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,8 +69,7 @@ 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.3.5 // 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
@@ -81,10 +77,6 @@ require (
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
@@ -99,7 +91,6 @@ 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/knadh/koanf/maps v0.1.2 // indirect
github.com/magiconair/properties v1.8.7 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
@@ -114,9 +105,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 +117,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
)

83
go.sum
View File

@@ -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 h1:PqbXLC3TkfeZyakF5eeh3NTWEbYl4VHNVeufANzDbKQ=
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/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=
@@ -174,18 +173,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 +187,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=
@@ -404,12 +389,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=
@@ -532,19 +517,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 +537,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 +587,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 +612,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 +640,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=
@@ -694,8 +665,8 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY
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 +680,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=
@@ -741,8 +712,8 @@ 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=
@@ -797,8 +768,8 @@ 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.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=
@@ -833,8 +804,8 @@ 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=
@@ -921,8 +892,8 @@ 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/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
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=
@@ -932,8 +903,8 @@ 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.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=
@@ -950,8 +921,8 @@ 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.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=
@@ -1019,8 +990,8 @@ 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=

View File

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

View File

@@ -152,15 +152,15 @@ 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."
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 career start year"
career_start: IntCriterionInput
"Filter by career end year"
career_end: IntCriterionInput
"Filter by tattoos"
tattoos: StringCriterionInput
"Filter by piercings"
@@ -177,8 +177,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"
@@ -222,8 +220,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 +245,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
@@ -603,8 +599,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!]
}
@@ -665,7 +659,7 @@ 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"
@@ -690,8 +684,6 @@ input TagFilterType {
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
@@ -824,7 +816,6 @@ input FolderFilterType {
NOT: FolderFilterType
path: StringCriterionInput
basename: StringCriterionInput
parent_folder: HierarchicalMultiCriterionInput
zip_file: MultiCriterionInput
@@ -933,7 +924,7 @@ input GenderCriterionInput {
}
input CircumcisionCriterionInput {
value: [CircumcisedEnum!]
value: [CircumisedEnum!]
modifier: CriterionModifier!
}

View File

@@ -99,8 +99,6 @@ input BulkGroupUpdateInput {
ids: [ID!]
# rating expressed as 1-100
rating100: Int
date: String
synopsis: String
studio_id: ID
director: String
urls: BulkUpdateStrings

View File

@@ -26,8 +26,6 @@ input GenerateMetadataInput {
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
@@ -131,14 +129,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!
}

View File

@@ -7,7 +7,7 @@ enum GenderEnum {
NON_BINARY
}
enum CircumcisedEnum {
enum CircumisedEnum {
CUT
UNCUT
}
@@ -29,10 +29,10 @@ type Performer {
measurements: String
fake_tits: String
penis_length: Float
circumcised: CircumcisedEnum
circumcised: CircumisedEnum
career_length: String @deprecated(reason: "Use career_start and career_end")
career_start: String
career_end: String
career_start: Int
career_end: Int
tattoos: String
piercings: String
alias_list: [String!]!
@@ -78,10 +78,10 @@ input PerformerCreateInput {
measurements: String
fake_tits: String
penis_length: Float
circumcised: CircumcisedEnum
circumcised: CircumisedEnum
career_length: String @deprecated(reason: "Use career_start and career_end")
career_start: String
career_end: String
career_start: Int
career_end: Int
tattoos: String
piercings: String
"Duplicate aliases and those equal to name will be ignored (case-insensitive)"
@@ -119,10 +119,10 @@ input PerformerUpdateInput {
measurements: String
fake_tits: String
penis_length: Float
circumcised: CircumcisedEnum
circumcised: CircumisedEnum
career_length: String @deprecated(reason: "Use career_start and career_end")
career_start: String
career_end: String
career_start: Int
career_end: Int
tattoos: String
piercings: String
"Duplicate aliases and those equal to name will be ignored (case-insensitive)"
@@ -165,10 +165,10 @@ input BulkPerformerUpdateInput {
measurements: String
fake_tits: String
penis_length: Float
circumcised: CircumcisedEnum
circumcised: CircumisedEnum
career_length: String @deprecated(reason: "Use career_start and career_end")
career_start: String
career_end: String
career_start: Int
career_end: Int
tattoos: String
piercings: String
"Duplicate aliases and those equal to name will result in an error (case-insensitive)"

View File

@@ -19,8 +19,8 @@ type ScrapedPerformer {
penis_length: String
circumcised: String
career_length: String @deprecated(reason: "Use career_start and career_end")
career_start: String
career_end: String
career_start: Int
career_end: Int
tattoos: String
piercings: String
# aliases must be comma-delimited to be parsed correctly
@@ -57,8 +57,8 @@ input ScrapedPerformerInput {
penis_length: String
circumcised: String
career_length: String @deprecated(reason: "Use career_start and career_end")
career_start: String
career_end: String
career_start: Int
career_end: Int
tattoos: String
piercings: String
aliases: String

View File

@@ -73,7 +73,6 @@ type ScrapedTag {
name: String!
description: String
alias_list: [String!]
parent: ScrapedTag
"Remote site ID, if applicable"
remote_site_id: String
}

View File

@@ -31,11 +31,6 @@ fragment TagFragment on Tag {
id
description
aliases
category {
id
name
description
}
}
fragment MeasurementsFragment on Measurements {

View File

@@ -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) {
@@ -83,15 +42,6 @@ func authenticateHandler() func(http.Handler) http.Handler {
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) {

View File

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

View File

@@ -5,7 +5,7 @@ package api
type key int
const (
galleryKey key = iota
galleryKey key = 0
performerKey
sceneKey
studioKey

View File

@@ -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
@@ -43,8 +43,7 @@ const (
type Loaders struct {
SceneByID *SceneLoader
SceneIDsByFileID *FileIDsRelatedIDsLoader
SceneFiles *RelatedFileIDsLoader
SceneFiles *SceneFileIDsLoader
ScenePlayCount *ScenePlayCountLoader
SceneOCount *SceneOCountLoader
ScenePlayHistory *ScenePlayHistoryLoader
@@ -52,14 +51,12 @@ type Loaders struct {
SceneLastPlayed *SceneLastPlayedLoader
SceneCustomFields *CustomFieldsLoader
ImageFiles *RelatedFileIDsLoader
GalleryFiles *RelatedFileIDsLoader
ImageFiles *ImageFileIDsLoader
GalleryFiles *GalleryFileIDsLoader
GalleryByID *GalleryLoader
GalleryIDsByFileID *FileIDsRelatedIDsLoader
GalleryCustomFields *CustomFieldsLoader
ImageByID *ImageLoader
ImageIDsByFileID *FileIDsRelatedIDsLoader
ImageCustomFields *CustomFieldsLoader
PerformerByID *PerformerLoader
@@ -68,17 +65,12 @@ type Loaders struct {
StudioByID *StudioLoader
StudioCustomFields *CustomFieldsLoader
TagByID *TagLoader
TagCustomFields *CustomFieldsLoader
TagByID *TagLoader
TagCustomFields *CustomFieldsLoader
GroupByID *GroupLoader
GroupCustomFields *CustomFieldsLoader
FileByID *FileLoader
FolderByID *FolderLoader
FolderParentFolderIDs *FolderRelatedFolderIDsLoader
FolderSubFolderIDs *FolderRelatedFolderIDsLoader
FileByID *FileLoader
FolderByID *FolderLoader
}
type Middleware struct {
@@ -94,21 +86,11 @@ 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,
@@ -119,11 +101,6 @@ func (m Middleware) Middleware(next http.Handler) http.Handler {
maxBatch: maxBatch,
fetch: m.fetchImages(ctx),
},
ImageIDsByFileID: &FileIDsRelatedIDsLoader{
wait: wait,
maxBatch: maxBatch,
fetch: m.fetchImageIDsByFileID(ctx),
},
ImageCustomFields: &CustomFieldsLoader{
wait: wait,
maxBatch: maxBatch,
@@ -184,27 +161,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,17 +231,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 {
@@ -299,17 +255,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 {
@@ -334,17 +279,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 {
@@ -472,28 +406,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 {

View File

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

View File

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

View File

@@ -9,10 +9,10 @@ import (
"github.com/stashapp/stash/pkg/models"
)
// 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)
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -10,6 +10,7 @@ import (
"github.com/stashapp/stash/pkg/image"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/performer"
"github.com/stashapp/stash/pkg/utils"
)
func (r *performerResolver) AliasList(ctx context.Context, obj *models.Performer) ([]string, error) {
@@ -109,28 +110,12 @@ 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)
ret := utils.FormatYearRange(obj.CareerStart, obj.CareerEnd)
return &ret, nil
}

View File

@@ -9,8 +9,6 @@ import (
"github.com/stashapp/stash/internal/api/urlbuilders"
"github.com/stashapp/stash/internal/manager"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/session"
"github.com/stashapp/stash/pkg/signedurl"
)
func convertVideoFile(f models.File) (*models.VideoFile, error) {
@@ -109,38 +107,15 @@ func (r *sceneResolver) Paths(ctx context.Context, obj *models.Scene) (*ScenePat
baseURL, _ := ctx.Value(BaseURLCtxKey).(string)
config := manager.GetInstance().Config
builder := urlbuilders.NewSceneURLBuilder(baseURL, obj)
var streamPath string
var captionBasePath string
if config.HasCredentials() {
userID := session.GetCurrentUserID(ctx)
if userID == nil {
return nil, fmt.Errorf("user ID not found")
}
// Sign the stream prefix
streamURL := builder.GetStreamURL("")
streamURL.RawQuery = signedParams(config, *userID, signedurl.DerivePrefix(streamURL.Path)).Encode()
streamPath = streamURL.String()
// Sign the caption prefix
captionBase := builder.GetCaptionURL()
captionBasePath = captionBase + "?" + signedParams(config, *userID, builder.GetCaptionPath()).Encode()
} else {
apiKey := config.GetAPIKey()
streamURL := builder.GetStreamURL(apiKey)
streamPath = streamURL.String()
captionBasePath = builder.GetCaptionURL()
}
// Web-only formats: use unsigned URLs (rely on cookie authentication)
screenshotPath := builder.GetScreenshotURL()
previewPath := builder.GetStreamPreviewURL()
streamPath := builder.GetStreamURL(config.GetAPIKey()).String()
webpPath := builder.GetStreamPreviewImageURL()
objHash := obj.GetHash(config.GetVideoFileNamingAlgorithm())
vttPath := builder.GetSpriteVTTURL(objHash)
spritePath := builder.GetSpriteURL(objHash)
funscriptPath := builder.GetFunscriptURL(config.GetAPIKey()).String()
funscriptPath := builder.GetFunscriptURL()
captionBasePath := builder.GetCaptionURL()
interactiveHeatmap := builder.GetInteractiveHeatmapURL()
return &ScenePathsType{
@@ -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) {

View File

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

View File

@@ -7,7 +7,6 @@ import (
"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"
@@ -20,7 +19,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 +57,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 +112,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)
}

View File

@@ -227,12 +227,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")

View File

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

View File

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

View File

@@ -52,6 +52,17 @@ 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.CareerStart = input.CareerStart
newPerformer.CareerEnd = input.CareerEnd
// if career_start/career_end not provided, parse deprecated career_length
if newPerformer.CareerStart == nil && newPerformer.CareerEnd == nil && input.CareerLength != nil {
start, end, err := utils.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.Tattoos = translator.string(input.Tattoos)
newPerformer.Piercings = translator.string(input.Piercings)
newPerformer.Favorite = translator.bool(input.Favorite)
@@ -89,25 +100,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)
@@ -281,25 +273,18 @@ func performerPartialFromInput(input models.PerformerUpdateInput, translator cha
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)
}
updatedPerformer.CareerStart = translator.optionalInt(input.CareerStart, "career_start")
updatedPerformer.CareerEnd = translator.optionalInt(input.CareerEnd, "career_end")
} else if translator.hasField("career_length") && input.CareerLength != nil {
start, end, err := models.ParseYearRangeString(*input.CareerLength)
start, end, err := utils.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)
updatedPerformer.CareerStart = models.NewOptionalInt(*start)
}
if end != nil {
updatedPerformer.CareerEnd = models.NewOptionalDate(*end)
updatedPerformer.CareerEnd = models.NewOptionalInt(*end)
}
}
updatedPerformer.Tattoos = translator.optionalString(input.Tattoos, "tattoos")
@@ -459,24 +444,18 @@ func (r *mutationResolver) BulkPerformerUpdate(ctx context.Context, input BulkPe
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)
}
updatedPerformer.CareerStart = translator.optionalInt(input.CareerStart, "career_start")
updatedPerformer.CareerEnd = translator.optionalInt(input.CareerEnd, "career_end")
} else if translator.hasField("career_length") && input.CareerLength != nil {
start, end, err := models.ParseYearRangeString(*input.CareerLength)
start, end, err := utils.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)
updatedPerformer.CareerStart = models.NewOptionalInt(*start)
}
if end != nil {
updatedPerformer.CareerEnd = models.NewOptionalDate(*end)
updatedPerformer.CareerEnd = models.NewOptionalInt(*end)
}
}
updatedPerformer.Tattoos = translator.optionalString(input.Tattoos, "tattoos")
@@ -654,7 +633,7 @@ func (r *mutationResolver) PerformerMerge(ctx context.Context, input PerformerMe
}
legacyURLs := legacyPerformerURLsFromInput(*input.Values, translator)
if legacyURLs.AnySet() {
return nil, errors.New("merging legacy performer URLs is not supported")
return nil, errors.New("Merging legacy performer URLs is not supported")
}
if input.Values.Image != nil {

View File

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

View File

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

View File

@@ -6,7 +6,6 @@ import (
"fmt"
"slices"
"strconv"
"strings"
"github.com/stashapp/stash/pkg/match"
"github.com/stashapp/stash/pkg/models"
@@ -364,8 +363,7 @@ func (r *queryResolver) ScrapeSingleTag(ctx context.Context, source scraper.Sour
client := r.newStashBoxClient(*b)
var ret []*models.ScrapedTag
query := *input.Query
out, err := client.QueryTag(ctx, query)
out, err := client.QueryTag(ctx, *input.Query)
if err != nil {
return nil, err
@@ -385,22 +383,6 @@ func (r *queryResolver) ScrapeSingleTag(ctx context.Context, source scraper.Sour
}); 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
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,7 +8,7 @@ import (
"os"
"os/exec"
gosxnotifier "github.com/feederbox826/gosx-notifier"
gosxnotifier "github.com/kermieisinthehouse/gosx-notifier"
"github.com/stashapp/stash/pkg/logger"
)

View File

@@ -33,10 +33,8 @@ type MetadataOptions struct {
SetCoverImage *bool `json:"setCoverImage"`
SetOrganized *bool `json:"setOrganized"`
// defaults to true if not provided
// Deprecated: use PerformerGenders instead
IncludeMalePerformers *bool `json:"includeMalePerformers"`
// Filter to only include performers with these genders. If not provided, all genders are included.
PerformerGenders []models.GenderEnum `json:"performerGenders"`
// defaults to true if not provided

View File

@@ -22,8 +22,7 @@ type SceneMissingHashCounter interface {
// will ensure that all oshash values are set on all scenes.
func ValidateVideoFileNamingAlgorithm(ctx context.Context, qb SceneMissingHashCounter, newValue models.HashAlgorithm) error {
// if algorithm is being set to MD5, then all checksums must be present
switch newValue {
case models.HashAlgorithmMd5:
if newValue == models.HashAlgorithmMd5 {
missingMD5, err := qb.CountMissingChecksum(ctx)
if err != nil {
return err
@@ -32,7 +31,7 @@ func ValidateVideoFileNamingAlgorithm(ctx context.Context, qb SceneMissingHashCo
if missingMD5 > 0 {
return errors.New("some checksums are missing on scenes. Run Scan with calculateMD5 set to true")
}
case models.HashAlgorithmOshash:
} else if newValue == models.HashAlgorithmOshash {
missingOSHash, err := qb.CountMissingOSHash(ctx)
if err != nil {
return err

View File

@@ -10,7 +10,6 @@ import (
"runtime"
"strconv"
"strings"
"time"
"sync"
// "github.com/sasha-s/go-deadlock" // if you have deadlock issues
@@ -44,9 +43,6 @@ const (
Password = "password"
MaxSessionAge = "max_session_age"
SignedURLExpiry = "signed_url_expiry"
signedURLExpiryDefault = 60 * 60 * 4 // 4 hours in seconds
// SFWContentMode mode config key
SFWContentMode = "sfw_content_mode"
@@ -1233,21 +1229,6 @@ func (i *Config) GetMaxSessionAge() int {
return ret
}
// GetSignedURLExpiry gets the expiry time for signed URLs, in seconds.
// Defaults to 4 hours to accommodate long video playback sessions.
func (i *Config) GetSignedURLExpiry() time.Duration {
i.RLock()
defer i.RUnlock()
ret := signedURLExpiryDefault * time.Second
v := i.forKey(SignedURLExpiry)
if v.Exists(SignedURLExpiry) {
ret = time.Duration(v.Int(SignedURLExpiry)) * time.Second
}
return ret
}
// GetCustomServedFolders gets the map of custom paths to their applicable
// filesystem locations
func (i *Config) GetCustomServedFolders() utils.URLMap {

View File

@@ -38,12 +38,3 @@ func (s StashConfigs) GetStashFromDirPath(dirPath string) *StashConfig {
}
return nil
}
func (s StashConfigs) Paths() []string {
paths := make([]string, len(s))
for i, c := range s {
// #6618 - clean the path to ensure comparison works correctly
paths[i] = filepath.Clean(c.Path)
}
return paths
}

View File

@@ -48,12 +48,6 @@ type Action struct {
Speed float64
}
var utf8BOM = []byte{0xEF, 0xBB, 0xBF}
func unmarshalFunscriptData(data []byte, funscript *Script) error {
return json.Unmarshal(bytes.TrimPrefix(data, utf8BOM), funscript)
}
type GradientTable []struct {
Col colorful.Color
Pos float64
@@ -102,7 +96,7 @@ func (g *InteractiveHeatmapSpeedGenerator) LoadFunscriptData(path string, sceneD
}
var funscript Script
err = unmarshalFunscriptData(data, &funscript)
err = json.Unmarshal(data, &funscript)
if err != nil {
return Script{}, err
}
@@ -376,7 +370,7 @@ func LoadFunscriptData(path string) (Script, error) {
}
var funscript Script
err = unmarshalFunscriptData(data, &funscript)
err = json.Unmarshal(data, &funscript)
if err != nil {
return Script{}, err
}
@@ -414,7 +408,7 @@ func ConvertFunscriptToCSV(funscriptPath string) ([]byte, error) {
}
// I don't know whether the csv format requires int or float, so for now we'll use int
fmt.Fprintf(&buffer, "%d,%d\r\n", int(math.Round(action.At)), pos)
buffer.WriteString(fmt.Sprintf("%d,%d\r\n", int(math.Round(action.At)), pos))
}
return buffer.Bytes(), nil
}

View File

@@ -1,53 +0,0 @@
package manager
import (
"os"
"path/filepath"
"testing"
)
func TestLoadFunscriptDataHandlesUTF8BOM(t *testing.T) {
path := filepath.Join(t.TempDir(), "test.funscript")
data := append([]byte{0xEF, 0xBB, 0xBF}, []byte(`{"actions":[{"at":100,"pos":40},{"at":50,"pos":20}]}`)...)
if err := os.WriteFile(path, data, 0644); err != nil {
t.Fatal(err)
}
tests := []struct {
name string
load func() (Script, error)
}{
{
name: "load funscript data",
load: func() (Script, error) {
return LoadFunscriptData(path)
},
},
{
name: "heatmap generator load funscript data",
load: func() (Script, error) {
return NewInteractiveHeatmapSpeedGenerator(false).LoadFunscriptData(path, 1)
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
funscript, err := tt.load()
if err != nil {
t.Fatal(err)
}
if len(funscript.Actions) != 2 {
t.Fatalf("expected 2 actions, got %d", len(funscript.Actions))
}
if funscript.Actions[0].At != 50 || funscript.Actions[0].Pos != 20 {
t.Errorf("expected first action to be sorted to at=50 pos=20, got at=%v pos=%d", funscript.Actions[0].At, funscript.Actions[0].Pos)
}
if funscript.Actions[1].At != 100 || funscript.Actions[1].Pos != 40 {
t.Errorf("expected second action to be sorted to at=100 pos=40, got at=%v pos=%d", funscript.Actions[1].At, funscript.Actions[1].Pos)
}
})
}
}

View File

@@ -76,10 +76,9 @@ func performImport(ctx context.Context, i importer, duplicateBehaviour ImportDup
var id int
if existing != nil {
switch duplicateBehaviour {
case ImportDuplicateEnumFail:
if duplicateBehaviour == ImportDuplicateEnumFail {
return fmt.Errorf("existing object with name '%s'", name)
case ImportDuplicateEnumIgnore:
} else if duplicateBehaviour == ImportDuplicateEnumIgnore {
logger.Infof("Skipping existing object %q", name)
return nil
}

View File

@@ -74,28 +74,6 @@ func getScanPaths(inputPaths []string) []*config.StashConfig {
return ret
}
// Filters the input array for paths that are within the paths managed by stash
func filterStashPaths(inputPaths []string) []string {
if len(inputPaths) == 0 {
return inputPaths
}
stashPaths := config.GetInstance().GetStashPaths()
var ret []string
for _, p := range inputPaths {
s := stashPaths.GetStashFromDirPath(p)
if s == nil {
logger.Warnf("%s is not in the configured stash paths", p)
continue
}
ret = append(ret, p)
}
return ret
}
// ScanSubscribe subscribes to a notification that is triggered when a
// scan or clean is complete.
func (s *Manager) ScanSubscribe(ctx context.Context) <-chan bool {
@@ -145,8 +123,7 @@ func (s *Manager) Scan(ctx context.Context, input ScanMetadataInput) (int, error
ZipFileExtensions: cfg.GetGalleryExtensions(),
// ScanFilters is set in ScanJob.Execute
// HandlerRequiredFilters is set in ScanJob.Execute
RootPaths: cfg.GetStashPaths().Paths(),
Rescan: input.Rescan,
Rescan: input.Rescan,
}
scanJob := ScanJob{
@@ -314,8 +291,6 @@ type CleanMetadataInput struct {
Paths []string `json:"paths"`
// Do a dry run. Don't delete any files
DryRun bool `json:"dryRun"`
IgnoreZipFileContents bool `json:"ignoreZipFileContents"`
}
func (s *Manager) Clean(ctx context.Context, input CleanMetadataInput) int {
@@ -433,7 +408,7 @@ type StashBoxBatchTagInput struct {
ExcludeFields []string `json:"exclude_fields"`
// Refresh items already tagged by StashBox if true. Only tag items with no StashBox tagging if false
Refresh bool `json:"refresh"`
// If batch adding studios or tags, should their parent entities also be created?
// If batch adding studios, should their parent studios also be created?
CreateParent bool `json:"createParent"`
// IDs in stash of the items to update.
// If set, names and stash_ids fields will be ignored.
@@ -751,7 +726,6 @@ func (s *Manager) batchTagTagsByIds(ctx context.Context, input StashBoxBatchTagI
if (input.Refresh && hasStashID) || (!input.Refresh && !hasStashID) {
tasks = append(tasks, &stashBoxBatchTagTagTask{
tag: t,
createParent: input.CreateParent,
box: box,
excludedFields: input.ExcludeFields,
})
@@ -772,7 +746,6 @@ func (s *Manager) batchTagTagsByNamesOrStashIds(input StashBoxBatchTagInput, box
if len(stashID) > 0 {
tasks = append(tasks, &stashBoxBatchTagTagTask{
stashID: &stashID,
createParent: input.CreateParent,
box: box,
excludedFields: input.ExcludeFields,
})
@@ -784,7 +757,6 @@ func (s *Manager) batchTagTagsByNamesOrStashIds(input StashBoxBatchTagInput, box
if len(name) > 0 {
tasks = append(tasks, &stashBoxBatchTagTagTask{
name: &name,
createParent: input.CreateParent,
box: box,
excludedFields: input.ExcludeFields,
})
@@ -811,7 +783,6 @@ func (s *Manager) batchTagAllTags(ctx context.Context, input StashBoxBatchTagInp
for _, t := range tags {
tasks = append(tasks, &stashBoxBatchTagTagTask{
tag: t,
createParent: input.CreateParent,
box: box,
excludedFields: input.ExcludeFields,
})

View File

@@ -1,268 +0,0 @@
//go:build integration
// +build integration
package manager
import (
"context"
"io/fs"
"os"
"path/filepath"
"testing"
"github.com/stashapp/stash/pkg/file"
// Necessary to register custom migrations.
_ "github.com/stashapp/stash/pkg/sqlite/migrations"
)
// stashIgnorePathFilter wraps StashIgnoreFilter to implement PathFilter for testing.
// It provides a fixed library root for the filter.
type stashIgnorePathFilter struct {
filter *file.StashIgnoreFilter
libraryRoot string
}
func (f *stashIgnorePathFilter) Accept(ctx context.Context, path string, info fs.FileInfo, zipFilePath string) bool {
return f.filter.Accept(ctx, path, info, f.libraryRoot, zipFilePath)
}
// createTestFileOnDisk creates a file with some content.
func createTestFileOnDisk(t *testing.T, dir, name string) string {
t.Helper()
path := filepath.Join(dir, name)
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
t.Fatalf("failed to create directory for %s: %v", path, err)
}
// Write some content so the file has a non-zero size.
if err := os.WriteFile(path, []byte("test content for "+name), 0644); err != nil {
t.Fatalf("failed to create file %s: %v", path, err)
}
return path
}
// createStashIgnoreFile creates a .stashignore file with the given content.
func createStashIgnoreFile(t *testing.T, dir, content string) {
t.Helper()
path := filepath.Join(dir, ".stashignore")
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
t.Fatalf("failed to create .stashignore: %v", err)
}
}
func TestScannerWithStashIgnore(t *testing.T) {
// Create temp directory structure.
tmpDir := t.TempDir()
// Create test files.
createTestFileOnDisk(t, tmpDir, "video1.mp4")
createTestFileOnDisk(t, tmpDir, "video2.mp4")
createTestFileOnDisk(t, tmpDir, "ignore_me.mp4")
createTestFileOnDisk(t, tmpDir, "subdir/video3.mp4")
createTestFileOnDisk(t, tmpDir, "subdir/skip_this.mp4")
createTestFileOnDisk(t, tmpDir, "excluded_dir/video4.mp4")
createTestFileOnDisk(t, tmpDir, "temp/processing.mp4")
// Create .stashignore file.
stashignore := `# Ignore specific files
ignore_me.mp4
subdir/skip_this.mp4
# Ignore directories
excluded_dir/
temp/
`
createStashIgnoreFile(t, tmpDir, stashignore)
// Create stashignore filter with library root.
stashIgnoreFilter := &stashIgnorePathFilter{
filter: file.NewStashIgnoreFilter(),
libraryRoot: tmpDir,
}
// Create scanner.
scanner := &file.Scanner{
ScanFilters: []file.PathFilter{stashIgnoreFilter},
}
testScenarios := []struct {
path string
accepted bool
}{
{filepath.Join(tmpDir, "video1.mp4"), true},
{filepath.Join(tmpDir, "video2.mp4"), true},
{filepath.Join(tmpDir, "ignore_me.mp4"), false},
{filepath.Join(tmpDir, "subdir/video3.mp4"), true},
{filepath.Join(tmpDir, "subdir/skip_this.mp4"), false},
{filepath.Join(tmpDir, "excluded_dir/video4.mp4"), false},
{filepath.Join(tmpDir, "temp/processing.mp4"), false},
}
ctx := context.Background()
for _, scenario := range testScenarios {
info, err := os.Stat(scenario.path)
if err != nil {
t.Fatalf("failed to stat file %s: %v", scenario.path, err)
}
accepted := scanner.AcceptEntry(ctx, scenario.path, info, "")
if accepted != scenario.accepted {
t.Errorf("unexpected accept result for %s: expected %v, got %v",
scenario.path, scenario.accepted, accepted)
}
}
}
func TestScannerWithNestedStashIgnore(t *testing.T) {
// Create temp directory structure.
tmpDir := t.TempDir()
// Create test files.
createTestFileOnDisk(t, tmpDir, "root.mp4")
createTestFileOnDisk(t, tmpDir, "root.tmp")
createTestFileOnDisk(t, tmpDir, "subdir/sub.mp4")
createTestFileOnDisk(t, tmpDir, "subdir/sub.log")
createTestFileOnDisk(t, tmpDir, "subdir/sub.tmp")
// Root .stashignore excludes *.tmp.
createStashIgnoreFile(t, tmpDir, "*.tmp\n")
// Subdir .stashignore excludes *.log.
createStashIgnoreFile(t, filepath.Join(tmpDir, "subdir"), "*.log\n")
// Create stashignore filter with library root.
stashIgnoreFilter := &stashIgnorePathFilter{
filter: file.NewStashIgnoreFilter(),
libraryRoot: tmpDir,
}
// Create scanner.
scanner := &file.Scanner{
ScanFilters: []file.PathFilter{stashIgnoreFilter},
}
testScenarios := []struct {
path string
accepted bool
}{
{filepath.Join(tmpDir, "root.mp4"), true},
{filepath.Join(tmpDir, "root.tmp"), false},
{filepath.Join(tmpDir, "subdir/sub.mp4"), true},
{filepath.Join(tmpDir, "subdir/sub.log"), false},
{filepath.Join(tmpDir, "subdir/sub.tmp"), false},
}
ctx := context.Background()
for _, scenario := range testScenarios {
info, err := os.Stat(scenario.path)
if err != nil {
t.Fatalf("failed to stat file %s: %v", scenario.path, err)
}
accepted := scanner.AcceptEntry(ctx, scenario.path, info, "")
if accepted != scenario.accepted {
t.Errorf("unexpected accept result for %s: expected %v, got %v",
scenario.path, scenario.accepted, accepted)
}
}
}
func TestScannerWithoutStashIgnore(t *testing.T) {
// Create temp directory structure (no .stashignore).
tmpDir := t.TempDir()
// Create test files.
createTestFileOnDisk(t, tmpDir, "video1.mp4")
createTestFileOnDisk(t, tmpDir, "video2.mp4")
createTestFileOnDisk(t, tmpDir, "subdir/video3.mp4")
// Create stashignore filter with library root (but no .stashignore file exists).
stashIgnoreFilter := &stashIgnorePathFilter{
filter: file.NewStashIgnoreFilter(),
libraryRoot: tmpDir,
}
// Create scanner.
scanner := &file.Scanner{
ScanFilters: []file.PathFilter{stashIgnoreFilter},
}
testScenarios := []struct {
path string
accepted bool
}{
{filepath.Join(tmpDir, "video1.mp4"), true},
{filepath.Join(tmpDir, "video2.mp4"), true},
{filepath.Join(tmpDir, "subdir/video3.mp4"), true},
}
ctx := context.Background()
for _, scenario := range testScenarios {
info, err := os.Stat(scenario.path)
if err != nil {
t.Fatalf("failed to stat file %s: %v", scenario.path, err)
}
accepted := scanner.AcceptEntry(ctx, scenario.path, info, "")
if accepted != scenario.accepted {
t.Errorf("unexpected accept result for %s: expected %v, got %v",
scenario.path, scenario.accepted, accepted)
}
}
}
func TestScannerWithNegationPattern(t *testing.T) {
// Create temp directory structure.
tmpDir := t.TempDir()
// Create test files.
createTestFileOnDisk(t, tmpDir, "file1.tmp")
createTestFileOnDisk(t, tmpDir, "file2.tmp")
createTestFileOnDisk(t, tmpDir, "keep_this.tmp")
createTestFileOnDisk(t, tmpDir, "video.mp4")
// Create .stashignore with negation.
stashignore := `*.tmp
!keep_this.tmp
`
createStashIgnoreFile(t, tmpDir, stashignore)
// Create stashignore filter with library root.
stashIgnoreFilter := &stashIgnorePathFilter{
filter: file.NewStashIgnoreFilter(),
libraryRoot: tmpDir,
}
// Create scanner.
scanner := &file.Scanner{
ScanFilters: []file.PathFilter{stashIgnoreFilter},
}
testScenarios := []struct {
path string
accepted bool
}{
{filepath.Join(tmpDir, "file1.tmp"), false},
{filepath.Join(tmpDir, "file2.tmp"), false},
{filepath.Join(tmpDir, "keep_this.tmp"), true},
{filepath.Join(tmpDir, "video.mp4"), true},
}
ctx := context.Background()
for _, scenario := range testScenarios {
info, err := os.Stat(scenario.path)
if err != nil {
t.Fatalf("failed to stat file %s: %v", scenario.path, err)
}
accepted := scanner.AcceptEntry(ctx, scenario.path, info, "")
if accepted != scenario.accepted {
t.Errorf("unexpected accept result for %s: expected %v, got %v",
scenario.path, scenario.accepted, accepted)
}
}
}

View File

@@ -313,36 +313,9 @@ func (j *CleanGeneratedJob) cleanBlobFiles(ctx context.Context, progress *job.Pr
return err
}
// remove empty hash prefix subdirectories
j.removeEmptyDirs(j.Paths.Blobs)
return nil
}
func (j *CleanGeneratedJob) removeEmptyDirs(root string) {
entries, err := os.ReadDir(root)
if err != nil {
return
}
for _, entry := range entries {
if !entry.IsDir() {
continue
}
dirPath := filepath.Join(root, entry.Name())
subEntries, err := os.ReadDir(dirPath)
if err != nil {
continue
}
if len(subEntries) == 0 {
j.logDelete("removing empty directory: %s", entry.Name())
j.deleteDir(dirPath)
}
}
}
func (j *CleanGeneratedJob) getScenesWithHash(ctx context.Context, hash string) ([]*models.Scene, error) {
fp := models.Fingerprint{
Fingerprint: hash,
@@ -664,8 +637,6 @@ func (j *CleanGeneratedJob) cleanMarkerFiles(ctx context.Context, progress *job.
return err
}
j.removeEmptyDirs(j.Paths.Generated.Markers)
return nil
}
@@ -759,7 +730,5 @@ func (j *CleanGeneratedJob) cleanThumbnailFiles(ctx context.Context, progress *j
return err
}
j.removeEmptyDirs(j.Paths.Generated.Thumbnails)
return nil
}

View File

@@ -7,8 +7,6 @@ import (
"os"
"path/filepath"
"github.com/stashapp/stash/internal/manager/config"
"github.com/stashapp/stash/pkg/fsutil"
"github.com/stashapp/stash/pkg/job"
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/sqlite"
@@ -31,21 +29,6 @@ type databaseSchemaInfo struct {
StepsRequired uint
}
// PreExecute validates the environment before executing the migration.
// It returns an error if the migration cannot be performed.
func (s *MigrateJob) PreExecute() error {
// ensure backup directory exists and is writable
backupDir := s.Config.GetBackupDirectoryPathOrDefault()
if backupDir != "" {
if err := fsutil.EnsureDir(backupDir); err != nil {
logger.Errorf("error ensuring backup directory exists: %s", err)
logger.Warnf("Backup directory (%s) must be modified to a valid directory or removed from the config file", config.BackupDirectoryPath)
return fmt.Errorf("error creating backup directory: %w", err)
}
}
return nil
}
func (s *MigrateJob) Execute(ctx context.Context, progress *job.Progress) error {
schemaInfo, err := s.required()
if err != nil {

View File

@@ -40,10 +40,9 @@ func (j *cleanJob) Execute(ctx context.Context, progress *job.Progress) error {
}
j.cleaner.Clean(ctx, file.CleanOptions{
Paths: j.input.Paths,
DryRun: j.input.DryRun,
IgnoreZipFileContents: j.input.IgnoreZipFileContents,
PathFilter: newCleanFilter(instance.Config),
Paths: j.input.Paths,
DryRun: j.input.DryRun,
PathFilter: newCleanFilter(instance.Config),
}, progress)
if job.IsCancelled(ctx) {
@@ -155,12 +154,11 @@ func newCleanFilter(c *config.Config) *cleanFilter {
generatedPath: c.GetGeneratedPath(),
videoExcludeRegex: generateRegexps(c.GetExcludes()),
imageExcludeRegex: generateRegexps(c.GetImageExcludes()),
stashIgnoreFilter: file.NewStashIgnoreFilter(),
},
}
}
func (f *cleanFilter) Accept(ctx context.Context, path string, info fs.FileInfo, zipFilePath string) bool {
func (f *cleanFilter) Accept(ctx context.Context, path string, info fs.FileInfo) bool {
// #1102 - clean anything in generated path
generatedPath := f.generatedPath
@@ -175,18 +173,12 @@ func (f *cleanFilter) Accept(ctx context.Context, path string, info fs.FileInfo,
}
if stash == nil {
logger.Infof("%s not in any stash library directories. Marking to clean: %q", fileOrFolder, path)
logger.Infof("%s not in any stash library directories. Marking to clean: \"%s\"", fileOrFolder, path)
return false
}
if fsutil.IsPathInDir(generatedPath, path) {
logger.Infof("%s is in generated path. Marking to clean: %q", fileOrFolder, path)
return false
}
// Check .stashignore files, bounded to the library root.
if !f.stashIgnoreFilter.Accept(ctx, path, info, stash.Path, zipFilePath) {
logger.Infof("%s is excluded due to .stashignore. Marking to clean: %q", fileOrFolder, path)
logger.Infof("%s is in generated path. Marking to clean: \"%s\"", fileOrFolder, path)
return false
}

View File

@@ -43,8 +43,6 @@ type GenerateMetadataInput struct {
GalleryIDs []string `json:"galleryIDs"`
// overwrite existing media
Overwrite bool `json:"overwrite"`
// paths to run generate on, in addition to the other ID lists
Paths []string `json:"paths"`
}
type GeneratePreviewOptionsInput struct {
@@ -135,13 +133,8 @@ func (j *GenerateJob) Execute(ctx context.Context, progress *job.Progress) error
r := j.repository
if err := r.WithReadTxn(ctx, func(ctx context.Context) error {
qb := r.Scene
if len(j.input.SceneIDs) == 0 &&
len(j.input.MarkerIDs) == 0 &&
len(j.input.ImageIDs) == 0 &&
len(j.input.GalleryIDs) == 0 &&
len(j.input.Paths) == 0 {
j.queueTasks(ctx, g, nil, queue)
if len(j.input.SceneIDs) == 0 && len(j.input.MarkerIDs) == 0 && len(j.input.ImageIDs) == 0 && len(j.input.GalleryIDs) == 0 {
j.queueTasks(ctx, g, queue)
} else {
if len(j.input.SceneIDs) > 0 {
scenes, err = qb.FindMany(ctx, sceneIDs)
@@ -190,11 +183,6 @@ func (j *GenerateJob) Execute(ctx context.Context, progress *job.Progress) error
}
}
}
if len(j.input.Paths) > 0 {
paths := filterStashPaths(j.input.Paths)
j.queueTasks(ctx, g, paths, queue)
}
}
return nil
@@ -262,9 +250,7 @@ func (j *GenerateJob) Execute(ctx context.Context, progress *job.Progress) error
for f := range queue {
if job.IsCancelled(ctx) {
// keep draining the queue so the producer goroutine can finish
// and release its read transaction, otherwise the DB stays locked
continue
break
}
wg.Add()
@@ -290,18 +276,17 @@ func (j *GenerateJob) Execute(ctx context.Context, progress *job.Progress) error
return nil
}
func (j *GenerateJob) queueTasks(ctx context.Context, g *generate.Generator, paths []string, queue chan<- Task) {
func (j *GenerateJob) queueTasks(ctx context.Context, g *generate.Generator, queue chan<- Task) {
j.totals = totalsGenerate{}
j.queueScenesTasks(ctx, g, paths, queue)
j.queueImagesTasks(ctx, g, paths, queue)
j.queueScenesTasks(ctx, g, queue)
j.queueImagesTasks(ctx, g, queue)
}
func (j *GenerateJob) queueScenesTasks(ctx context.Context, g *generate.Generator, paths []string, queue chan<- Task) {
func (j *GenerateJob) queueScenesTasks(ctx context.Context, g *generate.Generator, queue chan<- Task) {
const batchSize = 1000
findFilter := models.BatchFindFilter(batchSize)
sceneFilter := scene.FilterFromPaths(paths)
r := j.repository
@@ -310,7 +295,7 @@ func (j *GenerateJob) queueScenesTasks(ctx context.Context, g *generate.Generato
return
}
scenes, err := scene.Query(ctx, r.Scene, sceneFilter, findFilter)
scenes, err := scene.Query(ctx, r.Scene, nil, findFilter)
if err != nil {
logger.Errorf("Error encountered queuing files to scan: %s", err.Error())
return
@@ -337,11 +322,10 @@ func (j *GenerateJob) queueScenesTasks(ctx context.Context, g *generate.Generato
}
}
func (j *GenerateJob) queueImagesTasks(ctx context.Context, g *generate.Generator, paths []string, queue chan<- Task) {
func (j *GenerateJob) queueImagesTasks(ctx context.Context, g *generate.Generator, queue chan<- Task) {
const batchSize = 1000
findFilter := models.BatchFindFilter(batchSize)
imageFilter := image.FilterFromPaths(paths)
r := j.repository
@@ -350,7 +334,7 @@ func (j *GenerateJob) queueImagesTasks(ctx context.Context, g *generate.Generato
return
}
images, err := image.Query(ctx, r.Image, imageFilter, findFilter)
images, err := image.Query(ctx, r.Image, nil, findFilter)
if err != nil {
logger.Errorf("Error encountered queuing files to scan: %s", err.Error())
return

View File

@@ -25,11 +25,7 @@ func (t *GenerateClipPreviewTask) Start(ctx context.Context) {
}
prevPath := GetInstance().Paths.Generated.GetClipPreviewPath(t.Image.Checksum, models.DefaultGthumbWidth)
f := t.Image.Files.Primary()
if f == nil {
return
}
filePath := f.Base().Path
filePath := t.Image.Files.Primary().Base().Path
clipPreviewOptions := image.ClipPreviewOptions{
InputArgs: GetInstance().Config.GetTranscodeInputArgs(),

View File

@@ -35,9 +35,6 @@ func (t *GenerateImageThumbnailTask) Start(ctx context.Context) {
thumbPath := GetInstance().Paths.Generated.GetThumbnailPath(t.Image.Checksum, models.DefaultGthumbWidth)
f := t.Image.Files.Primary()
if f == nil {
return
}
path := f.Base().Path
logger.Debugf("Generating thumbnail for %s", path)

View File

@@ -45,9 +45,6 @@ func (t *GenerateInteractiveHeatmapSpeedTask) Start(ctx context.Context) {
r := t.repository
if err := r.WithTxn(ctx, func(ctx context.Context) error {
primaryFile := t.Scene.Files.Primary()
if primaryFile == nil {
return nil
}
primaryFile.InteractiveSpeed = &median
if err := r.File.Update(ctx, primaryFile); err != nil {
return fmt.Errorf("updating interactive speed for %s: %w", primaryFile.Path, err)

View File

@@ -162,35 +162,34 @@ func (t *ImportTask) unzipFile() error {
continue
}
if err := t.unzipFileEntry(f, fn); err != nil {
if err := os.MkdirAll(filepath.Dir(fn), os.ModePerm); err != nil {
return err
}
o, err := os.OpenFile(fn, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode())
if err != nil {
return err
}
i, err := f.Open()
if err != nil {
o.Close()
return err
}
if _, err := io.Copy(o, i); err != nil {
o.Close()
i.Close()
return err
}
o.Close()
i.Close()
}
return nil
}
func (t *ImportTask) unzipFileEntry(f *zip.File, fn string) error {
if err := os.MkdirAll(filepath.Dir(fn), os.ModePerm); err != nil {
return err
}
o, err := os.OpenFile(fn, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode())
if err != nil {
return err
}
defer o.Close()
i, err := f.Open()
if err != nil {
return err
}
defer i.Close()
_, err = io.Copy(o, i)
return err
}
func (t *ImportTask) ImportPerformers(ctx context.Context) {
logger.Info("[performers] importing")

View File

@@ -35,7 +35,7 @@ func (j *OptimiseDatabaseJob) Execute(ctx context.Context, progress *job.Progres
return nil
}
if err != nil {
return fmt.Errorf("error analyzing database: %w", err)
return fmt.Errorf("Error analyzing database: %w", err)
}
progress.ExecuteTask("Vacuuming database", func() {

View File

@@ -20,12 +20,12 @@ func (s *Manager) RunPluginTask(
pluginProgress := make(chan float64)
task, err := s.PluginCache.CreateTask(ctx, pluginID, taskName, args, pluginProgress)
if err != nil {
return fmt.Errorf("error creating plugin task: %w", err)
return fmt.Errorf("Error creating plugin task: %w", err)
}
err = task.Start()
if err != nil {
return fmt.Errorf("error running plugin task: %w", err)
return fmt.Errorf("Error running plugin task: %w", err)
}
done := make(chan bool)

View File

@@ -26,7 +26,6 @@ import (
"github.com/stashapp/stash/pkg/scene"
"github.com/stashapp/stash/pkg/scene/generate"
"github.com/stashapp/stash/pkg/txn"
"github.com/stashapp/stash/pkg/utils"
)
type ScanJob struct {
@@ -36,8 +35,6 @@ type ScanJob struct {
fileQueue chan file.ScannedFile
count int
unmatchedCaptionFiles utils.MutexField[[]string]
}
func (j *ScanJob) Execute(ctx context.Context, progress *job.Progress) error {
@@ -76,8 +73,6 @@ func (j *ScanJob) Execute(ctx context.Context, progress *job.Progress) error {
j.scanner.ScanFilters = []file.PathFilter{newScanFilter(c, repo, minModTime)}
j.scanner.HandlerRequiredFilters = []file.Filter{newHandlerRequiredFilter(cfg, repo)}
logger.Infof("Starting scan of %d paths with %d parallel tasks", len(paths), nTasks)
j.runJob(ctx, paths, nTasks, progress)
taskQueue.Close()
@@ -88,7 +83,7 @@ func (j *ScanJob) Execute(ctx context.Context, progress *job.Progress) error {
}
elapsed := time.Since(start)
logger.Infof("Scan finished (%s)", elapsed)
logger.Info(fmt.Sprintf("Scan finished (%s)", elapsed))
j.subscriptions.notify()
return nil
@@ -171,33 +166,12 @@ func (j *ScanJob) queueFileFunc(ctx context.Context, f models.FS, zipFile *file.
return nil
}
zipFilePath := ""
if zipFile != nil {
zipFilePath = zipFile.Path
}
if !j.scanner.AcceptEntry(ctx, path, info, zipFilePath) {
if !j.scanner.AcceptEntry(ctx, path, info) {
if info.IsDir() {
logger.Debugf("Skipping directory %s", path)
return fs.SkipDir
}
// we don't include caption files in the file scan, but we do need
// to handle them
if fsutil.MatchExtension(path, video.CaptionExts) {
fileRepo := j.scanner.Repository.File
matched := video.AssociateCaptions(ctx, path, j.scanner.Repository.TxnManager, fileRepo, fileRepo)
if !matched {
logger.Debugf("No matching video file found for caption file %s", path)
j.unmatchedCaptionFiles.SetFunc(func(files []string) []string {
return append(files, path)
})
}
return nil
}
logger.Debugf("Skipping file %s", path)
return nil
}
@@ -283,10 +257,8 @@ func (j *ScanJob) processQueue(ctx context.Context, parallelTasks int, progress
for f := range j.fileQueue {
logger.Tracef("Processing queued file %s", f.Path)
if ctx.Err() != nil {
// Keep receiving until queueFiles closes the channel; otherwise
// the walker can block on send (full buffer) and never finish.
continue
if err := ctx.Err(); err != nil {
return
}
wg.Add()
@@ -337,55 +309,10 @@ func (j *ScanJob) handleFile(ctx context.Context, f file.ScannedFile, progress *
return err
}
// if this is a new video file, match it with any unmatched caption files
if r.New && len(j.unmatchedCaptionFiles.Get()) > 0 {
videoFile, _ := r.File.(*models.VideoFile)
if videoFile != nil {
// try to match any unmatched caption files to this video file
for _, captionPath := range j.unmatchedCaptionFiles.Get() {
if video.MatchesCaption(videoFile.Path, captionPath) {
video.AssociateCaptions(ctx, captionPath, j.scanner.Repository.TxnManager, j.scanner.Repository.File, j.scanner.Repository.File)
// remove from the unmatched list
j.unmatchedCaptionFiles.SetFunc(func(files []string) []string {
newFiles := make([]string, 0, len(files)-1)
for _, f := range files {
if f != captionPath {
newFiles = append(newFiles, f)
}
}
return newFiles
})
}
}
}
}
// clean captions - scene handler handles this as well, but
// unchanged files aren't processed by the scene handler
if r.IsUnchanged() {
videoFile, _ := r.File.(*models.VideoFile)
if videoFile != nil {
txnMgr := j.scanner.Repository.TxnManager
fileRepo := j.scanner.Repository.File
if err := txn.WithDatabase(ctx, txnMgr, func(ctx context.Context) error {
return video.CleanCaptions(ctx, videoFile, txnMgr, fileRepo)
}); err != nil {
logger.Errorf("Error cleaning captions: %v", err)
}
}
}
// handle rename should have already handled the contents of the zip file
// so shouldn't need to scan it again.
// Only scan zip contents if the file is new, the fingerprint changed,
// if a force rescan was requested, or if the handler was required because
// a related object (e.g. a deleted gallery) is missing and needs to be
// recreated from the contents.
// so shouldn't need to scan it again
if j.scanner.IsZipFile(f.Info.Name()) && (r.New || r.FingerprintChanged || j.scanner.Rescan || r.HandlerRequired) {
if (r.New || r.Updated) && j.scanner.IsZipFile(f.Info.Name()) {
ff := r.File
f.BaseFile = ff.Base()
@@ -397,8 +324,6 @@ func (j *ScanJob) handleFile(ctx context.Context, f file.ScannedFile, progress *
if err := j.scanZipFile(zipCtx, f, progress); err != nil {
logger.Errorf("Error scanning zip file %q: %v", f.Path, err)
}
} else if r.Updated && j.scanner.IsZipFile(f.Info.Name()) {
logger.Debugf("Skipping zip file scan for %q: fingerprint unchanged", f.Path)
}
return nil
@@ -453,10 +378,11 @@ type sceneFinder interface {
// handlerRequiredFilter returns true if a File's handler needs to be executed despite the file not being updated.
type handlerRequiredFilter struct {
extensionConfig
txnManager txn.Manager
SceneFinder sceneFinder
ImageFinder fileCounter
GalleryFinder galleryFinder
txnManager txn.Manager
SceneFinder sceneFinder
ImageFinder fileCounter
GalleryFinder galleryFinder
CaptionUpdater video.CaptionUpdater
FolderCache *lru.LRU[bool]
@@ -472,6 +398,7 @@ func newHandlerRequiredFilter(c *config.Config, repo models.Repository) *handler
SceneFinder: repo.Scene,
ImageFinder: repo.Image,
GalleryFinder: repo.Gallery,
CaptionUpdater: repo.File,
FolderCache: lru.New[bool](processes * 2),
videoFileNamingAlgorithm: c.GetVideoFileNamingAlgorithm(),
}
@@ -546,35 +473,65 @@ func (f *handlerRequiredFilter) Accept(ctx context.Context, ff models.File) bool
}
}
if isVideoFile {
// TODO - check if the cover exists
// hash := scene.GetHash(ff, f.videoFileNamingAlgorithm)
// ssPath := instance.Paths.Scene.GetScreenshotPath(hash)
// if exists, _ := fsutil.FileExists(ssPath); !exists {
// // if not, check if the file is a primary file for a scene
// scenes, err := f.SceneFinder.FindByPrimaryFileID(ctx, ff.Base().ID)
// if err != nil {
// // just ignore
// return false
// }
// if len(scenes) > 0 {
// // if it is, then it needs to be re-generated
// return true
// }
// }
// clean captions - scene handler handles this as well, but
// unchanged files aren't processed by the scene handler
videoFile, _ := ff.(*models.VideoFile)
if videoFile != nil {
if err := video.CleanCaptions(ctx, videoFile, f.txnManager, f.CaptionUpdater); err != nil {
logger.Errorf("Error cleaning captions: %v", err)
}
}
}
return false
}
type scanFilter struct {
extensionConfig
txnManager txn.Manager
txnManager txn.Manager
FileFinder models.FileFinder
CaptionUpdater video.CaptionUpdater
stashPaths config.StashConfigs
generatedPath string
videoExcludeRegex []*regexp.Regexp
imageExcludeRegex []*regexp.Regexp
minModTime time.Time
stashIgnoreFilter *file.StashIgnoreFilter
}
func newScanFilter(c *config.Config, repo models.Repository, minModTime time.Time) *scanFilter {
return &scanFilter{
extensionConfig: newExtensionConfig(c),
txnManager: repo.TxnManager,
FileFinder: repo.File,
CaptionUpdater: repo.File,
stashPaths: c.GetStashPaths(),
generatedPath: c.GetGeneratedPath(),
videoExcludeRegex: generateRegexps(c.GetExcludes()),
imageExcludeRegex: generateRegexps(c.GetImageExcludes()),
minModTime: minModTime,
stashIgnoreFilter: file.NewStashIgnoreFilter(),
}
}
func (f *scanFilter) Accept(ctx context.Context, path string, info fs.FileInfo, zipFilePath string) bool {
func (f *scanFilter) Accept(ctx context.Context, path string, info fs.FileInfo) bool {
if fsutil.IsPathInDir(f.generatedPath, path) {
logger.Warnf("Skipping %q as it overlaps with the generated folder", path)
return false
@@ -591,16 +548,19 @@ func (f *scanFilter) Accept(ctx context.Context, path string, info fs.FileInfo,
return false
}
// Check .stashignore files, bounded to the library root.
if !f.stashIgnoreFilter.Accept(ctx, path, info, s.Path, zipFilePath) {
logger.Debugf("Skipping %s due to .stashignore", path)
return false
}
isVideoFile := useAsVideo(path)
isImageFile := useAsImage(path)
isZipFile := fsutil.MatchExtension(path, f.zipExt)
// handle caption files
if fsutil.MatchExtension(path, video.CaptionExts) {
// we don't include caption files in the file scan, but we do need
// to handle them
video.AssociateCaptions(ctx, path, f.txnManager, f.FileFinder, f.CaptionUpdater)
return false
}
if !info.IsDir() && !isVideoFile && !isImageFile && !isZipFile {
logger.Debugf("Skipping %s as it does not match any known file extensions", path)
return false
@@ -664,9 +624,8 @@ func getScanHandlers(options ScanMetadataInput, taskQueue *job.TaskQueue, progre
&file.FilteredHandler{
Filter: file.FilterFunc(imageFileFilter),
Handler: &image.ScanHandler{
CreatorUpdater: r.Image,
GalleryFinder: r.Gallery,
SceneFinderUpdater: r.Scene,
CreatorUpdater: r.Image,
GalleryFinder: r.Gallery,
ScanGenerator: &imageGenerators{
input: options,
taskQueue: taskQueue,
@@ -695,10 +654,9 @@ func getScanHandlers(options ScanMetadataInput, taskQueue *job.TaskQueue, progre
&file.FilteredHandler{
Filter: file.FilterFunc(videoFileFilter),
Handler: &scene.ScanHandler{
CreatorUpdater: r.Scene,
GalleryFinderUpdater: r.Gallery,
CaptionUpdater: r.File,
PluginCache: pluginCache,
CreatorUpdater: r.Scene,
CaptionUpdater: r.File,
PluginCache: pluginCache,
ScanGenerator: &sceneGenerators{
input: options,
taskQueue: taskQueue,

View File

@@ -4,7 +4,6 @@ import (
"context"
"fmt"
"strconv"
"strings"
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/match"
@@ -542,7 +541,6 @@ type stashBoxBatchTagTagTask struct {
name *string
stashID *string
tag *models.Tag
createParent bool
excludedFields []string
}
@@ -590,11 +588,8 @@ func (t *stashBoxBatchTagTagTask) findStashBoxTag(ctx context.Context) (*models.
client := stashbox.NewClient(*t.box, stashbox.ExcludeTagPatterns(instance.Config.GetScraperExcludeTagPatterns()))
nameQuery := ""
switch {
case t.name != nil:
nameQuery = *t.name
results, err = client.QueryTag(ctx, *t.name)
case t.stashID != nil:
results, err = client.QueryTag(ctx, *t.stashID)
@@ -620,7 +615,6 @@ func (t *stashBoxBatchTagTagTask) findStashBoxTag(ctx context.Context) (*models.
if remoteID != "" {
results, err = client.QueryTag(ctx, remoteID)
} else {
nameQuery = t.tag.Name
results, err = client.QueryTag(ctx, t.tag.Name)
}
}
@@ -633,26 +627,10 @@ func (t *stashBoxBatchTagTagTask) findStashBoxTag(ctx context.Context) (*models.
return nil, nil
}
var result *models.ScrapedTag
// QueryTag returns tags that partially match the name, so find the exact match if searching by name
if nameQuery != "" {
for _, r := range results {
if strings.EqualFold(r.Name, nameQuery) {
result = r
break
}
}
} else {
result = results[0]
}
if result == nil {
return nil, nil
}
result := results[0]
if err := r.WithReadTxn(ctx, func(ctx context.Context) error {
return match.ScrapedTagHierarchy(ctx, r.Tag, result, t.box.Endpoint)
return match.ScrapedTag(ctx, r.Tag, result, t.box.Endpoint)
}); err != nil {
return nil, err
}
@@ -660,39 +638,6 @@ func (t *stashBoxBatchTagTagTask) findStashBoxTag(ctx context.Context) (*models.
return result, nil
}
func (t *stashBoxBatchTagTagTask) processParentTag(ctx context.Context, parent *models.ScrapedTag, excluded map[string]bool) error {
if parent.StoredID == nil {
// Create new parent tag
newParentTag := parent.ToTag(t.box.Endpoint, excluded)
r := instance.Repository
err := r.WithTxn(ctx, func(ctx context.Context) error {
qb := r.Tag
if err := tag.ValidateCreate(ctx, *newParentTag, qb); err != nil {
return err
}
if err := qb.Create(ctx, &models.CreateTagInput{Tag: newParentTag}); err != nil {
return err
}
storedID := strconv.Itoa(newParentTag.ID)
parent.StoredID = &storedID
return nil
})
if err != nil {
logger.Errorf("Failed to create parent tag %s: %v", parent.Name, err)
} else {
logger.Infof("Created parent tag %s", parent.Name)
}
return err
}
// Parent already exists — nothing to update for categories
return nil
}
func (t *stashBoxBatchTagTagTask) processMatchedTag(ctx context.Context, s *models.ScrapedTag, excluded map[string]bool) {
// Determine the tag ID to update — either from the task's tag or from the
// StoredID set by match.ScrapedTag (when batch adding by name and the tag
@@ -704,12 +649,6 @@ func (t *stashBoxBatchTagTagTask) processMatchedTag(ctx context.Context, s *mode
tagID, _ = strconv.Atoi(*s.StoredID)
}
if s.Parent != nil && t.createParent {
if err := t.processParentTag(ctx, s.Parent, excluded); err != nil {
return
}
}
if tagID > 0 {
r := instance.Repository
err := r.WithTxn(ctx, func(ctx context.Context) error {

View File

@@ -33,9 +33,6 @@ func (t *GenerateTranscodeTask) Start(ctx context.Context) {
}
f := t.Scene.Files.Primary()
if f == nil {
return
}
ffprobe := instance.FFProbe
var container ffmpeg.Container

View File

@@ -45,13 +45,13 @@ func (f *FFMpeg) InitHWSupport(ctx context.Context) {
// log if the initialization takes too long
const hwInitLogTimeoutSecondsDefault = 5
hwInitLogTimeout := hwInitLogTimeoutSecondsDefault * time.Second
timer := time.NewTimer(hwInitLogTimeout)
hwInitLogTimeoutSeconds := hwInitLogTimeoutSecondsDefault * time.Second
timer := time.NewTimer(hwInitLogTimeoutSeconds)
go func() {
select {
case <-timer.C:
logger.Warnf("[InitHWSupport] Hardware codec initialization is taking longer than %s...", hwInitLogTimeout)
logger.Warnf("[InitHWSupport] Hardware codec initialization is taking longer than %s...", hwInitLogTimeoutSeconds)
logger.Info("[InitHWSupport] Hardware encoding will not be available until initialization is complete.")
case <-done:
if !timer.Stop() {
@@ -96,16 +96,16 @@ func (f *FFMpeg) initHWSupport(ctx context.Context) {
// #6064 - add timeout to context to prevent hangs
const hwTestTimeoutSecondsDefault = 10
hwTestTimeout := hwTestTimeoutSecondsDefault * time.Second
hwTestTimeoutSeconds := hwTestTimeoutSecondsDefault * time.Second
// allow timeout to be overridden with environment variable
if timeout := os.Getenv("STASH_HW_TEST_TIMEOUT"); timeout != "" {
if seconds, err := strconv.Atoi(timeout); err == nil {
hwTestTimeout = time.Duration(seconds) * time.Second
hwTestTimeoutSeconds = time.Duration(seconds) * time.Second
}
}
testCtx, cancel := context.WithTimeout(ctx, hwTestTimeout)
testCtx, cancel := context.WithTimeout(ctx, hwTestTimeoutSeconds)
defer cancel()
cmd := f.Command(testCtx, args)
@@ -117,7 +117,7 @@ func (f *FFMpeg) initHWSupport(ctx context.Context) {
if err := cmd.Run(); err != nil {
if testCtx.Err() != nil {
logger.Debugf("[InitHWSupport] Codec %s test timed out after %s", codec, hwTestTimeout)
logger.Debugf("[InitHWSupport] Codec %s test timed out after %s", codec, hwTestTimeoutSeconds)
continue
}
@@ -185,12 +185,6 @@ func (f *FFMpeg) hwCanFullHWTranscode(ctx context.Context, codec VideoCodec, vf
// Prepend input for hardware encoding only
func (f *FFMpeg) hwDeviceInit(args Args, toCodec VideoCodec, fullhw bool) Args {
// check for custom /dev/dri device #6435
driDevice := os.Getenv("STASH_HW_DRI_DEVICE")
if driDevice == "" {
driDevice = "/dev/dri/renderD128"
}
switch toCodec {
case VideoCodecN264,
VideoCodecN264H:
@@ -207,7 +201,7 @@ func (f *FFMpeg) hwDeviceInit(args Args, toCodec VideoCodec, fullhw bool) Args {
case VideoCodecV264,
VideoCodecVVP9:
args = append(args, "-vaapi_device")
args = append(args, driDevice)
args = append(args, "/dev/dri/renderD128")
if fullhw {
args = append(args, "-hwaccel")
args = append(args, "vaapi")

View File

@@ -20,7 +20,6 @@ import (
"github.com/stashapp/stash/pkg/fsutil"
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/signedurl"
"github.com/stashapp/stash/pkg/utils"
"github.com/zencoder/go-dash/v3/mpd"
@@ -434,21 +433,26 @@ func serveHLSManifest(sm *StreamManager, w http.ResponseWriter, r *http.Request,
baseURL := prefix + baseUrl.String()
urlQuery := url.Values{}
copyAuthParams(urlQuery, r.URL.Query())
apikey := r.URL.Query().Get(apiKeyParamKey)
if resolution != "" {
urlQuery.Set(resolutionParamKey, resolution)
}
segQuery := ""
// TODO - this needs to be handled outside of this package
if apikey != "" {
urlQuery.Set(apiKeyParamKey, apikey)
}
urlQueryString := ""
if len(urlQuery) > 0 {
segQuery = "?" + urlQuery.Encode()
urlQueryString = "?" + urlQuery.Encode()
}
var buf bytes.Buffer
fmt.Fprint(&buf, "#EXTM3U\n")
fmt.Fprint(&buf, "#EXT-X-VERSION:3\n")
fmt.Fprint(&buf, "#EXT-X-MEDIA-SEQUENCE:0\n")
fmt.Fprintf(&buf, "#EXT-X-TARGETDURATION:%d\n", segmentLength)
@@ -464,7 +468,7 @@ func serveHLSManifest(sm *StreamManager, w http.ResponseWriter, r *http.Request,
}
fmt.Fprintf(&buf, "#EXTINF:%f,\n", thisLength)
fmt.Fprintf(&buf, "%s/%d.ts%s\n", baseURL, segment, segQuery)
fmt.Fprintf(&buf, "%s/%d.ts%s\n", baseURL, segment, urlQueryString)
leftover -= thisLength
segment++
@@ -476,25 +480,6 @@ func serveHLSManifest(sm *StreamManager, w http.ResponseWriter, r *http.Request,
utils.ServeStaticContent(w, r, buf.Bytes())
}
// Forward auth params to segment URLs. API key takes precedence
// over signed params since it is explicitly configured by the user.
// TODO - this needs to be handled outside of this package
func copyAuthParams(dest url.Values, src url.Values) {
apikey := src.Get(apiKeyParamKey)
if apikey != "" {
dest.Set(apiKeyParamKey, apikey)
} else {
cid := src.Get(signedurl.CIDParam)
expires := src.Get(signedurl.ExpiresParam)
sig := src.Get(signedurl.SigParam)
if cid != "" && expires != "" && sig != "" {
dest.Set(signedurl.CIDParam, cid)
dest.Set(signedurl.ExpiresParam, expires)
dest.Set(signedurl.SigParam, sig)
}
}
}
// serveDASHManifest serves a generated DASH manifest.
func serveDASHManifest(sm *StreamManager, w http.ResponseWriter, r *http.Request, vf *models.VideoFile, resolution string) {
if sm.cacheDir == "" {
@@ -544,10 +529,11 @@ func serveDASHManifest(sm *StreamManager, w http.ResponseWriter, r *http.Request
urlQuery := url.Values{}
// Forward auth params to segment URLs. API key takes precedence
// over signed params since it is explicitly configured by the user.
// TODO - this needs to be handled outside of this package
copyAuthParams(urlQuery, r.URL.Query())
apikey := r.URL.Query().Get(apiKeyParamKey)
if apikey != "" {
urlQuery.Set(apiKeyParamKey, apikey)
}
maxTranscodeSize := sm.config.GetMaxStreamingTranscodeSize().GetMaxResolution()
if resolution != "" {

View File

@@ -19,9 +19,6 @@ type ScreenshotOptions struct {
Verbosity ffmpeg.LogLevel
UseSelectFilter bool
// SlowSeek uses accurate seek by placing -ss after the input.
SlowSeek bool
}
func (o *ScreenshotOptions) setDefaults() {
@@ -63,14 +60,9 @@ func ScreenshotTime(input string, t float64, options ScreenshotOptions) ffmpeg.A
var args ffmpeg.Args
args = args.LogLevel(options.Verbosity)
args = args.Overwrite()
args = args.Seek(t)
if !options.SlowSeek {
args = args.Seek(t)
}
args = args.Input(input)
if options.SlowSeek {
args = args.Seek(t)
}
args = args.VideoFrames(1)
if options.Quality > 0 {

View File

@@ -1,51 +0,0 @@
package transcoder
import (
"reflect"
"testing"
)
func TestScreenshotTimeDefaultUsesFastSeek(t *testing.T) {
options := ScreenshotOptions{
OutputPath: "out.jpg",
OutputType: ScreenshotOutputTypeImage2,
}
got := ScreenshotTime("input.webm", 12.5, options)
want := []string{
"-v", "error",
"-y",
"-ss", "12.5",
"-i", "input.webm",
"-frames:v", "1",
"-f", "image2",
"out.jpg",
}
if !reflect.DeepEqual([]string(got), want) {
t.Fatalf("ScreenshotTime() = %#v, want %#v", []string(got), want)
}
}
func TestScreenshotTimeSlowSeek(t *testing.T) {
options := ScreenshotOptions{
OutputPath: "out.jpg",
OutputType: ScreenshotOutputTypeImage2,
SlowSeek: true,
}
got := ScreenshotTime("input.webm", 12.5, options)
want := []string{
"-v", "error",
"-y",
"-i", "input.webm",
"-ss", "12.5",
"-frames:v", "1",
"-f", "image2",
"out.jpg",
}
if !reflect.DeepEqual([]string(got), want) {
t.Fatalf("ScreenshotTime() = %#v, want %#v", []string(got), want)
}
}

View File

@@ -33,11 +33,6 @@ type cleanJob struct {
type CleanOptions struct {
Paths []string
// IgnoreZipFileContents will skip checking the contents of zip files 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.
IgnoreZipFileContents bool
// Do a dry run. Don't delete any files
DryRun bool
@@ -179,16 +174,13 @@ func (j *cleanJob) assessFiles(ctx context.Context, toDelete *deleteSet) error {
more := true
r := j.Repository
includeZipContents := !j.options.IgnoreZipFileContents
if err := r.WithReadTxn(ctx, func(ctx context.Context) error {
for more {
if job.IsCancelled(ctx) {
return nil
}
files, err := r.File.FindAllInPaths(ctx, j.options.Paths, includeZipContents, batchSize, offset)
files, err := r.File.FindAllInPaths(ctx, j.options.Paths, batchSize, offset)
if err != nil {
return fmt.Errorf("error querying for files: %w", err)
}
@@ -266,8 +258,6 @@ func (j *cleanJob) assessFolders(ctx context.Context, toDelete *deleteSet) error
offset := 0
progress := j.progress
includeZipContents := !j.options.IgnoreZipFileContents
more := true
r := j.Repository
if err := r.WithReadTxn(ctx, func(ctx context.Context) error {
@@ -276,7 +266,7 @@ func (j *cleanJob) assessFolders(ctx context.Context, toDelete *deleteSet) error
return nil
}
folders, err := r.Folder.FindAllInPaths(ctx, j.options.Paths, includeZipContents, batchSize, offset)
folders, err := r.Folder.FindAllInPaths(ctx, j.options.Paths, batchSize, offset)
if err != nil {
return fmt.Errorf("error querying for folders: %w", err)
}
@@ -358,14 +348,8 @@ func (j *cleanJob) shouldClean(ctx context.Context, f models.File) bool {
// run through path filter, if returns false then the file should be cleaned
filter := j.options.PathFilter
// need to get the zip file path if present
zipFilePath := ""
if f.Base().ZipFile != nil {
zipFilePath = f.Base().ZipFile.Base().Path
}
// don't log anything - assume filter will have logged the reason
return !filter.Accept(ctx, path, info, zipFilePath)
return !filter.Accept(ctx, path, info)
}
func (j *cleanJob) shouldCleanFolder(ctx context.Context, f *models.Folder) bool {
@@ -403,14 +387,8 @@ func (j *cleanJob) shouldCleanFolder(ctx context.Context, f *models.Folder) bool
// run through path filter, if returns false then the file should be cleaned
filter := j.options.PathFilter
// need to get the zip file path if present
zipFilePath := ""
if f.ZipFile != nil {
zipFilePath = f.ZipFile.Base().Path
}
// don't log anything - assume filter will have logged the reason
return !filter.Accept(ctx, path, info, zipFilePath)
return !filter.Accept(ctx, path, info)
}
func (j *cleanJob) deleteFile(ctx context.Context, fileID models.FileID, fn string) {

View File

@@ -4,7 +4,6 @@ import (
"context"
"fmt"
"path/filepath"
"slices"
"strings"
"time"
@@ -13,9 +12,8 @@ import (
)
// GetOrCreateFolderHierarchy gets the folder for the given path, or creates a folder hierarchy for the given path if one if no existing folder is found.
// Creates folder entries for each level of the hierarchy that doesn't already exist, up to the provided root paths.
// Does not create any folders in the file system.
func GetOrCreateFolderHierarchy(ctx context.Context, fc models.FolderFinderCreator, path string, rootPaths []string) (*models.Folder, error) {
// Does not create any folders in the file system
func GetOrCreateFolderHierarchy(ctx context.Context, fc models.FolderFinderCreator, path string) (*models.Folder, error) {
// get or create folder hierarchy
// assume case sensitive when searching for the folder
const caseSensitive = true
@@ -25,33 +23,17 @@ func GetOrCreateFolderHierarchy(ctx context.Context, fc models.FolderFinderCreat
}
if folder == nil {
var parentID *models.FolderID
if !slices.Contains(rootPaths, path) {
parentPath := filepath.Dir(path)
// safety check - don't allow parent path to be the same as the current path,
// otherwise we could end up in an infinite loop
if parentPath == path {
// #6618 - log a warning and return nil for the parent ID,
// which will cause the folder to be created with no parent
logger.Warnf("parent path is the same as the current path: %s", path)
return nil, nil
}
parent, err := GetOrCreateFolderHierarchy(ctx, fc, parentPath, rootPaths)
if err != nil {
return nil, err
}
parentID = &parent.ID
parentPath := filepath.Dir(path)
parent, err := GetOrCreateFolderHierarchy(ctx, fc, parentPath)
if err != nil {
return nil, err
}
now := time.Now()
folder = &models.Folder{
Path: path,
ParentFolderID: parentID,
ParentFolderID: &parent.ID,
DirEntry: models.DirEntry{
// leave mod time empty for now - it will be updated when the folder is scanned
},
@@ -59,8 +41,6 @@ func GetOrCreateFolderHierarchy(ctx context.Context, fc models.FolderFinderCreat
UpdatedAt: now,
}
logger.Infof("%s doesn't exist. Creating new folder entry...", path)
if err = fc.Create(ctx, folder); err != nil {
return nil, fmt.Errorf("creating folder %s: %w", path, err)
}
@@ -69,18 +49,12 @@ func GetOrCreateFolderHierarchy(ctx context.Context, fc models.FolderFinderCreat
return folder, nil
}
type zipHierarchyMover struct {
folderStore models.FolderReaderWriter
files models.FileFinderUpdater
rootPaths []string
}
func (m zipHierarchyMover) transferZipHierarchy(ctx context.Context, zipFileID models.FileID, oldPath string, newPath string) error {
if err := m.transferZipFolderHierarchy(ctx, zipFileID, oldPath, newPath); err != nil {
func transferZipHierarchy(ctx context.Context, folderStore models.FolderReaderWriter, files models.FileFinderUpdater, zipFileID models.FileID, oldPath string, newPath string) error {
if err := transferZipFolderHierarchy(ctx, folderStore, zipFileID, oldPath, newPath); err != nil {
return fmt.Errorf("moving folder hierarchy for file %s: %w", oldPath, err)
}
if err := m.transferZipFileEntries(ctx, zipFileID, oldPath, newPath); err != nil {
if err := transferZipFileEntries(ctx, folderStore, files, zipFileID, oldPath, newPath); err != nil {
return fmt.Errorf("moving zip file contents for file %s: %w", oldPath, err)
}
@@ -89,8 +63,8 @@ func (m zipHierarchyMover) transferZipHierarchy(ctx context.Context, zipFileID m
// transferZipFolderHierarchy creates the folder hierarchy for zipFileID under newPath, and removes
// ZipFileID from folders under oldPath.
func (m zipHierarchyMover) transferZipFolderHierarchy(ctx context.Context, zipFileID models.FileID, oldPath string, newPath string) error {
zipFolders, err := m.folderStore.FindByZipFileID(ctx, zipFileID)
func transferZipFolderHierarchy(ctx context.Context, folderStore models.FolderReaderWriter, zipFileID models.FileID, oldPath string, newPath string) error {
zipFolders, err := folderStore.FindByZipFileID(ctx, zipFileID)
if err != nil {
return err
}
@@ -109,7 +83,7 @@ func (m zipHierarchyMover) transferZipFolderHierarchy(ctx context.Context, zipFi
}
newZfPath := filepath.Join(newPath, relZfPath)
newFolder, err := GetOrCreateFolderHierarchy(ctx, m.folderStore, newZfPath, m.rootPaths)
newFolder, err := GetOrCreateFolderHierarchy(ctx, folderStore, newZfPath)
if err != nil {
return err
}
@@ -117,14 +91,14 @@ func (m zipHierarchyMover) transferZipFolderHierarchy(ctx context.Context, zipFi
// add ZipFileID to new folder
logger.Debugf("adding zip file %s to folder %s", zipFileID, newFolder.Path)
newFolder.ZipFileID = &zipFileID
if err = m.folderStore.Update(ctx, newFolder); err != nil {
if err = folderStore.Update(ctx, newFolder); err != nil {
return err
}
// remove ZipFileID from old folder
logger.Debugf("removing zip file %s from folder %s", zipFileID, oldFolder.Path)
oldFolder.ZipFileID = nil
if err = m.folderStore.Update(ctx, oldFolder); err != nil {
if err = folderStore.Update(ctx, oldFolder); err != nil {
return err
}
}
@@ -132,9 +106,9 @@ func (m zipHierarchyMover) transferZipFolderHierarchy(ctx context.Context, zipFi
return nil
}
func (m zipHierarchyMover) transferZipFileEntries(ctx context.Context, zipFileID models.FileID, oldPath, newPath string) error {
func transferZipFileEntries(ctx context.Context, folders models.FolderFinderCreator, files models.FileFinderUpdater, zipFileID models.FileID, oldPath, newPath string) error {
// move contained files if file is a zip file
zipFiles, err := m.files.FindByZipFileID(ctx, zipFileID)
zipFiles, err := files.FindByZipFileID(ctx, zipFileID)
if err != nil {
return fmt.Errorf("finding contained files in file %s: %w", oldPath, err)
}
@@ -155,7 +129,7 @@ func (m zipHierarchyMover) transferZipFileEntries(ctx context.Context, zipFileID
newZfDir := filepath.Join(newPath, relZfDir)
// folder should have been created by transferZipFolderHierarchy
newZfFolder, err := GetOrCreateFolderHierarchy(ctx, m.folderStore, newZfDir, m.rootPaths)
newZfFolder, err := GetOrCreateFolderHierarchy(ctx, folders, newZfDir)
if err != nil {
return fmt.Errorf("getting or creating folder hierarchy: %w", err)
}
@@ -163,7 +137,7 @@ func (m zipHierarchyMover) transferZipFileEntries(ctx context.Context, zipFileID
// update file parent folder
zfBase.ParentFolderID = newZfFolder.ID
logger.Debugf("moving %s to folder %s", zfBase.Path, newZfFolder.Path)
if err := m.files.Update(ctx, zf); err != nil {
if err := files.Update(ctx, zf); err != nil {
return fmt.Errorf("updating file %s: %w", oldZfPath, err)
}
}

View File

@@ -2,6 +2,7 @@ package file
import (
"context"
"errors"
"fmt"
"io/fs"
@@ -87,11 +88,6 @@ func (s *Scanner) detectFolderMove(ctx context.Context, file ScannedFile) (*mode
r := s.Repository
zipFilePath := ""
if file.ZipFile != nil {
zipFilePath = file.ZipFile.Base().Path
}
if err := SymWalk(file.FS, file.Path, func(path string, d fs.DirEntry, err error) error {
if err != nil {
// don't let errors prevent scanning
@@ -115,7 +111,7 @@ func (s *Scanner) detectFolderMove(ctx context.Context, file ScannedFile) (*mode
return nil
}
if !s.AcceptEntry(ctx, path, info, zipFilePath) {
if !s.AcceptEntry(ctx, path, info) {
return nil
}
@@ -165,7 +161,9 @@ func (s *Scanner) detectFolderMove(ctx context.Context, file ScannedFile) (*mode
continue
}
// treat any error as missing folder
if !errors.Is(err, fs.ErrNotExist) {
return fmt.Errorf("checking for parent folder %q: %w", pf.Path, err)
}
// parent folder is missing, possible candidate
// count the total number of files in the existing folder

View File

@@ -9,7 +9,7 @@ import (
// PathFilter provides a filter function for paths.
type PathFilter interface {
Accept(ctx context.Context, path string, info fs.FileInfo, zipFilePath string) bool
Accept(ctx context.Context, path string, info fs.FileInfo) bool
}
type PathFilterFunc func(path string) bool

View File

@@ -45,12 +45,9 @@ type Mover struct {
moved map[string]string
foldersCreated []string
// needed for creating folder hierarchy when moving zip file entries
rootPaths []string
}
func NewMover(fileStore models.FileFinderUpdater, folderStore models.FolderReaderWriter, rootPaths []string) *Mover {
func NewMover(fileStore models.FileFinderUpdater, folderStore models.FolderReaderWriter) *Mover {
return &Mover{
Files: fileStore,
Folders: folderStore,
@@ -58,7 +55,6 @@ func NewMover(fileStore models.FileFinderUpdater, folderStore models.FolderReade
renamerRemoverImpl: newRenamerRemoverImpl(),
mkDirFn: os.Mkdir,
},
rootPaths: rootPaths,
}
}
@@ -91,13 +87,7 @@ func (m *Mover) Move(ctx context.Context, f models.File, folder *models.Folder,
return fmt.Errorf("file %s already exists", newPath)
}
zipMover := zipHierarchyMover{
folderStore: m.Folders,
files: m.Files,
rootPaths: m.rootPaths,
}
if err := zipMover.transferZipHierarchy(ctx, fBase.ID, oldPath, newPath); err != nil {
if err := transferZipHierarchy(ctx, m.Folders, m.Files, fBase.ID, oldPath, newPath); err != nil {
return fmt.Errorf("moving folder hierarchy for file %s: %w", fBase.Path, err)
}
@@ -205,25 +195,6 @@ func correctSubFolderHierarchy(ctx context.Context, rw models.FolderReaderWriter
logger.Debugf("updating folder %s to %s", oldPath, correctPath)
// #6427 - ensure folder entry with new path doesn't already exist
const caseSensitive = true
existing, err := rw.FindByPath(ctx, correctPath, caseSensitive)
if err != nil {
return fmt.Errorf("finding folder by path %s: %w", correctPath, err)
}
if existing != nil {
// this should no longer be possible, but if it does happen, log a warning
// and skip updating this folder and its subfolders
logger.Warnf("folder with path %s already exists, setting parent_folder_id of %s to NULL and skipping", correctPath, oldPath)
f.ParentFolderID = nil
if err := rw.Update(ctx, f); err != nil {
return fmt.Errorf("updating folder parent id to NULL for folder %s: %w", oldPath, err)
}
continue
}
f.Path = correctPath
if err := rw.Update(ctx, f); err != nil {
return fmt.Errorf("updating folder path %s -> %s: %w", oldPath, f.Path, err)

View File

@@ -5,7 +5,6 @@ import (
"fmt"
"io/fs"
"path/filepath"
"slices"
"strings"
"sync"
"time"
@@ -61,10 +60,6 @@ type Scanner struct {
// handlers are called after a file has been scanned.
FileHandlers []Handler
// RootPaths form the top-level paths for the library.
// Used to determine the root of the folder hierarchy when creating folders.
RootPaths []string
// Rescan indicates whether files should be rescanned even if they haven't changed.
Rescan bool
@@ -111,12 +106,12 @@ type ScannedFile struct {
}
// AcceptEntry determines if the file entry should be accepted for scanning
func (s *Scanner) AcceptEntry(ctx context.Context, path string, info fs.FileInfo, zipFilePath string) bool {
func (s *Scanner) AcceptEntry(ctx context.Context, path string, info fs.FileInfo) bool {
// always accept if there's no filters
accept := len(s.ScanFilters) == 0
for _, filter := range s.ScanFilters {
// accept if any filter accepts the file
if filter.Accept(ctx, path, info, zipFilePath) {
if filter.Accept(ctx, path, info) {
accept = true
break
}
@@ -198,10 +193,6 @@ func (s *Scanner) ScanFolder(ctx context.Context, file ScannedFile) (*models.Fol
return f, err
}
func (s *Scanner) isRootPath(path string) bool {
return path == "." || slices.Contains(s.RootPaths, path)
}
func (s *Scanner) onNewFolder(ctx context.Context, file ScannedFile) (*models.Folder, error) {
renamed, err := s.handleFolderRename(ctx, file)
if err != nil {
@@ -221,16 +212,18 @@ func (s *Scanner) onNewFolder(ctx context.Context, file ScannedFile) (*models.Fo
UpdatedAt: now,
}
if !s.isRootPath(file.Path) {
dir := filepath.Dir(file.Path)
// create full folder hierarchy if parent folder doesn't exist, and set parent folder ID
parentFolder, err := GetOrCreateFolderHierarchy(ctx, s.Repository.Folder, dir, s.RootPaths)
dir := filepath.Dir(file.Path)
if dir != "." {
parentFolderID, err := s.getFolderID(ctx, dir)
if err != nil {
return nil, fmt.Errorf("getting parent folder %q: %w", dir, err)
}
toCreate.ParentFolderID = &parentFolder.ID
// if parent folder doesn't exist, assume it's a top-level folder
// this may not be true if we're using multiple goroutines
if parentFolderID != nil {
toCreate.ParentFolderID = parentFolderID
}
}
txn.AddPostCommitHook(ctx, func(ctx context.Context) {
@@ -319,19 +312,6 @@ func (s *Scanner) onExistingFolder(ctx context.Context, f ScannedFile, existing
}
}
// handle case where parent folder was not previously set
if existing.ParentFolderID == nil && !s.isRootPath(existing.Path) {
logger.Infof("Existing folder entry %q has no parent folder. Creating folder hierarchy and setting parent ID...", existing.Path)
// create full folder hierarchy if parent folder doesn't exist, and set parent folder ID
parentFolder, err := GetOrCreateFolderHierarchy(ctx, s.Repository.Folder, filepath.Dir(f.Path), s.RootPaths)
if err != nil {
return nil, fmt.Errorf("getting parent folder for %q: %w", f.Path, err)
}
existing.ParentFolderID = &parentFolder.ID
update = true
}
if update {
var err error
if err = s.Repository.Folder.Update(ctx, existing); err != nil {
@@ -343,16 +323,10 @@ func (s *Scanner) onExistingFolder(ctx context.Context, f ScannedFile, existing
}
type ScanFileResult struct {
File models.File
New bool
Renamed bool
Updated bool
FingerprintChanged bool
HandlerRequired bool
}
func (r ScanFileResult) IsUnchanged() bool {
return !r.New && !r.Renamed && !r.Updated
File models.File
New bool
Renamed bool
Updated bool
}
// ScanFile scans the provided file into the database, returning the scan result.
@@ -419,31 +393,13 @@ func (s *Scanner) onNewFile(ctx context.Context, f ScannedFile) (*ScanFileResult
baseFile.UpdatedAt = now
// find the parent folder
folderPath := filepath.Dir(path)
parentFolderID, err := s.getFolderID(ctx, folderPath)
parentFolderID, err := s.getFolderID(ctx, filepath.Dir(path))
if err != nil {
return nil, fmt.Errorf("getting parent folder for %q: %w", path, err)
}
if parentFolderID == nil {
// parent folders should have been created before scanning this file in a recursive scan
// assume that we are scanning specifically and only this file,
// so we should create the parent folder hierarchy if it doesn't exist
if err := s.Repository.WithTxn(ctx, func(ctx context.Context) error {
parentFolder, err := GetOrCreateFolderHierarchy(ctx, s.Repository.Folder, folderPath, s.RootPaths)
if err != nil {
return fmt.Errorf("getting parent folder for %q: %w", f.Path, err)
}
parentFolderID = &parentFolder.ID
return nil
}); err != nil {
return nil, err
}
}
if parentFolderID == nil {
// shouldn't happen
return nil, fmt.Errorf("parent folder ID is nil for %q", path)
return nil, fmt.Errorf("parent folder for %q doesn't exist", path)
}
baseFile.ParentFolderID = *parentFolderID
@@ -463,11 +419,7 @@ func (s *Scanner) onNewFile(ctx context.Context, f ScannedFile) (*ScanFileResult
// determine if the file is renamed from an existing file in the store
// do this after decoration so that missing fields can be populated
zipFilePath := ""
if f.ZipFile != nil {
zipFilePath = f.ZipFile.Base().Path
}
renamed, err := s.handleRename(ctx, file, fp, zipFilePath)
renamed, err := s.handleRename(ctx, file, fp)
if err != nil {
return nil, err
}
@@ -577,7 +529,7 @@ func (s *Scanner) getFileFS(f *models.BaseFile) (models.FS, error) {
return fs.OpenZip(zipPath, zipSize)
}
func (s *Scanner) handleRename(ctx context.Context, f models.File, fp []models.Fingerprint, zipFilePath string) (models.File, error) {
func (s *Scanner) handleRename(ctx context.Context, f models.File, fp []models.Fingerprint) (models.File, error) {
var others []models.File
for _, tfp := range fp {
@@ -619,7 +571,7 @@ func (s *Scanner) handleRename(ctx context.Context, f models.File, fp []models.F
// treat as a move
missing = append(missing, other)
}
case !s.AcceptEntry(ctx, other.Base().Path, info, zipFilePath):
case !s.AcceptEntry(ctx, other.Base().Path, info):
// #4393 - if the file is no longer in the configured library paths, treat it as a move
logger.Debugf("File %q no longer in library paths. Treating as a move.", other.Base().Path)
missing = append(missing, other)
@@ -652,19 +604,13 @@ func (s *Scanner) handleRename(ctx context.Context, f models.File, fp []models.F
fBaseCopy.Fingerprints = updatedBase.Fingerprints
*updatedBase = fBaseCopy
zipMover := zipHierarchyMover{
folderStore: s.Repository.Folder,
files: s.Repository.File,
rootPaths: s.RootPaths,
}
if err := s.Repository.WithTxn(ctx, func(ctx context.Context) error {
if err := s.Repository.File.Update(ctx, updated); err != nil {
return fmt.Errorf("updating file for rename %q: %w", newPath, err)
}
if s.IsZipFile(updatedBase.Basename) {
if err := zipMover.transferZipHierarchy(ctx, updatedBase.ID, oldPath, newPath); err != nil {
if err := transferZipHierarchy(ctx, s.Repository.Folder, s.Repository.File, updatedBase.ID, oldPath, newPath); err != nil {
return fmt.Errorf("moving zip hierarchy for renamed zip file %q: %w", newPath, err)
}
}
@@ -797,9 +743,6 @@ func (s *Scanner) onExistingFile(ctx context.Context, f ScannedFile, existing mo
return nil, err
}
oldFingerprints := existing.Base().Fingerprints
fingerprintChanged := fp.ContentsChanged(oldFingerprints)
s.removeOutdatedFingerprints(existing, fp)
existing.SetFingerprints(fp)
@@ -823,9 +766,8 @@ func (s *Scanner) onExistingFile(ctx context.Context, f ScannedFile, existing mo
return nil, err
}
return &ScanFileResult{
File: existing,
Updated: true,
FingerprintChanged: fingerprintChanged,
File: existing,
Updated: true,
}, nil
}
@@ -912,8 +854,7 @@ func (s *Scanner) onUnchangedFile(ctx context.Context, f ScannedFile, existing m
// if this file is a zip file, then we need to rescan the contents
// as well. We do this by indicating that the file is updated.
return &ScanFileResult{
File: existing,
Updated: true,
HandlerRequired: true,
File: existing,
Updated: true,
}, nil
}

View File

@@ -1,264 +0,0 @@
package file
import (
"context"
"io/fs"
"os"
"path/filepath"
"strings"
"sync"
lru "github.com/hashicorp/golang-lru/v2"
ignore "github.com/sabhiram/go-gitignore"
"github.com/stashapp/stash/pkg/logger"
)
const stashIgnoreFilename = ".stashignore"
// entriesCacheSize is the size of the LRU cache for collected ignore entries.
// This cache stores the computed list of ignore entries per directory, avoiding
// repeated directory tree walks for files in the same directory.
const entriesCacheSize = 500
// StashIgnoreFilter implements PathFilter to exclude files/directories
// based on .stashignore files with gitignore-style patterns.
type StashIgnoreFilter struct {
// cache stores compiled ignore patterns per directory.
cache sync.Map // map[string]*ignoreEntry
// entriesCache stores collected ignore entries per (dir, libraryRoot) pair.
// This avoids recomputing the entry list for every file in the same directory.
entriesCache *lru.Cache[string, []*ignoreEntry]
}
// ignoreEntry holds the compiled ignore patterns for a directory.
type ignoreEntry struct {
// patterns is the compiled gitignore matcher for this directory.
patterns *ignore.GitIgnore
// dir is the directory this entry applies to.
dir string
}
// NewStashIgnoreFilter creates a new StashIgnoreFilter.
func NewStashIgnoreFilter() *StashIgnoreFilter {
// Create the LRU cache for collected entries.
// Ignore error as it only fails if size <= 0.
entriesCache, _ := lru.New[string, []*ignoreEntry](entriesCacheSize)
return &StashIgnoreFilter{
entriesCache: entriesCache,
}
}
// Accept returns true if the path should be included in the scan.
// It checks for .stashignore files in the directory hierarchy and
// applies gitignore-style pattern matching.
// The libraryRoot parameter bounds the search for .stashignore files -
// only directories within the library root are checked.
// zipFilepath is the path of the zip file if the file is inside a zip.
// .stashignore files will not be read within zip files.
func (f *StashIgnoreFilter) Accept(ctx context.Context, path string, info fs.FileInfo, libraryRoot string, zipFilePath string) bool {
// If no library root provided, accept the file (safety fallback).
if libraryRoot == "" {
return true
}
// Get the directory containing this path.
dir := filepath.Dir(path)
// If the file is inside a zip, use the zip file's directory as the base for .stashignore lookup.
if zipFilePath != "" {
dir = filepath.Dir(zipFilePath)
}
// Collect all applicable ignore entries from library root to this directory.
entries := f.collectIgnoreEntries(dir, libraryRoot)
// If no .stashignore files found, accept the file.
if len(entries) == 0 {
return true
}
// Check each ignore entry in order (from root to most specific).
// Later entries can override earlier ones with negation patterns.
ignored := false
for _, entry := range entries {
// Get path relative to the ignore file's directory.
entryRelPath, err := filepath.Rel(entry.dir, path)
if err != nil {
continue
}
entryRelPath = filepath.ToSlash(entryRelPath)
if info.IsDir() {
entryRelPath += "/"
}
if entry.patterns.MatchesPath(entryRelPath) {
ignored = true
}
}
return !ignored
}
// collectIgnoreEntries gathers all ignore entries from library root to the given directory.
// It walks up the directory tree from dir to libraryRoot and returns entries in order
// from root to most specific. Results are cached to avoid repeated computation for
// files in the same directory.
func (f *StashIgnoreFilter) collectIgnoreEntries(dir string, libraryRoot string) []*ignoreEntry {
// Clean paths for consistent comparison and cache key generation.
dir = filepath.Clean(dir)
libraryRoot = filepath.Clean(libraryRoot)
// Build cache key from dir and libraryRoot.
cacheKey := dir + "\x00" + libraryRoot
// Check the entries cache first.
if cached, ok := f.entriesCache.Get(cacheKey); ok {
return cached
}
// Try subdirectory shortcut: if parent's entries are cached, extend them.
if dir != libraryRoot {
parent := filepath.Dir(dir)
if isPathInOrEqual(libraryRoot, parent) {
parentKey := parent + "\x00" + libraryRoot
if parentEntries, ok := f.entriesCache.Get(parentKey); ok {
// Parent is cached - just check if current dir has a .stashignore.
entries := parentEntries
if entry := f.getOrLoadIgnoreEntry(dir); entry != nil {
// Copy parent slice and append to avoid mutating cached slice.
entries = make([]*ignoreEntry, len(parentEntries), len(parentEntries)+1)
copy(entries, parentEntries)
entries = append(entries, entry)
}
f.entriesCache.Add(cacheKey, entries)
return entries
}
}
}
// No cache hit - compute from scratch.
// Walk up from dir to library root, collecting directories.
var dirs []string
current := dir
for {
// Check if we're still within the library root.
// nolint:staticcheck // QF1006 - we could make this the for condition
// but I don't think it improves readability
if !isPathInOrEqual(libraryRoot, current) {
break
}
dirs = append(dirs, current)
// Stop if we've reached the library root.
if current == libraryRoot {
break
}
parent := filepath.Dir(current)
if parent == current {
// Reached filesystem root without finding library root.
break
}
current = parent
}
// Reverse to get root-to-leaf order.
for i, j := 0, len(dirs)-1; i < j; i, j = i+1, j-1 {
dirs[i], dirs[j] = dirs[j], dirs[i]
}
// Check each directory for .stashignore files.
var entries []*ignoreEntry
for _, d := range dirs {
if entry := f.getOrLoadIgnoreEntry(d); entry != nil {
entries = append(entries, entry)
}
}
// Cache the result.
f.entriesCache.Add(cacheKey, entries)
return entries
}
// isPathInOrEqual checks if path is equal to or inside root.
func isPathInOrEqual(root, path string) bool {
if path == root {
return true
}
// Check if path starts with root + separator.
return strings.HasPrefix(path, root+string(filepath.Separator))
}
// getOrLoadIgnoreEntry returns the cached ignore entry for a directory, or loads it.
func (f *StashIgnoreFilter) getOrLoadIgnoreEntry(dir string) *ignoreEntry {
// Check cache first.
if cached, ok := f.cache.Load(dir); ok {
entry := cached.(*ignoreEntry)
if entry.patterns == nil {
return nil // Cached negative result.
}
return entry
}
// Try to load .stashignore from this directory.
stashIgnorePath := filepath.Join(dir, stashIgnoreFilename)
patterns, err := f.loadIgnoreFile(stashIgnorePath)
if err != nil {
if !os.IsNotExist(err) {
logger.Warnf("Failed to load .stashignore from %s: %v", dir, err)
}
f.cache.Store(dir, &ignoreEntry{patterns: nil, dir: dir})
return nil
}
if patterns == nil {
// File exists but has no patterns (empty or only comments).
f.cache.Store(dir, &ignoreEntry{patterns: nil, dir: dir})
return nil
}
logger.Debugf("Loaded .stashignore from %s", dir)
entry := &ignoreEntry{
patterns: patterns,
dir: dir,
}
f.cache.Store(dir, entry)
return entry
}
// loadIgnoreFile loads and compiles a .stashignore file.
func (f *StashIgnoreFilter) loadIgnoreFile(path string) (*ignore.GitIgnore, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
lines := strings.Split(string(data), "\n")
var patterns []string
for _, line := range lines {
// Trim trailing whitespace (but preserve leading for patterns).
line = strings.TrimRight(line, " \t\r")
// Skip empty lines.
if line == "" {
continue
}
// Skip comments (but not escaped #).
if strings.HasPrefix(line, "#") && !strings.HasPrefix(line, "\\#") {
continue
}
patterns = append(patterns, line)
}
if len(patterns) == 0 {
// File exists but has no patterns (e.g., only comments).
return nil, nil
}
return ignore.CompileIgnoreLines(patterns...), nil
}

View File

@@ -1,523 +0,0 @@
package file
import (
"context"
"io/fs"
"os"
"path/filepath"
"sort"
"testing"
)
// Helper to create an empty file.
func createTestFile(t *testing.T, dir, name string) {
t.Helper()
path := filepath.Join(dir, name)
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
t.Fatalf("failed to create directory for %s: %v", path, err)
}
if err := os.WriteFile(path, []byte{}, 0644); err != nil {
t.Fatalf("failed to create file %s: %v", path, err)
}
}
// Helper to create a file with content.
func createTestFileWithContent(t *testing.T, dir, name, content string) {
t.Helper()
path := filepath.Join(dir, name)
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
t.Fatalf("failed to create directory for %s: %v", path, err)
}
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
t.Fatalf("failed to create file %s: %v", path, err)
}
}
// Helper to create a directory.
func createTestDir(t *testing.T, dir, name string) {
t.Helper()
path := filepath.Join(dir, name)
if err := os.MkdirAll(path, 0755); err != nil {
t.Fatalf("failed to create directory %s: %v", path, err)
}
}
// walkAndFilter walks the directory tree and returns paths accepted by the filter.
// Returns paths relative to root for easier assertion.
func walkAndFilter(t *testing.T, root string, filter *StashIgnoreFilter) []string {
t.Helper()
var accepted []string
ctx := context.Background()
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
// Skip the root directory itself.
if path == root {
return nil
}
info, err := d.Info()
if err != nil {
return err
}
if filter.Accept(ctx, path, info, root, "") {
relPath, _ := filepath.Rel(root, path)
accepted = append(accepted, relPath)
} else if info.IsDir() {
// If directory is rejected, skip it.
return filepath.SkipDir
}
return nil
})
if err != nil {
t.Fatalf("walk failed: %v", err)
}
sort.Strings(accepted)
return accepted
}
// assertPathsEqual checks that the accepted paths match expected.
func assertPathsEqual(t *testing.T, expected, actual []string) {
t.Helper()
sort.Strings(expected)
if len(expected) != len(actual) {
t.Errorf("path count mismatch:\nexpected %d: %v\nactual %d: %v", len(expected), expected, len(actual), actual)
return
}
for i := range expected {
if expected[i] != actual[i] {
t.Errorf("path mismatch at index %d:\nexpected: %s\nactual: %s", i, expected[i], actual[i])
}
}
}
func TestStashIgnore_ExactFilename(t *testing.T) {
tmpDir := t.TempDir()
// Create test files.
createTestFile(t, tmpDir, "video1.mp4")
createTestFile(t, tmpDir, "video2.mp4")
createTestFile(t, tmpDir, "ignore_me.mp4")
// Create .stashignore that excludes exact filename.
createTestFileWithContent(t, tmpDir, ".stashignore", "ignore_me.mp4\n")
filter := NewStashIgnoreFilter()
accepted := walkAndFilter(t, tmpDir, filter)
expected := []string{
".stashignore",
"video1.mp4",
"video2.mp4",
}
assertPathsEqual(t, expected, accepted)
}
func TestStashIgnore_WildcardPattern(t *testing.T) {
tmpDir := t.TempDir()
// Create test files.
createTestFile(t, tmpDir, "video1.mp4")
createTestFile(t, tmpDir, "video2.mp4")
createTestFile(t, tmpDir, "temp1.tmp")
createTestFile(t, tmpDir, "temp2.tmp")
createTestFile(t, tmpDir, "notes.log")
// Create .stashignore that excludes by extension.
createTestFileWithContent(t, tmpDir, ".stashignore", "*.tmp\n*.log\n")
filter := NewStashIgnoreFilter()
accepted := walkAndFilter(t, tmpDir, filter)
expected := []string{
".stashignore",
"video1.mp4",
"video2.mp4",
}
assertPathsEqual(t, expected, accepted)
}
func TestStashIgnore_DirectoryExclusion(t *testing.T) {
tmpDir := t.TempDir()
// Create test files.
createTestFile(t, tmpDir, "video1.mp4")
createTestDir(t, tmpDir, "excluded_dir")
createTestFile(t, tmpDir, "excluded_dir/video2.mp4")
createTestFile(t, tmpDir, "excluded_dir/video3.mp4")
createTestDir(t, tmpDir, "included_dir")
createTestFile(t, tmpDir, "included_dir/video4.mp4")
// Create .stashignore that excludes a directory.
createTestFileWithContent(t, tmpDir, ".stashignore", "excluded_dir/\n")
filter := NewStashIgnoreFilter()
accepted := walkAndFilter(t, tmpDir, filter)
expected := []string{
".stashignore",
"included_dir",
"included_dir/video4.mp4",
"video1.mp4",
}
assertPathsEqual(t, expected, accepted)
}
func TestStashIgnore_NegationPattern(t *testing.T) {
tmpDir := t.TempDir()
// Create test files.
createTestFile(t, tmpDir, "file1.tmp")
createTestFile(t, tmpDir, "file2.tmp")
createTestFile(t, tmpDir, "keep_this.tmp")
// Create .stashignore that excludes *.tmp but keeps one.
createTestFileWithContent(t, tmpDir, ".stashignore", "*.tmp\n!keep_this.tmp\n")
filter := NewStashIgnoreFilter()
accepted := walkAndFilter(t, tmpDir, filter)
expected := []string{
".stashignore",
"keep_this.tmp",
}
assertPathsEqual(t, expected, accepted)
}
func TestStashIgnore_CommentsAndEmptyLines(t *testing.T) {
tmpDir := t.TempDir()
// Create test files.
createTestFile(t, tmpDir, "video1.mp4")
createTestFile(t, tmpDir, "ignore_me.mp4")
// Create .stashignore with comments and empty lines.
stashignore := `# This is a comment
ignore_me.mp4
# Another comment
`
createTestFileWithContent(t, tmpDir, ".stashignore", stashignore)
filter := NewStashIgnoreFilter()
accepted := walkAndFilter(t, tmpDir, filter)
expected := []string{
".stashignore",
"video1.mp4",
}
assertPathsEqual(t, expected, accepted)
}
func TestStashIgnore_NestedStashIgnoreFiles(t *testing.T) {
tmpDir := t.TempDir()
// Create test files.
createTestFile(t, tmpDir, "root_video.mp4")
createTestFile(t, tmpDir, "root_ignore.tmp")
createTestDir(t, tmpDir, "subdir")
createTestFile(t, tmpDir, "subdir/sub_video.mp4")
createTestFile(t, tmpDir, "subdir/sub_ignore.log")
createTestFile(t, tmpDir, "subdir/also_tmp.tmp")
// Root .stashignore excludes *.tmp.
createTestFileWithContent(t, tmpDir, ".stashignore", "*.tmp\n")
// Subdir .stashignore excludes *.log.
createTestFileWithContent(t, tmpDir, "subdir/.stashignore", "*.log\n")
filter := NewStashIgnoreFilter()
accepted := walkAndFilter(t, tmpDir, filter)
// *.tmp from root should apply everywhere.
// *.log from subdir should only apply in subdir.
expected := []string{
".stashignore",
"root_video.mp4",
"subdir",
"subdir/.stashignore",
"subdir/sub_video.mp4",
}
assertPathsEqual(t, expected, accepted)
}
func TestStashIgnore_PathPattern(t *testing.T) {
tmpDir := t.TempDir()
// Create test files.
createTestFile(t, tmpDir, "video1.mp4")
createTestDir(t, tmpDir, "subdir")
createTestFile(t, tmpDir, "subdir/video2.mp4")
createTestFile(t, tmpDir, "subdir/skip_this.mp4")
// Create .stashignore that excludes a specific path.
createTestFileWithContent(t, tmpDir, ".stashignore", "subdir/skip_this.mp4\n")
filter := NewStashIgnoreFilter()
accepted := walkAndFilter(t, tmpDir, filter)
expected := []string{
".stashignore",
"subdir",
"subdir/video2.mp4",
"video1.mp4",
}
assertPathsEqual(t, expected, accepted)
}
func TestStashIgnore_DoubleStarPattern(t *testing.T) {
tmpDir := t.TempDir()
// Create test files.
createTestFile(t, tmpDir, "video1.mp4")
createTestDir(t, tmpDir, "a")
createTestFile(t, tmpDir, "a/video2.mp4")
createTestDir(t, tmpDir, "a/temp")
createTestFile(t, tmpDir, "a/temp/video3.mp4")
createTestDir(t, tmpDir, "a/b")
createTestDir(t, tmpDir, "a/b/temp")
createTestFile(t, tmpDir, "a/b/temp/video4.mp4")
// Create .stashignore that excludes temp directories at any level.
createTestFileWithContent(t, tmpDir, ".stashignore", "**/temp/\n")
filter := NewStashIgnoreFilter()
accepted := walkAndFilter(t, tmpDir, filter)
expected := []string{
".stashignore",
"a",
"a/b",
"a/video2.mp4",
"video1.mp4",
}
assertPathsEqual(t, expected, accepted)
}
func TestStashIgnore_LeadingSlashPattern(t *testing.T) {
tmpDir := t.TempDir()
// Create test files.
createTestFile(t, tmpDir, "ignore.mp4")
createTestDir(t, tmpDir, "subdir")
createTestFile(t, tmpDir, "subdir/ignore.mp4")
// Create .stashignore that excludes only at root level.
createTestFileWithContent(t, tmpDir, ".stashignore", "/ignore.mp4\n")
filter := NewStashIgnoreFilter()
accepted := walkAndFilter(t, tmpDir, filter)
// Only root ignore.mp4 should be excluded.
expected := []string{
".stashignore",
"subdir",
"subdir/ignore.mp4",
}
assertPathsEqual(t, expected, accepted)
}
func TestStashIgnore_NoStashIgnoreFile(t *testing.T) {
tmpDir := t.TempDir()
// Create test files without any .stashignore.
createTestFile(t, tmpDir, "video1.mp4")
createTestFile(t, tmpDir, "video2.mp4")
createTestDir(t, tmpDir, "subdir")
createTestFile(t, tmpDir, "subdir/video3.mp4")
filter := NewStashIgnoreFilter()
accepted := walkAndFilter(t, tmpDir, filter)
// All files should be accepted.
expected := []string{
"subdir",
"subdir/video3.mp4",
"video1.mp4",
"video2.mp4",
}
assertPathsEqual(t, expected, accepted)
}
func TestStashIgnore_HiddenDirectories(t *testing.T) {
tmpDir := t.TempDir()
// Create test files including hidden directory.
createTestFile(t, tmpDir, "video1.mp4")
createTestDir(t, tmpDir, ".hidden")
createTestFile(t, tmpDir, ".hidden/video2.mp4")
// Create .stashignore that excludes hidden directories.
createTestFileWithContent(t, tmpDir, ".stashignore", ".*\n!.stashignore\n")
filter := NewStashIgnoreFilter()
accepted := walkAndFilter(t, tmpDir, filter)
expected := []string{
".stashignore",
"video1.mp4",
}
assertPathsEqual(t, expected, accepted)
}
func TestStashIgnore_MultiplePatternsSameLine(t *testing.T) {
tmpDir := t.TempDir()
// Create test files.
createTestFile(t, tmpDir, "video1.mp4")
createTestFile(t, tmpDir, "file.tmp")
createTestFile(t, tmpDir, "file.log")
createTestFile(t, tmpDir, "file.bak")
// Each pattern should be on its own line.
createTestFileWithContent(t, tmpDir, ".stashignore", "*.tmp\n*.log\n*.bak\n")
filter := NewStashIgnoreFilter()
accepted := walkAndFilter(t, tmpDir, filter)
expected := []string{
".stashignore",
"video1.mp4",
}
assertPathsEqual(t, expected, accepted)
}
func TestStashIgnore_TrailingSpaces(t *testing.T) {
tmpDir := t.TempDir()
// Create test files.
createTestFile(t, tmpDir, "video1.mp4")
createTestFile(t, tmpDir, "ignore_me.mp4")
// Pattern with trailing spaces (should be trimmed).
createTestFileWithContent(t, tmpDir, ".stashignore", "ignore_me.mp4 \n")
filter := NewStashIgnoreFilter()
accepted := walkAndFilter(t, tmpDir, filter)
expected := []string{
".stashignore",
"video1.mp4",
}
assertPathsEqual(t, expected, accepted)
}
func TestStashIgnore_EscapedHash(t *testing.T) {
tmpDir := t.TempDir()
// Create test files.
createTestFile(t, tmpDir, "video1.mp4")
createTestFile(t, tmpDir, "#filename.mp4")
// Escaped hash should match literal # character.
createTestFileWithContent(t, tmpDir, ".stashignore", "\\#filename.mp4\n")
filter := NewStashIgnoreFilter()
accepted := walkAndFilter(t, tmpDir, filter)
expected := []string{
".stashignore",
"video1.mp4",
}
assertPathsEqual(t, expected, accepted)
}
func TestStashIgnore_CaseSensitiveMatching(t *testing.T) {
tmpDir := t.TempDir()
// Create test files - use distinct names that work on all filesystems.
createTestFile(t, tmpDir, "video_lower.mp4")
createTestFile(t, tmpDir, "VIDEO_UPPER.mp4")
createTestFile(t, tmpDir, "other.avi")
// Pattern should match exactly (case-sensitive).
createTestFileWithContent(t, tmpDir, ".stashignore", "video_lower.mp4\n")
filter := NewStashIgnoreFilter()
accepted := walkAndFilter(t, tmpDir, filter)
// Only exact match is excluded.
expected := []string{
".stashignore",
"VIDEO_UPPER.mp4",
"other.avi",
}
assertPathsEqual(t, expected, accepted)
}
func TestStashIgnore_ComplexScenario(t *testing.T) {
tmpDir := t.TempDir()
// Create a complex directory structure.
createTestFile(t, tmpDir, "video1.mp4")
createTestFile(t, tmpDir, "video2.avi")
createTestFile(t, tmpDir, "thumbnail.jpg")
createTestFile(t, tmpDir, "metadata.nfo")
createTestDir(t, tmpDir, "movies")
createTestFile(t, tmpDir, "movies/movie1.mp4")
createTestFile(t, tmpDir, "movies/movie1.nfo")
createTestDir(t, tmpDir, "movies/.thumbnails")
createTestFile(t, tmpDir, "movies/.thumbnails/thumb1.jpg")
createTestDir(t, tmpDir, "temp")
createTestFile(t, tmpDir, "temp/processing.mp4")
createTestDir(t, tmpDir, "backup")
createTestFile(t, tmpDir, "backup/video1.mp4.bak")
// Complex .stashignore.
stashignore := `# Ignore metadata files
*.nfo
# Ignore hidden directories
.*
!.stashignore
# Ignore temp and backup directories
temp/
backup/
# But keep thumbnails in specific location
!movies/.thumbnails/
`
createTestFileWithContent(t, tmpDir, ".stashignore", stashignore)
filter := NewStashIgnoreFilter()
accepted := walkAndFilter(t, tmpDir, filter)
expected := []string{
".stashignore",
"movies",
"movies/.thumbnails",
"movies/.thumbnails/thumb1.jpg",
"movies/movie1.mp4",
"thumbnail.jpg",
"video1.mp4",
"video2.avi",
}
assertPathsEqual(t, expected, accepted)
}

View File

@@ -90,20 +90,11 @@ type CaptionUpdater interface {
UpdateCaptions(ctx context.Context, fileID models.FileID, captions []*models.VideoCaption) error
}
// MatchesCaption returns true if the caption file matches the video file based on the filename
func MatchesCaption(videoPath, captionPath string) bool {
captionPrefix := getCaptionPrefix(captionPath)
videoPrefix := strings.TrimSuffix(videoPath, filepath.Ext(videoPath)) + "."
return captionPrefix == videoPrefix
}
// associates captions to scene/s with the same basename
// returns true if the caption file was matched to a video file and processed, false otherwise
func AssociateCaptions(ctx context.Context, captionPath string, txnMgr txn.Manager, fqb models.FileFinder, w CaptionUpdater) bool {
func AssociateCaptions(ctx context.Context, captionPath string, txnMgr txn.Manager, fqb models.FileFinder, w CaptionUpdater) {
captionLang := getCaptionsLangFromPath(captionPath)
captionPrefix := getCaptionPrefix(captionPath)
matched := false
if err := txn.WithTxn(ctx, txnMgr, func(ctx context.Context) error {
var err error
files, er := fqb.FindAllByPath(ctx, captionPrefix+"*", true)
@@ -126,36 +117,28 @@ func AssociateCaptions(ctx context.Context, captionPath string, txnMgr txn.Manag
path := f.Base().Path
logger.Debugf("Matched captions to file %s", path)
matched = true
captions, er := w.GetCaptions(ctx, fileID)
if er != nil {
return fmt.Errorf("getting captions for file %s: %w", path, er)
}
fileExt := filepath.Ext(captionPath)
ext := fileExt[1:]
if !IsLangInCaptions(captionLang, ext, captions) { // only update captions if language code is not present
newCaption := &models.VideoCaption{
LanguageCode: captionLang,
Filename: filepath.Base(captionPath),
CaptionType: ext,
if er == nil {
fileExt := filepath.Ext(captionPath)
ext := fileExt[1:]
if !IsLangInCaptions(captionLang, ext, captions) { // only update captions if language code is not present
newCaption := &models.VideoCaption{
LanguageCode: captionLang,
Filename: filepath.Base(captionPath),
CaptionType: ext,
}
captions = append(captions, newCaption)
er = w.UpdateCaptions(ctx, fileID, captions)
if er == nil {
logger.Debugf("Updated captions for file %s. Added %s", path, captionLang)
}
}
captions = append(captions, newCaption)
er = w.UpdateCaptions(ctx, fileID, captions)
if er != nil {
return fmt.Errorf("updating captions for file %s: %w", path, er)
}
logger.Debugf("Updated captions for file %s. Added %s", path, captionLang)
}
}
return err
}); err != nil {
logger.Error(err.Error())
}
return matched
}
// CleanCaptions removes non existent/accessible language codes from captions

View File

@@ -99,9 +99,7 @@ func (f *zipFS) rel(name string) (string, error) {
relName, err := filepath.Rel(f.zipPath, name)
if err != nil {
// if the path is not relative to the zip path, then it's not found in the zip file,
// so treat this as a file not found
return "", fs.ErrNotExist
return "", fmt.Errorf("internal error getting relative path: %w", err)
}
// convert relName to use slash, since zip files do so regardless

View File

@@ -148,7 +148,7 @@ func Touch(path string) error {
var (
replaceCharsRE = regexp.MustCompile(`[&=\\/:*"?_ ]`)
removeCharsRE = regexp.MustCompile(`[^\p{L}\p{N}\-.]`)
removeCharsRE = regexp.MustCompile(`[^[:alnum:]-.]`)
multiHyphenRE = regexp.MustCompile(`\-+`)
)

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