mirror of
https://github.com/stashapp/stash.git
synced 2026-06-10 21:42:03 -05:00
Compare commits
1 Commits
master
...
localisati
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe7155b001 |
28
.github/workflows/build-compiler.yml
vendored
28
.github/workflows/build-compiler.yml
vendored
@@ -1,28 +0,0 @@
|
||||
name: Compiler Build
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
COMPILER_IMAGE: ghcr.io/stashapp/compiler:13
|
||||
|
||||
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
|
||||
270
.github/workflows/build.yml
vendored
270
.github/workflows/build.yml
vendored
@@ -2,7 +2,7 @@ name: Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
branches:
|
||||
- develop
|
||||
- master
|
||||
- 'releases/**'
|
||||
@@ -15,163 +15,50 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
COMPILER_IMAGE: ghcr.io/stashapp/compiler:13
|
||||
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
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v6
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
# pnpm version is read from the packageManager field in package.json
|
||||
# very broken (4.3, 4.4)
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061
|
||||
with:
|
||||
package_json_file: ui/v2.5/package.json
|
||||
|
||||
- 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: cd ui/v2.5 && pnpm install --frozen-lockfile
|
||||
|
||||
- name: Generate
|
||||
run: make generate
|
||||
|
||||
- name: Cache UI build
|
||||
uses: actions/cache@v5
|
||||
id: 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') }}
|
||||
|
||||
- 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: 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'
|
||||
|
||||
# Places generated Go files + UI build into the working tree so the build compiles
|
||||
- name: Download generated artifacts
|
||||
uses: actions/download-artifact@v8
|
||||
- name: Pull compiler image
|
||||
run: docker pull $COMPILER_IMAGE
|
||||
|
||||
- name: Cache node modules
|
||||
uses: actions/cache@v3
|
||||
env:
|
||||
cache-name: cache-node_modules
|
||||
with:
|
||||
name: generated
|
||||
path: ui/v2.5/node_modules
|
||||
key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('ui/v2.5/pnpm-lock.yaml') }}
|
||||
|
||||
- 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
|
||||
- name: Cache UI build
|
||||
uses: actions/cache@v3
|
||||
id: cache-ui
|
||||
env:
|
||||
cache-name: cache-ui
|
||||
with:
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
path: ui/v2.5/build
|
||||
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: 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 seperate 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:
|
||||
@@ -180,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
|
||||
@@ -234,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
|
||||
@@ -242,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
|
||||
@@ -266,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' }}
|
||||
@@ -323,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
|
||||
@@ -339,7 +213,7 @@ jobs:
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
|
||||
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
|
||||
run: |
|
||||
docker run --rm --privileged tonistiigi/binfmt
|
||||
docker run --rm --privileged docker/binfmt:a7996909642ee92942dcd6cff44b9b95f08dad64
|
||||
docker info
|
||||
docker buildx create --name builder --use
|
||||
docker buildx inspect --bootstrap
|
||||
|
||||
61
.github/workflows/golangci-lint.yml
vendored
61
.github/workflows/golangci-lint.yml
vendored
@@ -9,20 +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'
|
||||
|
||||
- name: Pull compiler image
|
||||
run: docker pull $COMPILER_IMAGE
|
||||
|
||||
- name: Start build container
|
||||
run: |
|
||||
mkdir -p .go-cache
|
||||
docker run -d --name build --mount type=bind,source="$(pwd)",target=/stash,consistency=delegated --mount type=bind,source="$(pwd)/.go-cache",target=/root/.cache/go-build,consistency=delegated -w /stash $COMPILER_IMAGE tail -f /dev/null
|
||||
|
||||
# generate-backend runs natively (just go generate + touch-ui) — no Docker needed
|
||||
- name: Generate Backend
|
||||
run: make generate-backend
|
||||
run: docker exec -t build /bin/bash -c "make generate-backend"
|
||||
|
||||
## WARN
|
||||
## using v1, update in a later PR
|
||||
- name: Run golangci-lint
|
||||
uses: golangci/golangci-lint-action@v6
|
||||
uses: golangci/golangci-lint-action@v6
|
||||
with:
|
||||
# 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
|
||||
|
||||
16
Makefile
16
Makefile
@@ -50,7 +50,7 @@ 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
|
||||
|
||||
.PHONY: release
|
||||
@@ -129,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
|
||||
@@ -187,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
|
||||
@@ -196,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
|
||||
|
||||
58
README.md
58
README.md
@@ -13,10 +13,10 @@
|
||||
|
||||

|
||||
|
||||
- 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.
|
||||
|
||||
@@ -24,19 +24,17 @@ For further information you can consult the [documentation](https://docs.stashap
|
||||
|
||||
# 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.
|
||||
> Stash can still be run through docker on older versions of macOS.
|
||||
#### 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
|
||||
:---:|:---:|:---:|:---:
|
||||
@@ -87,23 +85,23 @@ The badge below shows the current translation status of Stash across all support
|
||||
|
||||
Need help or want to get involved? Start with the documentation, then reach out to the community if you need further assistance.
|
||||
|
||||
### Documentation
|
||||
- [Official documentation](https://docs.stashapp.cc) - official guides guides and troubleshooting.
|
||||
- [In-app manual](https://docs.stashapp.cc/in-app-manual) press <kbd>Shift</kbd> + <kbd>?</kbd> in the app or view the manual online.
|
||||
- [FAQ](https://discourse.stashapp.cc/c/support/faq/28) - common questions and answers.
|
||||
- [Community wiki](https://discourse.stashapp.cc/tags/c/community-wiki/22/stash) - guides, how-to’s and tips.
|
||||
- Documentation
|
||||
- Official docs: https://docs.stashapp.cc - official guides guides and troubleshooting.
|
||||
- In-app manual: press <kbd>Shift</kbd> + <kbd>?</kbd> in the app or view the manual online: https://docs.stashapp.cc/in-app-manual.
|
||||
- FAQ: https://discourse.stashapp.cc/c/support/faq/28 - common questions and answers.
|
||||
- Community wiki: https://discourse.stashapp.cc/tags/c/community-wiki/22/stash - guides, how-to’s and tips.
|
||||
|
||||
### Community & discussion
|
||||
- [Community 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 & 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 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
|
||||
- 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/
|
||||
|
||||
# For Developers
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ RUN if [ "$TARGETPLATFORM" = "linux/arm/v6" ]; then BIN=stash-linux-arm32v6; \
|
||||
FROM --platform=$TARGETPLATFORM alpine:latest AS app
|
||||
COPY --from=binary /stash /usr/bin/
|
||||
|
||||
RUN apk add --no-cache ca-certificates python3 py3-requests py3-requests-toolbelt py3-lxml py3-pip ffmpeg tzdata vips vips-tools vips-heif \
|
||||
RUN apk add --no-cache ca-certificates python3 py3-requests py3-requests-toolbelt py3-lxml py3-pip ffmpeg tzdata vips vips-tools \
|
||||
&& pip install --break-system-packages mechanicalsoup cloudscraper stashapp-tools
|
||||
ENV STASH_CONFIG_FILE=/root/.stash/config.yml
|
||||
|
||||
|
||||
1
docker/compiler/.gitignore
vendored
Normal file
1
docker/compiler/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
*.sdk.tar.*
|
||||
@@ -1,86 +1,82 @@
|
||||
### OSXCROSS
|
||||
FROM debian:bookworm AS osxcross
|
||||
# add osxcross
|
||||
WORKDIR /tmp/osxcross
|
||||
ARG OSXCROSS_REVISION=5e1b71fcceb23952f3229995edca1b6231525b5b
|
||||
ADD --checksum=sha256:d3f771bbc20612fea577b18a71be3af2eb5ad2dd44624196cf55de866d008647 https://codeload.github.com/tpoechtrager/osxcross/tar.gz/${OSXCROSS_REVISION} /tmp/osxcross.tar.gz
|
||||
FROM golang:1.24.3
|
||||
|
||||
ARG OSX_SDK_VERSION=11.3
|
||||
ARG OSX_SDK_DOWNLOAD_FILE=MacOSX${OSX_SDK_VERSION}.sdk.tar.xz
|
||||
ARG OSX_SDK_DOWNLOAD_URL=https://github.com/phracker/MacOSX-SDKs/releases/download/${OSX_SDK_VERSION}/${OSX_SDK_DOWNLOAD_FILE}
|
||||
ADD --checksum=sha256:cd4f08a75577145b8f05245a2975f7c81401d75e9535dcffbb879ee1deefcbf4 ${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=10.10
|
||||
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.24.3 AS builder
|
||||
ENV PATH=/opt/osx-ndk-x86/bin:$PATH
|
||||
|
||||
# copy in nodejs instead of using nodesource :thumbsup:
|
||||
COPY --from=docker.io/library/node:24-bookworm /usr/local /usr/local
|
||||
# copy in osxcross
|
||||
COPY --from=osxcross /tmp/osxcross/target/lib /usr/lib
|
||||
COPY --from=osxcross /tmp/osxcross/target /opt/osx-ndk-x86
|
||||
# copy in cross-freebsd
|
||||
COPY --from=freebsd /opt/cross-freebsd /opt/cross-freebsd
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
git make tar bash nodejs zip \
|
||||
clang llvm-dev cmake patch libxml2-dev uuid-dev libssl-dev xz-utils \
|
||||
bzip2 gzip sed cpio libbz2-dev zlib1g-dev \
|
||||
gcc-mingw-w64 \
|
||||
gcc-arm-linux-gnueabi libc-dev-armel-cross linux-libc-dev-armel-cross \
|
||||
gcc-aarch64-linux-gnu libc-dev-arm64-cross && \
|
||||
rm -rf /var/lib/apt/lists/*;
|
||||
|
||||
# pnpm install with npm
|
||||
RUN npm install -g pnpm
|
||||
|
||||
# git for getting hash
|
||||
# make and bash for building
|
||||
# FreeBSD cross-compilation setup
|
||||
# https://github.com/smartmontools/docker-build/blob/6b8c92560d17d325310ba02d9f5a4b250cb0764a/Dockerfile#L66
|
||||
ENV FREEBSD_VERSION 13.4
|
||||
ENV FREEBSD_DOWNLOAD_URL http://ftp.plusline.de/FreeBSD/releases/amd64/${FREEBSD_VERSION}-RELEASE/base.txz
|
||||
ENV FREEBSD_SHA 8e13b0a93daba349b8d28ad246d7beb327659b2ef4fe44d89f447392daec5a7c
|
||||
|
||||
# clang for macos
|
||||
# zip for stashapp.zip
|
||||
# gcc-extensions for cross-arch build
|
||||
# we still target arm soft float?
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
git make bash \
|
||||
clang zip \
|
||||
gcc-mingw-w64 \
|
||||
gcc-arm-linux-gnueabi \
|
||||
libc-dev-armel-cross linux-libc-dev-armel-cross \
|
||||
gcc-aarch64-linux-gnu libc-dev-arm64-cross && \
|
||||
rm -rf /var/lib/apt/lists/*;
|
||||
RUN cd /tmp && \
|
||||
curl -o base.txz $FREEBSD_DOWNLOAD_URL && \
|
||||
echo "$FREEBSD_SHA base.txz" | sha256sum -c - && \
|
||||
mkdir -p /opt/cross-freebsd && \
|
||||
cd /opt/cross-freebsd && \
|
||||
tar -xf /tmp/base.txz ./lib/ ./usr/lib/ ./usr/include/ && \
|
||||
rm -f /tmp/base.txz && \
|
||||
cd /opt/cross-freebsd/usr/lib && \
|
||||
find . -xtype l | xargs ls -l | grep ' /lib/' | awk '{print "ln -sf /opt/cross-freebsd"$11 " " $9}' | /bin/sh && \
|
||||
ln -s libc++.a libstdc++.a && \
|
||||
ln -s libc++.so libstdc++.so
|
||||
|
||||
# macOS cross-compilation setup
|
||||
ENV OSX_SDK_VERSION 11.3
|
||||
ENV OSX_SDK_DOWNLOAD_FILE MacOSX${OSX_SDK_VERSION}.sdk.tar.xz
|
||||
ENV OSX_SDK_DOWNLOAD_URL https://github.com/phracker/MacOSX-SDKs/releases/download/${OSX_SDK_VERSION}/${OSX_SDK_DOWNLOAD_FILE}
|
||||
ENV OSX_SDK_SHA cd4f08a75577145b8f05245a2975f7c81401d75e9535dcffbb879ee1deefcbf4
|
||||
ENV OSXCROSS_REVISION 5e1b71fcceb23952f3229995edca1b6231525b5b
|
||||
ENV OSXCROSS_DOWNLOAD_URL https://codeload.github.com/tpoechtrager/osxcross/tar.gz/${OSXCROSS_REVISION}
|
||||
ENV OSXCROSS_SHA d3f771bbc20612fea577b18a71be3af2eb5ad2dd44624196cf55de866d008647
|
||||
|
||||
RUN cd /tmp && \
|
||||
curl -o osxcross.tar.gz $OSXCROSS_DOWNLOAD_URL && \
|
||||
echo "$OSXCROSS_SHA osxcross.tar.gz" | sha256sum -c - && \
|
||||
mkdir osxcross && \
|
||||
tar --strip=1 -C osxcross -xf osxcross.tar.gz && \
|
||||
rm -f osxcross.tar.gz && \
|
||||
curl -Lo $OSX_SDK_DOWNLOAD_FILE $OSX_SDK_DOWNLOAD_URL && \
|
||||
echo "$OSX_SDK_SHA $OSX_SDK_DOWNLOAD_FILE" | sha256sum -c - && \
|
||||
mv $OSX_SDK_DOWNLOAD_FILE osxcross/tarballs/ && \
|
||||
UNATTENDED=yes SDK_VERSION=$OSX_SDK_VERSION OSX_VERSION_MIN=10.10 osxcross/build.sh && \
|
||||
cp osxcross/target/lib/* /usr/lib/ && \
|
||||
mv osxcross/target /opt/osx-ndk-x86 && \
|
||||
rm -rf /tmp/osxcross
|
||||
|
||||
ENV PATH /opt/osx-ndk-x86/bin:$PATH
|
||||
|
||||
RUN mkdir -p /root/.ssh && \
|
||||
chmod 0700 /root/.ssh && \
|
||||
ssh-keyscan github.com > /root/.ssh/known_hosts
|
||||
|
||||
# ignore "dubious ownership" errors
|
||||
RUN git config --global safe.directory '*'
|
||||
|
||||
# To test locally:
|
||||
# make generate
|
||||
# make ui
|
||||
# cd docker/compiler
|
||||
# docker build . -t ghcr.io/stashapp/compiler:latest
|
||||
# docker run --rm -v /PATH_TO_STASH:/stash -w /stash -i -t ghcr.io/stashapp/compiler:latest make build-cc-all
|
||||
# # binaries will show up in /dist
|
||||
# make build
|
||||
# docker run --rm -v /PATH_TO_STASH:/stash -w /stash -i -t stashapp/compiler:latest make build-cc-all
|
||||
# # binaries will show up in /dist
|
||||
|
||||
@@ -1,22 +1,16 @@
|
||||
host=ghcr.io
|
||||
user=stashapp
|
||||
repo=compiler
|
||||
version=13
|
||||
|
||||
VERSION_IMAGE = ${host}/${user}/${repo}:${version}
|
||||
LATEST_IMAGE = ${host}/${user}/${repo}:latest
|
||||
version=12
|
||||
|
||||
latest:
|
||||
docker build -t ${LATEST_IMAGE} .
|
||||
docker build -t ${user}/${repo}:latest .
|
||||
|
||||
build:
|
||||
docker build -t ${VERSION_IMAGE} -t ${LATEST_IMAGE} .
|
||||
docker build -t ${user}/${repo}:${version} -t ${user}/${repo}:latest .
|
||||
|
||||
build-no-cache:
|
||||
docker build --no-cache -t ${VERSION_IMAGE} -t ${LATEST_IMAGE} .
|
||||
docker build --no-cache -t ${user}/${repo}:${version} -t ${user}/${repo}:latest .
|
||||
|
||||
# requires docker login ghcr.io
|
||||
# echo $CR_PAT | docker login ghcr.io -u USERNAME --password-stdin
|
||||
push:
|
||||
docker push ${VERSION_IMAGE}
|
||||
docker push ${LATEST_IMAGE}
|
||||
install: build
|
||||
docker push ${user}/${repo}:${version}
|
||||
docker push ${user}/${repo}:latest
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
Modified from https://github.com/bep/dockerfiles/tree/master/ci-goreleaser
|
||||
|
||||
When the Dockerfile is changed, the version number should be incremented in [.github/workflows/build-compiler.yml](../../.github/workflows/build-compiler.yml) and the workflow [manually ran](). `env: COMPILER_IMAGE` in [.github/workflows/build.yml](../../.github/workflows/build.yml) also needs to be updated to pull the correct image tag.
|
||||
When the Dockerfile is changed, the version number should be incremented in the Makefile and the new version tag should be pushed to Docker Hub. The GitHub workflow files also need to be updated to pull the correct image tag.
|
||||
|
||||
@@ -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/`.
|
||||
|
||||
|
||||
1
go.mod
1
go.mod
@@ -44,7 +44,6 @@ 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
|
||||
|
||||
2
go.sum
2
go.sum
@@ -537,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=
|
||||
|
||||
@@ -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!
|
||||
@@ -159,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
|
||||
}
|
||||
|
||||
|
||||
@@ -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!
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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!
|
||||
}
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -73,7 +73,6 @@ type ScrapedTag {
|
||||
name: String!
|
||||
description: String
|
||||
alias_list: [String!]
|
||||
parent: ScrapedTag
|
||||
"Remote site ID, if applicable"
|
||||
remote_site_id: String
|
||||
}
|
||||
|
||||
@@ -31,11 +31,6 @@ fragment TagFragment on Tag {
|
||||
id
|
||||
description
|
||||
aliases
|
||||
category {
|
||||
id
|
||||
name
|
||||
description
|
||||
}
|
||||
}
|
||||
|
||||
fragment MeasurementsFragment on Measurements {
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
//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 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
|
||||
@@ -66,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 {
|
||||
@@ -167,16 +161,6 @@ 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: &SceneFileIDsLoader{
|
||||
wait: wait,
|
||||
maxBatch: maxBatch,
|
||||
@@ -422,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 {
|
||||
|
||||
@@ -1,225 +0,0 @@
|
||||
// Code generated by github.com/vektah/dataloaden, DO NOT EDIT.
|
||||
|
||||
package loaders
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
)
|
||||
|
||||
// FolderParentFolderIDsLoaderConfig captures the config to create a new FolderParentFolderIDsLoader
|
||||
type FolderParentFolderIDsLoaderConfig struct {
|
||||
// Fetch is a method that provides the data for the loader
|
||||
Fetch func(keys []models.FolderID) ([][]models.FolderID, []error)
|
||||
|
||||
// Wait is how long wait before sending a batch
|
||||
Wait time.Duration
|
||||
|
||||
// MaxBatch will limit the maximum number of keys to send in one batch, 0 = not limit
|
||||
MaxBatch int
|
||||
}
|
||||
|
||||
// NewFolderParentFolderIDsLoader creates a new FolderParentFolderIDsLoader given a fetch, wait, and maxBatch
|
||||
func NewFolderParentFolderIDsLoader(config FolderParentFolderIDsLoaderConfig) *FolderRelatedFolderIDsLoader {
|
||||
return &FolderRelatedFolderIDsLoader{
|
||||
fetch: config.Fetch,
|
||||
wait: config.Wait,
|
||||
maxBatch: config.MaxBatch,
|
||||
}
|
||||
}
|
||||
|
||||
// FolderRelatedFolderIDsLoader batches and caches requests
|
||||
type FolderRelatedFolderIDsLoader struct {
|
||||
// this method provides the data for the loader
|
||||
fetch func(keys []models.FolderID) ([][]models.FolderID, []error)
|
||||
|
||||
// how long to done before sending a batch
|
||||
wait time.Duration
|
||||
|
||||
// this will limit the maximum number of keys to send in one batch, 0 = no limit
|
||||
maxBatch int
|
||||
|
||||
// INTERNAL
|
||||
|
||||
// lazily created cache
|
||||
cache map[models.FolderID][]models.FolderID
|
||||
|
||||
// the current batch. keys will continue to be collected until timeout is hit,
|
||||
// then everything will be sent to the fetch method and out to the listeners
|
||||
batch *folderParentFolderIDsLoaderBatch
|
||||
|
||||
// mutex to prevent races
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
type folderParentFolderIDsLoaderBatch struct {
|
||||
keys []models.FolderID
|
||||
data [][]models.FolderID
|
||||
error []error
|
||||
closing bool
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
// Load a FolderID by key, batching and caching will be applied automatically
|
||||
func (l *FolderRelatedFolderIDsLoader) Load(key models.FolderID) ([]models.FolderID, error) {
|
||||
return l.LoadThunk(key)()
|
||||
}
|
||||
|
||||
// LoadThunk returns a function that when called will block waiting for a FolderID.
|
||||
// This method should be used if you want one goroutine to make requests to many
|
||||
// different data loaders without blocking until the thunk is called.
|
||||
func (l *FolderRelatedFolderIDsLoader) LoadThunk(key models.FolderID) func() ([]models.FolderID, error) {
|
||||
l.mu.Lock()
|
||||
if it, ok := l.cache[key]; ok {
|
||||
l.mu.Unlock()
|
||||
return func() ([]models.FolderID, error) {
|
||||
return it, nil
|
||||
}
|
||||
}
|
||||
if l.batch == nil {
|
||||
l.batch = &folderParentFolderIDsLoaderBatch{done: make(chan struct{})}
|
||||
}
|
||||
batch := l.batch
|
||||
pos := batch.keyIndex(l, key)
|
||||
l.mu.Unlock()
|
||||
|
||||
return func() ([]models.FolderID, error) {
|
||||
<-batch.done
|
||||
|
||||
var data []models.FolderID
|
||||
if pos < len(batch.data) {
|
||||
data = batch.data[pos]
|
||||
}
|
||||
|
||||
var err error
|
||||
// its convenient to be able to return a single error for everything
|
||||
if len(batch.error) == 1 {
|
||||
err = batch.error[0]
|
||||
} else if batch.error != nil {
|
||||
err = batch.error[pos]
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
l.mu.Lock()
|
||||
l.unsafeSet(key, data)
|
||||
l.mu.Unlock()
|
||||
}
|
||||
|
||||
return data, err
|
||||
}
|
||||
}
|
||||
|
||||
// LoadAll fetches many keys at once. It will be broken into appropriate sized
|
||||
// sub batches depending on how the loader is configured
|
||||
func (l *FolderRelatedFolderIDsLoader) LoadAll(keys []models.FolderID) ([][]models.FolderID, []error) {
|
||||
results := make([]func() ([]models.FolderID, error), len(keys))
|
||||
|
||||
for i, key := range keys {
|
||||
results[i] = l.LoadThunk(key)
|
||||
}
|
||||
|
||||
folderIDs := make([][]models.FolderID, len(keys))
|
||||
errors := make([]error, len(keys))
|
||||
for i, thunk := range results {
|
||||
folderIDs[i], errors[i] = thunk()
|
||||
}
|
||||
return folderIDs, errors
|
||||
}
|
||||
|
||||
// LoadAllThunk returns a function that when called will block waiting for a FolderIDs.
|
||||
// This method should be used if you want one goroutine to make requests to many
|
||||
// different data loaders without blocking until the thunk is called.
|
||||
func (l *FolderRelatedFolderIDsLoader) LoadAllThunk(keys []models.FolderID) func() ([][]models.FolderID, []error) {
|
||||
results := make([]func() ([]models.FolderID, error), len(keys))
|
||||
for i, key := range keys {
|
||||
results[i] = l.LoadThunk(key)
|
||||
}
|
||||
return func() ([][]models.FolderID, []error) {
|
||||
folderIDs := make([][]models.FolderID, len(keys))
|
||||
errors := make([]error, len(keys))
|
||||
for i, thunk := range results {
|
||||
folderIDs[i], errors[i] = thunk()
|
||||
}
|
||||
return folderIDs, errors
|
||||
}
|
||||
}
|
||||
|
||||
// Prime the cache with the provided key and value. If the key already exists, no change is made
|
||||
// and false is returned.
|
||||
// (To forcefully prime the cache, clear the key first with loader.clear(key).prime(key, value).)
|
||||
func (l *FolderRelatedFolderIDsLoader) Prime(key models.FolderID, value []models.FolderID) bool {
|
||||
l.mu.Lock()
|
||||
var found bool
|
||||
if _, found = l.cache[key]; !found {
|
||||
// make a copy when writing to the cache, its easy to pass a pointer in from a loop var
|
||||
// and end up with the whole cache pointing to the same value.
|
||||
cpy := make([]models.FolderID, len(value))
|
||||
copy(cpy, value)
|
||||
l.unsafeSet(key, cpy)
|
||||
}
|
||||
l.mu.Unlock()
|
||||
return !found
|
||||
}
|
||||
|
||||
// Clear the value at key from the cache, if it exists
|
||||
func (l *FolderRelatedFolderIDsLoader) Clear(key models.FolderID) {
|
||||
l.mu.Lock()
|
||||
delete(l.cache, key)
|
||||
l.mu.Unlock()
|
||||
}
|
||||
|
||||
func (l *FolderRelatedFolderIDsLoader) unsafeSet(key models.FolderID, value []models.FolderID) {
|
||||
if l.cache == nil {
|
||||
l.cache = map[models.FolderID][]models.FolderID{}
|
||||
}
|
||||
l.cache[key] = value
|
||||
}
|
||||
|
||||
// keyIndex will return the location of the key in the batch, if its not found
|
||||
// it will add the key to the batch
|
||||
func (b *folderParentFolderIDsLoaderBatch) keyIndex(l *FolderRelatedFolderIDsLoader, key models.FolderID) int {
|
||||
for i, existingKey := range b.keys {
|
||||
if key == existingKey {
|
||||
return i
|
||||
}
|
||||
}
|
||||
|
||||
pos := len(b.keys)
|
||||
b.keys = append(b.keys, key)
|
||||
if pos == 0 {
|
||||
go b.startTimer(l)
|
||||
}
|
||||
|
||||
if l.maxBatch != 0 && pos >= l.maxBatch-1 {
|
||||
if !b.closing {
|
||||
b.closing = true
|
||||
l.batch = nil
|
||||
go b.end(l)
|
||||
}
|
||||
}
|
||||
|
||||
return pos
|
||||
}
|
||||
|
||||
func (b *folderParentFolderIDsLoaderBatch) startTimer(l *FolderRelatedFolderIDsLoader) {
|
||||
time.Sleep(l.wait)
|
||||
l.mu.Lock()
|
||||
|
||||
// we must have hit a batch limit and are already finalizing this batch
|
||||
if b.closing {
|
||||
l.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
l.batch = nil
|
||||
l.mu.Unlock()
|
||||
|
||||
b.end(l)
|
||||
}
|
||||
|
||||
func (b *folderParentFolderIDsLoaderBatch) end(l *FolderRelatedFolderIDsLoader) {
|
||||
b.data, b.error = l.fetch(b.keys)
|
||||
close(b.done)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -288,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
|
||||
|
||||
@@ -308,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
|
||||
@@ -335,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
|
||||
|
||||
@@ -348,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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -335,53 +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,
|
||||
// or if a force rescan was requested.
|
||||
// so shouldn't need to scan it again
|
||||
|
||||
if j.scanner.IsZipFile(f.Info.Name()) && (r.New || r.FingerprintChanged || j.scanner.Rescan) {
|
||||
if (r.New || r.Updated) && j.scanner.IsZipFile(f.Info.Name()) {
|
||||
ff := r.File
|
||||
f.BaseFile = ff.Base()
|
||||
|
||||
@@ -393,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
|
||||
@@ -449,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]
|
||||
|
||||
@@ -468,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(),
|
||||
}
|
||||
@@ -542,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
|
||||
@@ -587,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
|
||||
@@ -660,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,
|
||||
@@ -691,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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
101
pkg/file/scan.go
101
pkg/file/scan.go
@@ -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,15 +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
|
||||
}
|
||||
|
||||
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.
|
||||
@@ -418,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
|
||||
@@ -462,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
|
||||
}
|
||||
@@ -576,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 {
|
||||
@@ -618,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)
|
||||
@@ -651,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)
|
||||
}
|
||||
}
|
||||
@@ -796,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)
|
||||
|
||||
@@ -822,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
|
||||
}
|
||||
|
||||
|
||||
@@ -1,262 +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.
|
||||
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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(`\-+`)
|
||||
)
|
||||
|
||||
|
||||
@@ -15,9 +15,6 @@ func TestSanitiseBasename(t *testing.T) {
|
||||
{"multi-hyphen", `hyphened--name`, "hyphened-name-2da2a58f"},
|
||||
{"replaced characters", `a&b=c\d/:e*"f?_ g`, "a-b-c-d-e-f-g-ffca6fb0"},
|
||||
{"removed characters", `foo!!bar@@and, more`, "foobarand-more-7cee02ab"},
|
||||
{"unicode cjk", `テスト`, "テスト-63b560db"},
|
||||
{"unicode korean", `시험`, "시험-3fcc7beb"},
|
||||
{"mixed unicode", `Test テスト`, "Test-テスト-366aff1e"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
@@ -26,10 +27,18 @@ func IsFsPathCaseSensitive(path string) (bool, error) {
|
||||
if err != nil { // cannot be case flipped
|
||||
return false, err
|
||||
}
|
||||
i := strings.LastIndex(path, base)
|
||||
if i < 0 { // shouldn't happen
|
||||
return false, fmt.Errorf("could not case flip path %s", path)
|
||||
}
|
||||
|
||||
flippedPath := filepath.Join(filepath.Dir(path), fBase)
|
||||
flipped := []rune(path)
|
||||
for _, c := range fBase { // replace base of path with the flipped one ( we need to flip the base or last dir part )
|
||||
flipped[i] = c
|
||||
i++
|
||||
}
|
||||
|
||||
fiCase, err := os.Stat(flippedPath)
|
||||
fiCase, err := os.Stat(string(flipped))
|
||||
if err != nil { // cannot stat the case flipped path
|
||||
return true, nil // fs of path should be case sensitive
|
||||
}
|
||||
|
||||
@@ -41,15 +41,4 @@ func TestIsFsPathCaseSensitive_UnicodeByteLength(t *testing.T) {
|
||||
}
|
||||
|
||||
// assert.True(t, r, "expected fs to be case sensitive")
|
||||
|
||||
// Ensure that subfolders of a folder with multi-byte chars is not causing a panic
|
||||
path3 := filepath.Join(dir, "NoPanic ❤️")
|
||||
makeDir(path3)
|
||||
path4 := filepath.Join(path3, "Test")
|
||||
makeDir(path4)
|
||||
|
||||
_, err = IsFsPathCaseSensitive(path4)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ type ScanCreatorUpdater interface {
|
||||
|
||||
type ScanSceneFinderUpdater interface {
|
||||
FindByPath(ctx context.Context, p string) ([]*models.Scene, error)
|
||||
Update(ctx context.Context, updatedScene *models.Scene) error
|
||||
AddGalleryIDs(ctx context.Context, sceneID int, galleryIDs []int) error
|
||||
}
|
||||
|
||||
@@ -134,14 +135,13 @@ func (h *ScanHandler) associateExisting(ctx context.Context, existing []*models.
|
||||
if err := h.CreatorUpdater.AddFileID(ctx, i.ID, f.Base().ID); err != nil {
|
||||
return fmt.Errorf("adding file to gallery: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if !found || updateExisting {
|
||||
// update updated_at time when file association or content changes
|
||||
// update updated_at time
|
||||
if _, err := h.CreatorUpdater.UpdatePartial(ctx, i.ID, models.NewGalleryPartial()); err != nil {
|
||||
return fmt.Errorf("updating gallery: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if !found || updateExisting {
|
||||
h.PluginCache.RegisterPostHooks(ctx, i.ID, hook.GalleryUpdatePost, nil, nil)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
package gallery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stashapp/stash/pkg/models/mocks"
|
||||
"github.com/stashapp/stash/pkg/plugin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
func TestAssociateExisting_UpdatePartialOnContentChange(t *testing.T) {
|
||||
const (
|
||||
testGalleryID = 1
|
||||
testFileID = 100
|
||||
)
|
||||
|
||||
existingFile := &models.BaseFile{ID: models.FileID(testFileID), Path: "test.zip"}
|
||||
|
||||
makeGallery := func() *models.Gallery {
|
||||
return &models.Gallery{
|
||||
ID: testGalleryID,
|
||||
Files: models.NewRelatedFiles([]models.File{existingFile}),
|
||||
}
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
updateExisting bool
|
||||
expectUpdate bool
|
||||
}{
|
||||
{
|
||||
name: "calls UpdatePartial when file content changed",
|
||||
updateExisting: true,
|
||||
expectUpdate: true,
|
||||
},
|
||||
{
|
||||
name: "skips UpdatePartial when file unchanged and already associated",
|
||||
updateExisting: false,
|
||||
expectUpdate: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
db := mocks.NewDatabase()
|
||||
db.Gallery.On("GetFiles", mock.Anything, testGalleryID).Return([]models.File{existingFile}, nil)
|
||||
|
||||
if tt.expectUpdate {
|
||||
db.Gallery.On("UpdatePartial", mock.Anything, testGalleryID, mock.Anything).
|
||||
Return(&models.Gallery{ID: testGalleryID}, nil)
|
||||
}
|
||||
|
||||
h := &ScanHandler{
|
||||
CreatorUpdater: db.Gallery,
|
||||
PluginCache: &plugin.Cache{},
|
||||
}
|
||||
|
||||
db.WithTxnCtx(func(ctx context.Context) {
|
||||
err := h.associateExisting(ctx, []*models.Gallery{makeGallery()}, existingFile, tt.updateExisting)
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
if tt.expectUpdate {
|
||||
db.Gallery.AssertCalled(t, "UpdatePartial", mock.Anything, testGalleryID, mock.Anything)
|
||||
} else {
|
||||
db.Gallery.AssertNotCalled(t, "UpdatePartial", mock.Anything, mock.Anything, mock.Anything)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssociateExisting_UpdatePartialOnNewFile(t *testing.T) {
|
||||
const (
|
||||
testGalleryID = 1
|
||||
existFileID = 100
|
||||
newFileID = 200
|
||||
)
|
||||
|
||||
existingFile := &models.BaseFile{ID: models.FileID(existFileID), Path: "existing.zip"}
|
||||
newFile := &models.BaseFile{ID: models.FileID(newFileID), Path: "new.zip"}
|
||||
|
||||
gallery := &models.Gallery{
|
||||
ID: testGalleryID,
|
||||
Files: models.NewRelatedFiles([]models.File{existingFile}),
|
||||
}
|
||||
|
||||
db := mocks.NewDatabase()
|
||||
db.Gallery.On("GetFiles", mock.Anything, testGalleryID).Return([]models.File{existingFile}, nil)
|
||||
db.Gallery.On("AddFileID", mock.Anything, testGalleryID, models.FileID(newFileID)).Return(nil)
|
||||
db.Gallery.On("UpdatePartial", mock.Anything, testGalleryID, mock.Anything).
|
||||
Return(&models.Gallery{ID: testGalleryID}, nil)
|
||||
|
||||
h := &ScanHandler{
|
||||
CreatorUpdater: db.Gallery,
|
||||
PluginCache: &plugin.Cache{},
|
||||
}
|
||||
|
||||
db.WithTxnCtx(func(ctx context.Context) {
|
||||
err := h.associateExisting(ctx, []*models.Gallery{gallery}, newFile, false)
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
db.Gallery.AssertCalled(t, "AddFileID", mock.Anything, testGalleryID, models.FileID(newFileID))
|
||||
db.Gallery.AssertCalled(t, "UpdatePartial", mock.Anything, testGalleryID, mock.Anything)
|
||||
}
|
||||
@@ -3,9 +3,10 @@ package imagephash
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/corona10/goimagehash"
|
||||
"github.com/stashapp/stash/pkg/ffmpeg"
|
||||
@@ -31,9 +32,17 @@ func Generate(encoder *ffmpeg.FFMpeg, imageFile *models.ImageFile) (*uint64, err
|
||||
}
|
||||
|
||||
// loadImage loads an image from disk and decodes it.
|
||||
// Where Go has no built-in decoder for a specific format, ffmpeg is used to convert to BMP first.
|
||||
// For AVIF files, ffmpeg is used to convert to BMP first since Go has no built-in AVIF decoder.
|
||||
func loadImage(encoder *ffmpeg.FFMpeg, imageFile *models.ImageFile) (image.Image, error) {
|
||||
// try to load with Go's built-in decoders first for better performance
|
||||
ext := strings.ToLower(filepath.Ext(imageFile.Path))
|
||||
if ext == ".avif" {
|
||||
// AVIF in zip files is not supported - ffmpeg cannot read files inside zips
|
||||
if imageFile.Base().ZipFileID != nil {
|
||||
return nil, fmt.Errorf("AVIF images in zip files are not supported for phash generation")
|
||||
}
|
||||
return loadImageFFmpeg(encoder, imageFile.Path)
|
||||
}
|
||||
|
||||
reader, err := imageFile.Open(&file.OsFS{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -46,15 +55,6 @@ func loadImage(encoder *ffmpeg.FFMpeg, imageFile *models.ImageFile) (image.Image
|
||||
}
|
||||
|
||||
img, _, err := image.Decode(buf)
|
||||
if errors.Is(err, image.ErrFormat) {
|
||||
// try ffmpeg as a fallback for unsupported formats
|
||||
// ffmpeg cannot read files inside zips
|
||||
if imageFile.Base().ZipFileID != nil {
|
||||
return nil, fmt.Errorf("ffmpeg fallback unsupported for images in zip files")
|
||||
}
|
||||
return loadImageFFmpeg(encoder, imageFile.Path)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decoding image: %w", err)
|
||||
}
|
||||
|
||||
@@ -2,9 +2,7 @@ package image
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
)
|
||||
@@ -48,35 +46,6 @@ func Query(ctx context.Context, qb Queryer, imageFilter *models.ImageFilterType,
|
||||
return images, nil
|
||||
}
|
||||
|
||||
// FilterFromPaths creates a ImageFilterType that filters using the provided
|
||||
// paths.
|
||||
func FilterFromPaths(paths []string) *models.ImageFilterType {
|
||||
ret := &models.ImageFilterType{}
|
||||
or := ret
|
||||
sep := string(filepath.Separator)
|
||||
|
||||
for _, p := range paths {
|
||||
if !strings.HasSuffix(p, sep) {
|
||||
p += sep
|
||||
}
|
||||
|
||||
if ret.Path == nil {
|
||||
or = ret
|
||||
} else {
|
||||
newOr := &models.ImageFilterType{}
|
||||
or.Or = newOr
|
||||
or = newOr
|
||||
}
|
||||
|
||||
or.Path = &models.StringCriterionInput{
|
||||
Modifier: models.CriterionModifierEquals,
|
||||
Value: p + "%",
|
||||
}
|
||||
}
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
func CountByPerformerID(ctx context.Context, r QueryCounter, id int) (int, error) {
|
||||
filter := &models.ImageFilterType{
|
||||
Performers: &models.MultiCriterionInput{
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/stashapp/stash/pkg/logger"
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
@@ -40,11 +39,6 @@ type GalleryFinderCreator interface {
|
||||
UpdatePartial(ctx context.Context, id int, updatedGallery models.GalleryPartial) (*models.Gallery, error)
|
||||
}
|
||||
|
||||
type ScanSceneFinderUpdater interface {
|
||||
FindByPath(ctx context.Context, p string) ([]*models.Scene, error)
|
||||
AddGalleryIDs(ctx context.Context, sceneID int, galleryIDs []int) error
|
||||
}
|
||||
|
||||
type ScanConfig interface {
|
||||
GetCreateGalleriesFromFolders() bool
|
||||
}
|
||||
@@ -54,9 +48,8 @@ type ScanGenerator interface {
|
||||
}
|
||||
|
||||
type ScanHandler struct {
|
||||
CreatorUpdater ScanCreatorUpdater
|
||||
GalleryFinder GalleryFinderCreator
|
||||
SceneFinderUpdater ScanSceneFinderUpdater
|
||||
CreatorUpdater ScanCreatorUpdater
|
||||
GalleryFinder GalleryFinderCreator
|
||||
|
||||
ScanGenerator ScanGenerator
|
||||
|
||||
@@ -217,8 +210,8 @@ func (h *ScanHandler) associateExisting(ctx context.Context, existing []*models.
|
||||
changed = true
|
||||
}
|
||||
|
||||
if changed || updateExisting {
|
||||
// update updated_at time when file association or content changes
|
||||
if changed {
|
||||
// always update updated_at time
|
||||
imagePartial := models.NewImagePartial()
|
||||
imagePartial.GalleryIDs = galleryIDs
|
||||
|
||||
@@ -236,7 +229,9 @@ func (h *ScanHandler) associateExisting(ctx context.Context, existing []*models.
|
||||
return fmt.Errorf("updating gallery updated at timestamp: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if changed || updateExisting {
|
||||
h.PluginCache.RegisterPostHooks(ctx, i.ID, hook.ImageUpdatePost, nil, nil)
|
||||
}
|
||||
}
|
||||
@@ -329,39 +324,11 @@ func (h *ScanHandler) getOrCreateZipBasedGallery(ctx context.Context, zipFile mo
|
||||
return nil, fmt.Errorf("creating zip-based gallery: %w", err)
|
||||
}
|
||||
|
||||
// try to associate with scene
|
||||
if err := h.associateScene(ctx, &newGallery, zipFile); err != nil {
|
||||
return nil, fmt.Errorf("associating scene: %w", err)
|
||||
}
|
||||
|
||||
h.PluginCache.RegisterPostHooks(ctx, newGallery.ID, hook.GalleryCreatePost, nil, nil)
|
||||
|
||||
return &newGallery, nil
|
||||
}
|
||||
|
||||
func (h *ScanHandler) associateScene(ctx context.Context, existing *models.Gallery, zipFile models.File) error {
|
||||
galleryIDs := []int{existing.ID}
|
||||
|
||||
path := zipFile.Base().Path
|
||||
withoutExt := strings.TrimSuffix(path, filepath.Ext(path)) + ".*"
|
||||
|
||||
// find scenes with a file that matches
|
||||
scenes, err := h.SceneFinderUpdater.FindByPath(ctx, withoutExt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, scene := range scenes {
|
||||
// found related Scene
|
||||
logger.Infof("associate: Gallery %s is related to scene: %d", path, scene.ID)
|
||||
if err := h.SceneFinderUpdater.AddGalleryIDs(ctx, scene.ID, galleryIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *ScanHandler) getOrCreateGallery(ctx context.Context, f models.File) (*models.Gallery, error) {
|
||||
// don't create folder-based galleries for files in zip file
|
||||
if f.Base().ZipFile != nil {
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
package image
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stashapp/stash/pkg/models/mocks"
|
||||
"github.com/stashapp/stash/pkg/plugin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
type mockScanConfig struct{}
|
||||
|
||||
func (m *mockScanConfig) GetCreateGalleriesFromFolders() bool { return false }
|
||||
|
||||
func TestAssociateExisting_UpdatePartialOnContentChange(t *testing.T) {
|
||||
const (
|
||||
testImageID = 1
|
||||
testFileID = 100
|
||||
)
|
||||
|
||||
existingFile := &models.BaseFile{ID: models.FileID(testFileID), Path: "/images/test.jpg"}
|
||||
|
||||
makeImage := func() *models.Image {
|
||||
return &models.Image{
|
||||
ID: testImageID,
|
||||
Files: models.NewRelatedFiles([]models.File{existingFile}),
|
||||
GalleryIDs: models.NewRelatedIDs([]int{}),
|
||||
}
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
updateExisting bool
|
||||
expectUpdate bool
|
||||
}{
|
||||
{
|
||||
name: "calls UpdatePartial when file content changed",
|
||||
updateExisting: true,
|
||||
expectUpdate: true,
|
||||
},
|
||||
{
|
||||
name: "skips UpdatePartial when file unchanged and already associated",
|
||||
updateExisting: false,
|
||||
expectUpdate: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
db := mocks.NewDatabase()
|
||||
db.Image.On("GetFiles", mock.Anything, testImageID).Return([]models.File{existingFile}, nil)
|
||||
db.Image.On("GetGalleryIDs", mock.Anything, testImageID).Return([]int{}, nil)
|
||||
|
||||
if tt.expectUpdate {
|
||||
db.Image.On("UpdatePartial", mock.Anything, testImageID, mock.Anything).
|
||||
Return(&models.Image{ID: testImageID}, nil)
|
||||
}
|
||||
|
||||
h := &ScanHandler{
|
||||
CreatorUpdater: db.Image,
|
||||
GalleryFinder: db.Gallery,
|
||||
ScanConfig: &mockScanConfig{},
|
||||
PluginCache: &plugin.Cache{},
|
||||
}
|
||||
|
||||
db.WithTxnCtx(func(ctx context.Context) {
|
||||
err := h.associateExisting(ctx, []*models.Image{makeImage()}, existingFile, tt.updateExisting)
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
if tt.expectUpdate {
|
||||
db.Image.AssertCalled(t, "UpdatePartial", mock.Anything, testImageID, mock.Anything)
|
||||
} else {
|
||||
db.Image.AssertNotCalled(t, "UpdatePartial", mock.Anything, mock.Anything, mock.Anything)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssociateExisting_UpdatePartialOnNewFile(t *testing.T) {
|
||||
const (
|
||||
testImageID = 1
|
||||
existFileID = 100
|
||||
newFileID = 200
|
||||
)
|
||||
|
||||
existingFile := &models.BaseFile{ID: models.FileID(existFileID), Path: "/images/existing.jpg"}
|
||||
newFile := &models.BaseFile{ID: models.FileID(newFileID), Path: "/images/new.jpg"}
|
||||
|
||||
image := &models.Image{
|
||||
ID: testImageID,
|
||||
Files: models.NewRelatedFiles([]models.File{existingFile}),
|
||||
GalleryIDs: models.NewRelatedIDs([]int{}),
|
||||
}
|
||||
|
||||
db := mocks.NewDatabase()
|
||||
db.Image.On("GetFiles", mock.Anything, testImageID).Return([]models.File{existingFile}, nil)
|
||||
db.Image.On("GetGalleryIDs", mock.Anything, testImageID).Return([]int{}, nil)
|
||||
db.Image.On("AddFileID", mock.Anything, testImageID, models.FileID(newFileID)).Return(nil)
|
||||
db.Image.On("UpdatePartial", mock.Anything, testImageID, mock.Anything).
|
||||
Return(&models.Image{ID: testImageID}, nil)
|
||||
|
||||
h := &ScanHandler{
|
||||
CreatorUpdater: db.Image,
|
||||
GalleryFinder: db.Gallery,
|
||||
ScanConfig: &mockScanConfig{},
|
||||
PluginCache: &plugin.Cache{},
|
||||
}
|
||||
|
||||
db.WithTxnCtx(func(ctx context.Context) {
|
||||
err := h.associateExisting(ctx, []*models.Image{image}, newFile, false)
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
db.Image.AssertCalled(t, "AddFileID", mock.Anything, testImageID, models.FileID(newFileID))
|
||||
db.Image.AssertCalled(t, "UpdatePartial", mock.Anything, testImageID, mock.Anything)
|
||||
}
|
||||
@@ -188,20 +188,6 @@ func ScrapedGroup(ctx context.Context, qb GroupNamesFinder, storedID *string, na
|
||||
return
|
||||
}
|
||||
|
||||
// ScrapedTagHierarchy executes ScrapedTag for the provided tag and its parent.
|
||||
func ScrapedTagHierarchy(ctx context.Context, qb models.TagQueryer, s *models.ScrapedTag, stashBoxEndpoint string) error {
|
||||
if err := ScrapedTag(ctx, qb, s, stashBoxEndpoint); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if s.Parent == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Match parent by name only (categories don't have StashDB tag IDs)
|
||||
return ScrapedTag(ctx, qb, s.Parent, "")
|
||||
}
|
||||
|
||||
// ScrapedTag matches the provided tag with the tags
|
||||
// in the database and sets the ID field if one is found.
|
||||
func ScrapedTag(ctx context.Context, qb models.TagQueryer, s *models.ScrapedTag, stashBoxEndpoint string) error {
|
||||
|
||||
@@ -2,7 +2,6 @@ package models
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/stashapp/stash/pkg/utils"
|
||||
@@ -62,114 +61,3 @@ func ParseDate(s string) (Date, error) {
|
||||
|
||||
return Date{}, fmt.Errorf("failed to parse date %q: %v", s, errs)
|
||||
}
|
||||
|
||||
func DateFromYear(year int) Date {
|
||||
return Date{
|
||||
Time: time.Date(year, 1, 1, 0, 0, 0, 0, time.UTC),
|
||||
Precision: DatePrecisionYear,
|
||||
}
|
||||
}
|
||||
|
||||
func FormatYearRange(start *Date, end *Date) string {
|
||||
var (
|
||||
startStr, endStr string
|
||||
)
|
||||
|
||||
if start != nil {
|
||||
startStr = start.Format(dateFormatPrecision[DatePrecisionYear])
|
||||
}
|
||||
|
||||
if end != nil {
|
||||
endStr = end.Format(dateFormatPrecision[DatePrecisionYear])
|
||||
}
|
||||
|
||||
switch {
|
||||
case startStr == "" && endStr == "":
|
||||
return ""
|
||||
case endStr == "":
|
||||
return fmt.Sprintf("%s -", startStr)
|
||||
case startStr == "":
|
||||
return fmt.Sprintf("- %s", endStr)
|
||||
default:
|
||||
return fmt.Sprintf("%s - %s", startStr, endStr)
|
||||
}
|
||||
}
|
||||
|
||||
func FormatYearRangeString(start *string, end *string) string {
|
||||
switch {
|
||||
case start == nil && end == nil:
|
||||
return ""
|
||||
case end == nil:
|
||||
return fmt.Sprintf("%s -", *start)
|
||||
case start == nil:
|
||||
return fmt.Sprintf("- %s", *end)
|
||||
default:
|
||||
return fmt.Sprintf("%s - %s", *start, *end)
|
||||
}
|
||||
}
|
||||
|
||||
// ParseYearRangeString parses a year range string into start and end year integers.
|
||||
// Supported formats: "YYYY", "YYYY - YYYY", "YYYY-YYYY", "YYYY -", "- YYYY", "YYYY-present".
|
||||
// Returns nil for start/end if not present in the string.
|
||||
func ParseYearRangeString(s string) (start *Date, end *Date, err error) {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return nil, nil, fmt.Errorf("empty year range string")
|
||||
}
|
||||
|
||||
// normalize "present" to empty end
|
||||
lower := strings.ToLower(s)
|
||||
lower = strings.ReplaceAll(lower, "present", "")
|
||||
|
||||
// split on "-" if it contains one
|
||||
var parts []string
|
||||
if strings.Contains(lower, "-") {
|
||||
parts = strings.SplitN(lower, "-", 2)
|
||||
} else {
|
||||
// single value, treat as start year
|
||||
year, err := parseYear(lower)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("invalid year range %q: %w", s, err)
|
||||
}
|
||||
return year, nil, nil
|
||||
}
|
||||
|
||||
startStr := strings.TrimSpace(parts[0])
|
||||
endStr := strings.TrimSpace(parts[1])
|
||||
|
||||
if startStr != "" {
|
||||
y, err := parseYear(startStr)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("invalid start year in %q: %w", s, err)
|
||||
}
|
||||
start = y
|
||||
}
|
||||
|
||||
if endStr != "" {
|
||||
y, err := parseYear(endStr)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("invalid end year in %q: %w", s, err)
|
||||
}
|
||||
end = y
|
||||
}
|
||||
|
||||
if start == nil && end == nil {
|
||||
return nil, nil, fmt.Errorf("could not parse year range %q", s)
|
||||
}
|
||||
|
||||
return start, end, nil
|
||||
}
|
||||
|
||||
func parseYear(s string) (*Date, error) {
|
||||
ret, err := ParseDate(s)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parsing year %q: %w", s, err)
|
||||
}
|
||||
|
||||
year := ret.Time.Year()
|
||||
if year < 1900 || year > 2200 {
|
||||
return nil, fmt.Errorf("year %d out of reasonable range", year)
|
||||
}
|
||||
|
||||
return &ret, nil
|
||||
}
|
||||
|
||||
@@ -3,8 +3,6 @@ package models
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestParseDateStringAsTime(t *testing.T) {
|
||||
@@ -50,102 +48,3 @@ func TestParseDateStringAsTime(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatYearRange(t *testing.T) {
|
||||
datePtr := func(v int) *Date {
|
||||
date := DateFromYear(v)
|
||||
return &date
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
start *Date
|
||||
end *Date
|
||||
want string
|
||||
}{
|
||||
{"both nil", nil, nil, ""},
|
||||
{"only start", datePtr(2005), nil, "2005 -"},
|
||||
{"only end", nil, datePtr(2010), "- 2010"},
|
||||
{"start and end", datePtr(2005), datePtr(2010), "2005 - 2010"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := FormatYearRange(tt.start, tt.end)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatYearRangeString(t *testing.T) {
|
||||
stringPtr := func(v string) *string { return &v }
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
start *string
|
||||
end *string
|
||||
want string
|
||||
}{
|
||||
{"both nil", nil, nil, ""},
|
||||
{"only start", stringPtr("2005"), nil, "2005 -"},
|
||||
{"only end", nil, stringPtr("2010"), "- 2010"},
|
||||
{"start and end", stringPtr("2005"), stringPtr("2010"), "2005 - 2010"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := FormatYearRangeString(tt.start, tt.end)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseYearRangeString(t *testing.T) {
|
||||
intPtr := func(v int) *int { return &v }
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantStart *int
|
||||
wantEnd *int
|
||||
wantErr bool
|
||||
}{
|
||||
{"single year", "2005", intPtr(2005), nil, false},
|
||||
{"year range with spaces", "2005 - 2010", intPtr(2005), intPtr(2010), false},
|
||||
{"year range no spaces", "2005-2010", intPtr(2005), intPtr(2010), false},
|
||||
{"year dash open", "2005 -", intPtr(2005), nil, false},
|
||||
{"year dash open no space", "2005-", intPtr(2005), nil, false},
|
||||
{"dash year", "- 2010", nil, intPtr(2010), false},
|
||||
{"year present", "2005-present", intPtr(2005), nil, false},
|
||||
{"year Present caps", "2005 - Present", intPtr(2005), nil, false},
|
||||
{"whitespace padding", " 2005 - 2010 ", intPtr(2005), intPtr(2010), false},
|
||||
{"empty string", "", nil, nil, true},
|
||||
{"garbage", "not a year", nil, nil, true},
|
||||
{"partial garbage start", "abc - 2010", nil, nil, true},
|
||||
{"partial garbage end", "2005 - abc", nil, nil, true},
|
||||
{"year out of range", "1800", nil, nil, true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
start, end, err := ParseYearRangeString(tt.input)
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
return
|
||||
}
|
||||
assert.NoError(t, err)
|
||||
if tt.wantStart != nil {
|
||||
assert.NotNil(t, start)
|
||||
assert.Equal(t, *tt.wantStart, start.Time.Year())
|
||||
} else {
|
||||
assert.Nil(t, start)
|
||||
}
|
||||
if tt.wantEnd != nil {
|
||||
assert.NotNil(t, end)
|
||||
assert.Equal(t, *tt.wantEnd, end.Time.Year())
|
||||
} else {
|
||||
assert.Nil(t, end)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,8 +18,10 @@ type FolderQueryOptions struct {
|
||||
type FolderFilterType struct {
|
||||
OperatorFilter[FolderFilterType]
|
||||
|
||||
Path *StringCriterionInput `json:"path,omitempty"`
|
||||
Basename *StringCriterionInput `json:"basename,omitempty"`
|
||||
Path *StringCriterionInput `json:"path,omitempty"`
|
||||
Basename *StringCriterionInput `json:"basename,omitempty"`
|
||||
// Filter by parent directory path
|
||||
Dir *StringCriterionInput `json:"dir,omitempty"`
|
||||
ParentFolder *HierarchicalMultiCriterionInput `json:"parent_folder,omitempty"`
|
||||
ZipFile *MultiCriterionInput `json:"zip_file,omitempty"`
|
||||
// Filter by modification time
|
||||
|
||||
@@ -11,8 +11,6 @@ type GalleryFilterType struct {
|
||||
Checksum *StringCriterionInput `json:"checksum"`
|
||||
// Filter by path
|
||||
Path *StringCriterionInput `json:"path"`
|
||||
// Filter by parent folder
|
||||
ParentFolder *HierarchicalMultiCriterionInput `json:"parent_folder,omitempty"`
|
||||
// Filter by zip file count
|
||||
FileCount *IntCriterionInput `json:"file_count"`
|
||||
// Filter to only include galleries missing this property
|
||||
|
||||
@@ -49,8 +49,8 @@ type Performer struct {
|
||||
PenisLength float64 `json:"penis_length,omitempty"`
|
||||
Circumcised string `json:"circumcised,omitempty"`
|
||||
CareerLength string `json:"career_length,omitempty"` // deprecated - for import only
|
||||
CareerStart string `json:"career_start,omitempty"`
|
||||
CareerEnd string `json:"career_end,omitempty"`
|
||||
CareerStart *int `json:"career_start,omitempty"`
|
||||
CareerEnd *int `json:"career_end,omitempty"`
|
||||
Tattoos string `json:"tattoos,omitempty"`
|
||||
Piercings string `json:"piercings,omitempty"`
|
||||
Aliases StringOrStringList `json:"aliases,omitempty"`
|
||||
|
||||
@@ -153,13 +153,13 @@ func (_m *FileReaderWriter) FindAllByPath(ctx context.Context, path string, case
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// FindAllInPaths provides a mock function with given fields: ctx, p, includeZipContents, limit, offset
|
||||
func (_m *FileReaderWriter) FindAllInPaths(ctx context.Context, p []string, includeZipContents bool, limit int, offset int) ([]models.File, error) {
|
||||
ret := _m.Called(ctx, p, includeZipContents, limit, offset)
|
||||
// FindAllInPaths provides a mock function with given fields: ctx, p, limit, offset
|
||||
func (_m *FileReaderWriter) FindAllInPaths(ctx context.Context, p []string, limit int, offset int) ([]models.File, error) {
|
||||
ret := _m.Called(ctx, p, limit, offset)
|
||||
|
||||
var r0 []models.File
|
||||
if rf, ok := ret.Get(0).(func(context.Context, []string, bool, int, int) []models.File); ok {
|
||||
r0 = rf(ctx, p, includeZipContents, limit, offset)
|
||||
if rf, ok := ret.Get(0).(func(context.Context, []string, int, int) []models.File); ok {
|
||||
r0 = rf(ctx, p, limit, offset)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).([]models.File)
|
||||
@@ -167,8 +167,8 @@ func (_m *FileReaderWriter) FindAllInPaths(ctx context.Context, p []string, incl
|
||||
}
|
||||
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(1).(func(context.Context, []string, bool, int, int) error); ok {
|
||||
r1 = rf(ctx, p, includeZipContents, limit, offset)
|
||||
if rf, ok := ret.Get(1).(func(context.Context, []string, int, int) error); ok {
|
||||
r1 = rf(ctx, p, limit, offset)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
@@ -86,13 +86,13 @@ func (_m *FolderReaderWriter) Find(ctx context.Context, id models.FolderID) (*mo
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// FindAllInPaths provides a mock function with given fields: ctx, p, includeZipContents, limit, offset
|
||||
func (_m *FolderReaderWriter) FindAllInPaths(ctx context.Context, p []string, includeZipContents bool, limit int, offset int) ([]*models.Folder, error) {
|
||||
ret := _m.Called(ctx, p, includeZipContents, limit, offset)
|
||||
// FindAllInPaths provides a mock function with given fields: ctx, p, limit, offset
|
||||
func (_m *FolderReaderWriter) FindAllInPaths(ctx context.Context, p []string, limit int, offset int) ([]*models.Folder, error) {
|
||||
ret := _m.Called(ctx, p, limit, offset)
|
||||
|
||||
var r0 []*models.Folder
|
||||
if rf, ok := ret.Get(0).(func(context.Context, []string, bool, int, int) []*models.Folder); ok {
|
||||
r0 = rf(ctx, p, includeZipContents, limit, offset)
|
||||
if rf, ok := ret.Get(0).(func(context.Context, []string, int, int) []*models.Folder); ok {
|
||||
r0 = rf(ctx, p, limit, offset)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).([]*models.Folder)
|
||||
@@ -100,8 +100,8 @@ func (_m *FolderReaderWriter) FindAllInPaths(ctx context.Context, p []string, in
|
||||
}
|
||||
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(1).(func(context.Context, []string, bool, int, int) error); ok {
|
||||
r1 = rf(ctx, p, includeZipContents, limit, offset)
|
||||
if rf, ok := ret.Get(1).(func(context.Context, []string, int, int) error); ok {
|
||||
r1 = rf(ctx, p, limit, offset)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
@@ -201,52 +201,6 @@ func (_m *FolderReaderWriter) FindMany(ctx context.Context, id []models.FolderID
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// GetManyParentFolderIDs provides a mock function with given fields: ctx, folderIDs
|
||||
func (_m *FolderReaderWriter) GetManyParentFolderIDs(ctx context.Context, folderIDs []models.FolderID) ([][]models.FolderID, error) {
|
||||
ret := _m.Called(ctx, folderIDs)
|
||||
|
||||
var r0 [][]models.FolderID
|
||||
if rf, ok := ret.Get(0).(func(context.Context, []models.FolderID) [][]models.FolderID); ok {
|
||||
r0 = rf(ctx, folderIDs)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).([][]models.FolderID)
|
||||
}
|
||||
}
|
||||
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(1).(func(context.Context, []models.FolderID) error); ok {
|
||||
r1 = rf(ctx, folderIDs)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// GetManySubFolderIDs provides a mock function with given fields: ctx, folderIDs
|
||||
func (_m *FolderReaderWriter) GetManySubFolderIDs(ctx context.Context, folderIDs []models.FolderID) ([][]models.FolderID, error) {
|
||||
ret := _m.Called(ctx, folderIDs)
|
||||
|
||||
var r0 [][]models.FolderID
|
||||
if rf, ok := ret.Get(0).(func(context.Context, []models.FolderID) [][]models.FolderID); ok {
|
||||
r0 = rf(ctx, folderIDs)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).([][]models.FolderID)
|
||||
}
|
||||
}
|
||||
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(1).(func(context.Context, []models.FolderID) error); ok {
|
||||
r1 = rf(ctx, folderIDs)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// Query provides a mock function with given fields: ctx, options
|
||||
func (_m *FolderReaderWriter) Query(ctx context.Context, options models.FolderQueryOptions) (*models.FolderQueryResult, error) {
|
||||
ret := _m.Called(ctx, options)
|
||||
|
||||
@@ -49,20 +49,6 @@ func (_m *GalleryReaderWriter) AddImages(ctx context.Context, galleryID int, ima
|
||||
return r0
|
||||
}
|
||||
|
||||
// AddSceneIDs provides a mock function with given fields: ctx, galleryID, sceneIDs
|
||||
func (_m *GalleryReaderWriter) AddSceneIDs(ctx context.Context, galleryID int, sceneIDs []int) error {
|
||||
ret := _m.Called(ctx, galleryID, sceneIDs)
|
||||
|
||||
var r0 error
|
||||
if rf, ok := ret.Get(0).(func(context.Context, int, []int) error); ok {
|
||||
r0 = rf(ctx, galleryID, sceneIDs)
|
||||
} else {
|
||||
r0 = ret.Error(0)
|
||||
}
|
||||
|
||||
return r0
|
||||
}
|
||||
|
||||
// All provides a mock function with given fields: ctx
|
||||
func (_m *GalleryReaderWriter) All(ctx context.Context) ([]*models.Gallery, error) {
|
||||
ret := _m.Called(ctx)
|
||||
|
||||
@@ -3,7 +3,6 @@ package mocks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stashapp/stash/pkg/txn"
|
||||
@@ -90,16 +89,6 @@ func (db *Database) AssertExpectations(t mock.TestingT) {
|
||||
db.SavedFilter.AssertExpectations(t)
|
||||
}
|
||||
|
||||
// WithTxnCtx runs fn with a context that has a transaction hook manager registered,
|
||||
// so code that calls txn.AddPostCommitHook (e.g. plugin cache) won't nil-panic.
|
||||
// Always rolls back to avoid executing the registered hooks.
|
||||
func (db *Database) WithTxnCtx(fn func(ctx context.Context)) {
|
||||
_ = txn.WithTxn(context.Background(), db, func(ctx context.Context) error {
|
||||
fn(ctx)
|
||||
return errors.New("rollback")
|
||||
})
|
||||
}
|
||||
|
||||
func (db *Database) Repository() models.Repository {
|
||||
return models.Repository{
|
||||
TxnManager: db,
|
||||
|
||||
@@ -6,26 +6,26 @@ import (
|
||||
)
|
||||
|
||||
type Performer struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Disambiguation string `json:"disambiguation"`
|
||||
Gender *GenderEnum `json:"gender"`
|
||||
Birthdate *Date `json:"birthdate"`
|
||||
Ethnicity string `json:"ethnicity"`
|
||||
Country string `json:"country"`
|
||||
EyeColor string `json:"eye_color"`
|
||||
Height *int `json:"height"`
|
||||
Measurements string `json:"measurements"`
|
||||
FakeTits string `json:"fake_tits"`
|
||||
PenisLength *float64 `json:"penis_length"`
|
||||
Circumcised *CircumcisedEnum `json:"circumcised"`
|
||||
CareerStart *Date `json:"career_start"`
|
||||
CareerEnd *Date `json:"career_end"`
|
||||
Tattoos string `json:"tattoos"`
|
||||
Piercings string `json:"piercings"`
|
||||
Favorite bool `json:"favorite"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Disambiguation string `json:"disambiguation"`
|
||||
Gender *GenderEnum `json:"gender"`
|
||||
Birthdate *Date `json:"birthdate"`
|
||||
Ethnicity string `json:"ethnicity"`
|
||||
Country string `json:"country"`
|
||||
EyeColor string `json:"eye_color"`
|
||||
Height *int `json:"height"`
|
||||
Measurements string `json:"measurements"`
|
||||
FakeTits string `json:"fake_tits"`
|
||||
PenisLength *float64 `json:"penis_length"`
|
||||
Circumcised *CircumisedEnum `json:"circumcised"`
|
||||
CareerStart *int `json:"career_start"`
|
||||
CareerEnd *int `json:"career_end"`
|
||||
Tattoos string `json:"tattoos"`
|
||||
Piercings string `json:"piercings"`
|
||||
Favorite bool `json:"favorite"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
// Rating expressed in 1-100 scale
|
||||
Rating *int `json:"rating"`
|
||||
Details string `json:"details"`
|
||||
@@ -76,8 +76,8 @@ type PerformerPartial struct {
|
||||
FakeTits OptionalString
|
||||
PenisLength OptionalFloat64
|
||||
Circumcised OptionalString
|
||||
CareerStart OptionalDate
|
||||
CareerEnd OptionalDate
|
||||
CareerStart OptionalInt
|
||||
CareerEnd OptionalInt
|
||||
Tattoos OptionalString
|
||||
Piercings OptionalString
|
||||
Favorite OptionalBool
|
||||
|
||||
@@ -177,8 +177,8 @@ type ScrapedPerformer struct {
|
||||
PenisLength *string `json:"penis_length"`
|
||||
Circumcised *string `json:"circumcised"`
|
||||
CareerLength *string `json:"career_length"` // deprecated: use CareerStart/CareerEnd
|
||||
CareerStart *string `json:"career_start"`
|
||||
CareerEnd *string `json:"career_end"`
|
||||
CareerStart *int `json:"career_start"`
|
||||
CareerEnd *int `json:"career_end"`
|
||||
Tattoos *string `json:"tattoos"`
|
||||
Piercings *string `json:"piercings"`
|
||||
Aliases *string `json:"aliases"`
|
||||
@@ -225,16 +225,12 @@ func (p *ScrapedPerformer) ToPerformer(endpoint string, excluded map[string]bool
|
||||
// assume that career length is _not_ populated in favour of start/end
|
||||
|
||||
if p.CareerStart != nil && !excluded["career_start"] {
|
||||
date, err := ParseDate(*p.CareerStart)
|
||||
if err == nil {
|
||||
ret.CareerStart = &date
|
||||
}
|
||||
cs := *p.CareerStart
|
||||
ret.CareerStart = &cs
|
||||
}
|
||||
if p.CareerEnd != nil && !excluded["career_end"] {
|
||||
date, err := ParseDate(*p.CareerEnd)
|
||||
if err == nil {
|
||||
ret.CareerEnd = &date
|
||||
}
|
||||
ce := *p.CareerEnd
|
||||
ret.CareerEnd = &ce
|
||||
}
|
||||
if p.Country != nil && !excluded["country"] {
|
||||
ret.Country = *p.Country
|
||||
@@ -292,7 +288,7 @@ func (p *ScrapedPerformer) ToPerformer(endpoint string, excluded map[string]bool
|
||||
}
|
||||
}
|
||||
if p.Circumcised != nil && !excluded["circumcised"] {
|
||||
v := CircumcisedEnum(*p.Circumcised)
|
||||
v := CircumisedEnum(*p.Circumcised)
|
||||
if v.IsValid() {
|
||||
ret.Circumcised = &v
|
||||
}
|
||||
@@ -371,13 +367,13 @@ func (p *ScrapedPerformer) ToPartial(endpoint string, excluded map[string]bool,
|
||||
}
|
||||
if p.CareerLength != nil && !excluded["career_length"] {
|
||||
// parse career_length into career_start/career_end
|
||||
start, end, err := ParseYearRangeString(*p.CareerLength)
|
||||
start, end, err := utils.ParseYearRangeString(*p.CareerLength)
|
||||
if err == nil {
|
||||
if start != nil {
|
||||
ret.CareerStart = NewOptionalDate(*start)
|
||||
ret.CareerStart = NewOptionalInt(*start)
|
||||
}
|
||||
if end != nil {
|
||||
ret.CareerEnd = NewOptionalDate(*end)
|
||||
ret.CareerEnd = NewOptionalInt(*end)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -475,12 +471,11 @@ func (p *ScrapedPerformer) ToPartial(endpoint string, excluded map[string]bool,
|
||||
|
||||
type ScrapedTag struct {
|
||||
// Set if tag matched
|
||||
StoredID *string `json:"stored_id"`
|
||||
Name string `json:"name"`
|
||||
Description *string `json:"description"`
|
||||
AliasList []string `json:"alias_list"`
|
||||
RemoteSiteID *string `json:"remote_site_id"`
|
||||
Parent *ScrapedTag `json:"parent"`
|
||||
StoredID *string `json:"stored_id"`
|
||||
Name string `json:"name"`
|
||||
Description *string `json:"description"`
|
||||
AliasList []string `json:"alias_list"`
|
||||
RemoteSiteID *string `json:"remote_site_id"`
|
||||
}
|
||||
|
||||
func (ScrapedTag) IsScrapedContent() {}
|
||||
@@ -501,13 +496,6 @@ func (t *ScrapedTag) ToTag(endpoint string, excluded map[string]bool) *Tag {
|
||||
ret.Aliases = NewRelatedStrings(t.AliasList)
|
||||
}
|
||||
|
||||
if t.Parent != nil && t.Parent.StoredID != nil {
|
||||
parentID, err := strconv.Atoi(*t.Parent.StoredID)
|
||||
if err == nil && parentID > 0 {
|
||||
ret.ParentIDs = NewRelatedIDs([]int{parentID})
|
||||
}
|
||||
}
|
||||
|
||||
if t.RemoteSiteID != nil && endpoint != "" && *t.RemoteSiteID != "" {
|
||||
ret.StashIDs = NewRelatedStashIDs([]StashID{
|
||||
{
|
||||
@@ -539,16 +527,6 @@ func (t *ScrapedTag) ToPartial(storedID string, endpoint string, excluded map[st
|
||||
}
|
||||
}
|
||||
|
||||
if t.Parent != nil && t.Parent.StoredID != nil {
|
||||
parentID, err := strconv.Atoi(*t.Parent.StoredID)
|
||||
if err == nil && parentID > 0 {
|
||||
ret.ParentIDs = &UpdateIDs{
|
||||
IDs: []int{parentID},
|
||||
Mode: RelationshipUpdateModeAdd,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if t.RemoteSiteID != nil && endpoint != "" && *t.RemoteSiteID != "" {
|
||||
ret.StashIDs = &UpdateStashIDs{
|
||||
StashIDs: existingStashIDs,
|
||||
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func intPtr(i int) *int { return &i }
|
||||
|
||||
func Test_scrapedToStudioInput(t *testing.T) {
|
||||
const name = "name"
|
||||
url := "url"
|
||||
@@ -184,8 +186,8 @@ func Test_scrapedToPerformerInput(t *testing.T) {
|
||||
Weight: nextVal(),
|
||||
Measurements: nextVal(),
|
||||
FakeTits: nextVal(),
|
||||
CareerStart: dateStrFromInt(2005),
|
||||
CareerEnd: dateStrFromInt(2015),
|
||||
CareerStart: intPtr(2005),
|
||||
CareerEnd: intPtr(2015),
|
||||
Tattoos: nextVal(),
|
||||
Piercings: nextVal(),
|
||||
Aliases: nextVal(),
|
||||
@@ -210,8 +212,8 @@ func Test_scrapedToPerformerInput(t *testing.T) {
|
||||
Weight: nextIntVal(),
|
||||
Measurements: *nextVal(),
|
||||
FakeTits: *nextVal(),
|
||||
CareerStart: dateFromInt(2005),
|
||||
CareerEnd: dateFromInt(2015),
|
||||
CareerStart: intPtr(2005),
|
||||
CareerEnd: intPtr(2015),
|
||||
Tattoos: *nextVal(), // skip CareerLength counter slot
|
||||
Piercings: *nextVal(),
|
||||
Aliases: NewRelatedStrings([]string{*nextVal()}),
|
||||
|
||||
@@ -61,49 +61,49 @@ type GenderCriterionInput struct {
|
||||
Modifier CriterionModifier `json:"modifier"`
|
||||
}
|
||||
|
||||
type CircumcisedEnum string
|
||||
type CircumisedEnum string
|
||||
|
||||
const (
|
||||
CircumcisedEnumCut CircumcisedEnum = "CUT"
|
||||
CircumcisedEnumUncut CircumcisedEnum = "UNCUT"
|
||||
CircumisedEnumCut CircumisedEnum = "CUT"
|
||||
CircumisedEnumUncut CircumisedEnum = "UNCUT"
|
||||
)
|
||||
|
||||
var AllCircumcisionEnum = []CircumcisedEnum{
|
||||
CircumcisedEnumCut,
|
||||
CircumcisedEnumUncut,
|
||||
var AllCircumcisionEnum = []CircumisedEnum{
|
||||
CircumisedEnumCut,
|
||||
CircumisedEnumUncut,
|
||||
}
|
||||
|
||||
func (e CircumcisedEnum) IsValid() bool {
|
||||
func (e CircumisedEnum) IsValid() bool {
|
||||
switch e {
|
||||
case CircumcisedEnumCut, CircumcisedEnumUncut:
|
||||
case CircumisedEnumCut, CircumisedEnumUncut:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (e CircumcisedEnum) String() string {
|
||||
func (e CircumisedEnum) String() string {
|
||||
return string(e)
|
||||
}
|
||||
|
||||
func (e *CircumcisedEnum) UnmarshalGQL(v interface{}) error {
|
||||
func (e *CircumisedEnum) UnmarshalGQL(v interface{}) error {
|
||||
str, ok := v.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("enums must be strings")
|
||||
}
|
||||
|
||||
*e = CircumcisedEnum(str)
|
||||
*e = CircumisedEnum(str)
|
||||
if !e.IsValid() {
|
||||
return fmt.Errorf("%s is not a valid CircumcisedEnum", str)
|
||||
return fmt.Errorf("%s is not a valid CircumisedEnum", str)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e CircumcisedEnum) MarshalGQL(w io.Writer) {
|
||||
func (e CircumisedEnum) MarshalGQL(w io.Writer) {
|
||||
fmt.Fprint(w, strconv.Quote(e.String()))
|
||||
}
|
||||
|
||||
type CircumcisionCriterionInput struct {
|
||||
Value []CircumcisedEnum `json:"value"`
|
||||
Value []CircumisedEnum `json:"value"`
|
||||
Modifier CriterionModifier `json:"modifier"`
|
||||
}
|
||||
|
||||
@@ -139,9 +139,9 @@ type PerformerFilterType struct {
|
||||
// Filter by career length
|
||||
CareerLength *StringCriterionInput `json:"career_length"` // deprecated
|
||||
// Filter by career start year
|
||||
CareerStart *DateCriterionInput `json:"career_start"`
|
||||
CareerStart *IntCriterionInput `json:"career_start"`
|
||||
// Filter by career end year
|
||||
CareerEnd *DateCriterionInput `json:"career_end"`
|
||||
CareerEnd *IntCriterionInput `json:"career_end"`
|
||||
// Filter by tattoos
|
||||
Tattoos *StringCriterionInput `json:"tattoos"`
|
||||
// Filter by piercings
|
||||
@@ -158,8 +158,6 @@ type PerformerFilterType struct {
|
||||
TagCount *IntCriterionInput `json:"tag_count"`
|
||||
// Filter by scene count
|
||||
SceneCount *IntCriterionInput `json:"scene_count"`
|
||||
// Filter by scene marker count (via scene)
|
||||
MarkerCount *IntCriterionInput `json:"marker_count"`
|
||||
// Filter by image count
|
||||
ImageCount *IntCriterionInput `json:"image_count"`
|
||||
// Filter by gallery count
|
||||
@@ -204,8 +202,6 @@ type PerformerFilterType struct {
|
||||
GalleriesFilter *GalleryFilterType `json:"galleries_filter"`
|
||||
// Filter by related tags that meet this criteria
|
||||
TagsFilter *TagFilterType `json:"tags_filter"`
|
||||
// Filter by related scene markers (via scene) that meet this criteria
|
||||
MarkersFilter *SceneMarkerFilterType `json:"markers_filter"`
|
||||
// Filter by created at
|
||||
CreatedAt *TimestampCriterionInput `json:"created_at"`
|
||||
// Filter by updated at
|
||||
@@ -216,32 +212,32 @@ type PerformerFilterType struct {
|
||||
}
|
||||
|
||||
type PerformerCreateInput struct {
|
||||
Name string `json:"name"`
|
||||
Disambiguation *string `json:"disambiguation"`
|
||||
URL *string `json:"url"` // deprecated
|
||||
Urls []string `json:"urls"`
|
||||
Gender *GenderEnum `json:"gender"`
|
||||
Birthdate *string `json:"birthdate"`
|
||||
Ethnicity *string `json:"ethnicity"`
|
||||
Country *string `json:"country"`
|
||||
EyeColor *string `json:"eye_color"`
|
||||
Height *string `json:"height"`
|
||||
HeightCm *int `json:"height_cm"`
|
||||
Measurements *string `json:"measurements"`
|
||||
FakeTits *string `json:"fake_tits"`
|
||||
PenisLength *float64 `json:"penis_length"`
|
||||
Circumcised *CircumcisedEnum `json:"circumcised"`
|
||||
CareerLength *string `json:"career_length"`
|
||||
CareerStart *string `json:"career_start"`
|
||||
CareerEnd *string `json:"career_end"`
|
||||
Tattoos *string `json:"tattoos"`
|
||||
Piercings *string `json:"piercings"`
|
||||
Aliases *string `json:"aliases"`
|
||||
AliasList []string `json:"alias_list"`
|
||||
Twitter *string `json:"twitter"` // deprecated
|
||||
Instagram *string `json:"instagram"` // deprecated
|
||||
Favorite *bool `json:"favorite"`
|
||||
TagIds []string `json:"tag_ids"`
|
||||
Name string `json:"name"`
|
||||
Disambiguation *string `json:"disambiguation"`
|
||||
URL *string `json:"url"` // deprecated
|
||||
Urls []string `json:"urls"`
|
||||
Gender *GenderEnum `json:"gender"`
|
||||
Birthdate *string `json:"birthdate"`
|
||||
Ethnicity *string `json:"ethnicity"`
|
||||
Country *string `json:"country"`
|
||||
EyeColor *string `json:"eye_color"`
|
||||
Height *string `json:"height"`
|
||||
HeightCm *int `json:"height_cm"`
|
||||
Measurements *string `json:"measurements"`
|
||||
FakeTits *string `json:"fake_tits"`
|
||||
PenisLength *float64 `json:"penis_length"`
|
||||
Circumcised *CircumisedEnum `json:"circumcised"`
|
||||
CareerLength *string `json:"career_length"`
|
||||
CareerStart *int `json:"career_start"`
|
||||
CareerEnd *int `json:"career_end"`
|
||||
Tattoos *string `json:"tattoos"`
|
||||
Piercings *string `json:"piercings"`
|
||||
Aliases *string `json:"aliases"`
|
||||
AliasList []string `json:"alias_list"`
|
||||
Twitter *string `json:"twitter"` // deprecated
|
||||
Instagram *string `json:"instagram"` // deprecated
|
||||
Favorite *bool `json:"favorite"`
|
||||
TagIds []string `json:"tag_ids"`
|
||||
// This should be a URL or a base64 encoded data URL
|
||||
Image *string `json:"image"`
|
||||
StashIds []StashIDInput `json:"stash_ids"`
|
||||
@@ -256,33 +252,33 @@ type PerformerCreateInput struct {
|
||||
}
|
||||
|
||||
type PerformerUpdateInput struct {
|
||||
ID string `json:"id"`
|
||||
Name *string `json:"name"`
|
||||
Disambiguation *string `json:"disambiguation"`
|
||||
URL *string `json:"url"` // deprecated
|
||||
Urls []string `json:"urls"`
|
||||
Gender *GenderEnum `json:"gender"`
|
||||
Birthdate *string `json:"birthdate"`
|
||||
Ethnicity *string `json:"ethnicity"`
|
||||
Country *string `json:"country"`
|
||||
EyeColor *string `json:"eye_color"`
|
||||
Height *string `json:"height"`
|
||||
HeightCm *int `json:"height_cm"`
|
||||
Measurements *string `json:"measurements"`
|
||||
FakeTits *string `json:"fake_tits"`
|
||||
PenisLength *float64 `json:"penis_length"`
|
||||
Circumcised *CircumcisedEnum `json:"circumcised"`
|
||||
CareerLength *string `json:"career_length"`
|
||||
CareerStart *string `json:"career_start"`
|
||||
CareerEnd *string `json:"career_end"`
|
||||
Tattoos *string `json:"tattoos"`
|
||||
Piercings *string `json:"piercings"`
|
||||
Aliases *string `json:"aliases"`
|
||||
AliasList []string `json:"alias_list"`
|
||||
Twitter *string `json:"twitter"` // deprecated
|
||||
Instagram *string `json:"instagram"` // deprecated
|
||||
Favorite *bool `json:"favorite"`
|
||||
TagIds []string `json:"tag_ids"`
|
||||
ID string `json:"id"`
|
||||
Name *string `json:"name"`
|
||||
Disambiguation *string `json:"disambiguation"`
|
||||
URL *string `json:"url"` // deprecated
|
||||
Urls []string `json:"urls"`
|
||||
Gender *GenderEnum `json:"gender"`
|
||||
Birthdate *string `json:"birthdate"`
|
||||
Ethnicity *string `json:"ethnicity"`
|
||||
Country *string `json:"country"`
|
||||
EyeColor *string `json:"eye_color"`
|
||||
Height *string `json:"height"`
|
||||
HeightCm *int `json:"height_cm"`
|
||||
Measurements *string `json:"measurements"`
|
||||
FakeTits *string `json:"fake_tits"`
|
||||
PenisLength *float64 `json:"penis_length"`
|
||||
Circumcised *CircumisedEnum `json:"circumcised"`
|
||||
CareerLength *string `json:"career_length"`
|
||||
CareerStart *int `json:"career_start"`
|
||||
CareerEnd *int `json:"career_end"`
|
||||
Tattoos *string `json:"tattoos"`
|
||||
Piercings *string `json:"piercings"`
|
||||
Aliases *string `json:"aliases"`
|
||||
AliasList []string `json:"alias_list"`
|
||||
Twitter *string `json:"twitter"` // deprecated
|
||||
Instagram *string `json:"instagram"` // deprecated
|
||||
Favorite *bool `json:"favorite"`
|
||||
TagIds []string `json:"tag_ids"`
|
||||
// This should be a URL or a base64 encoded data URL
|
||||
Image *string `json:"image"`
|
||||
StashIds []StashIDInput `json:"stash_ids"`
|
||||
|
||||
@@ -14,7 +14,7 @@ type FileGetter interface {
|
||||
type FileFinder interface {
|
||||
FileGetter
|
||||
FindAllByPath(ctx context.Context, path string, caseSensitive bool) ([]File, error)
|
||||
FindAllInPaths(ctx context.Context, p []string, includeZipContents bool, limit, offset int) ([]File, error)
|
||||
FindAllInPaths(ctx context.Context, p []string, limit, offset int) ([]File, error)
|
||||
FindByPath(ctx context.Context, path string, caseSensitive bool) (File, error)
|
||||
FindByFingerprint(ctx context.Context, fp Fingerprint) ([]File, error)
|
||||
FindByZipFileID(ctx context.Context, zipFileID FileID) ([]File, error)
|
||||
|
||||
@@ -11,12 +11,10 @@ type FolderGetter interface {
|
||||
// FolderFinder provides methods to find folders.
|
||||
type FolderFinder interface {
|
||||
FolderGetter
|
||||
FindAllInPaths(ctx context.Context, p []string, includeZipContents bool, limit, offset int) ([]*Folder, error)
|
||||
FindAllInPaths(ctx context.Context, p []string, limit, offset int) ([]*Folder, error)
|
||||
FindByPath(ctx context.Context, path string, caseSensitive bool) (*Folder, error)
|
||||
FindByZipFileID(ctx context.Context, zipFileID FileID) ([]*Folder, error)
|
||||
FindByParentFolderID(ctx context.Context, parentFolderID FolderID) ([]*Folder, error)
|
||||
GetManyParentFolderIDs(ctx context.Context, folderIDs []FolderID) ([][]FolderID, error)
|
||||
GetManySubFolderIDs(ctx context.Context, folderIDs []FolderID) ([][]FolderID, error)
|
||||
}
|
||||
|
||||
type FolderQueryer interface {
|
||||
|
||||
@@ -83,7 +83,6 @@ type GalleryWriter interface {
|
||||
|
||||
CustomFieldsWriter
|
||||
|
||||
AddSceneIDs(ctx context.Context, galleryID int, sceneIDs []int) error
|
||||
AddFileID(ctx context.Context, id int, fileID FileID) error
|
||||
AddImages(ctx context.Context, galleryID int, imageIDs ...int) error
|
||||
RemoveImages(ctx context.Context, galleryID int, imageIDs ...int) error
|
||||
|
||||
@@ -56,8 +56,6 @@ type TagFilterType struct {
|
||||
PerformersFilter *PerformerFilterType `json:"performers_filter"`
|
||||
// Filter by related studios that meet this criteria
|
||||
StudiosFilter *StudioFilterType `json:"studios_filter"`
|
||||
// Filter by related scene markers that meet this criteria
|
||||
MarkersFilter *SceneMarkerFilterType `json:"markers_filter"`
|
||||
// Filter by created at
|
||||
CreatedAt *TimestampCriterionInput `json:"created_at"`
|
||||
// Filter by updated at
|
||||
|
||||
@@ -71,10 +71,10 @@ func ToJSON(ctx context.Context, reader ImageAliasStashIDGetter, performer *mode
|
||||
}
|
||||
|
||||
if performer.CareerStart != nil {
|
||||
newPerformerJSON.CareerStart = performer.CareerStart.String()
|
||||
newPerformerJSON.CareerStart = performer.CareerStart
|
||||
}
|
||||
if performer.CareerEnd != nil {
|
||||
newPerformerJSON.CareerEnd = performer.CareerEnd.String()
|
||||
newPerformerJSON.CareerEnd = performer.CareerEnd
|
||||
}
|
||||
|
||||
if err := performer.LoadAliases(ctx, reader); err != nil {
|
||||
|
||||
@@ -48,10 +48,10 @@ var (
|
||||
rating = 5
|
||||
height = 123
|
||||
weight = 60
|
||||
careerStart, _ = models.ParseDate("2005")
|
||||
careerEnd, _ = models.ParseDate("2015")
|
||||
careerStart = 2005
|
||||
careerEnd = 2015
|
||||
penisLength = 1.23
|
||||
circumcisedEnum = models.CircumcisedEnumCut
|
||||
circumcisedEnum = models.CircumisedEnumCut
|
||||
circumcised = circumcisedEnum.String()
|
||||
|
||||
emptyCustomFields = make(map[string]interface{})
|
||||
@@ -134,8 +134,8 @@ func createFullJSONPerformer(name string, image string, withCustomFields bool) *
|
||||
URLs: []string{url, twitter, instagram},
|
||||
Aliases: aliases,
|
||||
Birthdate: birthDate.String(),
|
||||
CareerStart: careerStart.String(),
|
||||
CareerEnd: careerEnd.String(),
|
||||
CareerStart: &careerStart,
|
||||
CareerEnd: &careerEnd,
|
||||
Country: country,
|
||||
Ethnicity: ethnicity,
|
||||
EyeColor: eyeColor,
|
||||
|
||||
@@ -247,7 +247,7 @@ func performerJSONToPerformer(performerJSON jsonschema.Performer) (models.Perfor
|
||||
}
|
||||
|
||||
if performerJSON.Circumcised != "" {
|
||||
v := models.CircumcisedEnum(performerJSON.Circumcised)
|
||||
v := models.CircumisedEnum(performerJSON.Circumcised)
|
||||
newPerformer.Circumcised = &v
|
||||
}
|
||||
|
||||
@@ -285,17 +285,11 @@ func performerJSONToPerformer(performerJSON jsonschema.Performer) (models.Perfor
|
||||
}
|
||||
|
||||
// prefer explicit career_start/career_end, fall back to parsing legacy career_length
|
||||
if performerJSON.CareerStart != "" || performerJSON.CareerEnd != "" {
|
||||
careerStart, err := models.ParseDate(performerJSON.CareerStart)
|
||||
if err == nil {
|
||||
newPerformer.CareerStart = &careerStart
|
||||
}
|
||||
careerEnd, err := models.ParseDate(performerJSON.CareerEnd)
|
||||
if err == nil {
|
||||
newPerformer.CareerEnd = &careerEnd
|
||||
}
|
||||
if performerJSON.CareerStart != nil || performerJSON.CareerEnd != nil {
|
||||
newPerformer.CareerStart = performerJSON.CareerStart
|
||||
newPerformer.CareerEnd = performerJSON.CareerEnd
|
||||
} else if performerJSON.CareerLength != "" {
|
||||
start, end, err := models.ParseYearRangeString(performerJSON.CareerLength)
|
||||
start, end, err := utils.ParseYearRangeString(performerJSON.CareerLength)
|
||||
if err != nil {
|
||||
return models.Performer{}, fmt.Errorf("invalid career_length %q: %w", performerJSON.CareerLength, err)
|
||||
}
|
||||
|
||||
@@ -317,15 +317,15 @@ func TestUpdate(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestImportCareerFields(t *testing.T) {
|
||||
startYear, _ := models.ParseDate("2005")
|
||||
endYear, _ := models.ParseDate("2015")
|
||||
startYear := 2005
|
||||
endYear := 2015
|
||||
|
||||
// explicit career_start/career_end should be used directly
|
||||
t.Run("explicit fields", func(t *testing.T) {
|
||||
input := jsonschema.Performer{
|
||||
Name: "test",
|
||||
CareerStart: startYear.String(),
|
||||
CareerEnd: endYear.String(),
|
||||
CareerStart: &startYear,
|
||||
CareerEnd: &endYear,
|
||||
}
|
||||
|
||||
p, err := performerJSONToPerformer(input)
|
||||
@@ -338,8 +338,8 @@ func TestImportCareerFields(t *testing.T) {
|
||||
t.Run("explicit fields override legacy", func(t *testing.T) {
|
||||
input := jsonschema.Performer{
|
||||
Name: "test",
|
||||
CareerStart: startYear.String(),
|
||||
CareerEnd: endYear.String(),
|
||||
CareerStart: &startYear,
|
||||
CareerEnd: &endYear,
|
||||
CareerLength: "1990 - 1995",
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package pkg
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -11,23 +10,22 @@ type cacheEntry struct {
|
||||
}
|
||||
|
||||
type repositoryCache struct {
|
||||
mu sync.RWMutex
|
||||
// cache maps the URL to the last modified time and the data
|
||||
cache map[string]cacheEntry
|
||||
}
|
||||
|
||||
func (c *repositoryCache) ensureCache() {
|
||||
if c.cache == nil {
|
||||
c.cache = make(map[string]cacheEntry)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *repositoryCache) lastModified(url string) *time.Time {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
|
||||
if c.cache == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
c.ensureCache()
|
||||
e, found := c.cache[url]
|
||||
|
||||
if !found {
|
||||
@@ -38,13 +36,7 @@ func (c *repositoryCache) lastModified(url string) *time.Time {
|
||||
}
|
||||
|
||||
func (c *repositoryCache) getPackageList(url string) []RemotePackage {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
|
||||
if c.cache == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
c.ensureCache()
|
||||
e, found := c.cache[url]
|
||||
|
||||
if !found {
|
||||
@@ -59,13 +51,7 @@ func (c *repositoryCache) cacheList(url string, lastModified time.Time, data []R
|
||||
return
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if c.cache == nil {
|
||||
c.cache = make(map[string]cacheEntry)
|
||||
}
|
||||
|
||||
c.ensureCache()
|
||||
c.cache[url] = cacheEntry{
|
||||
lastModified: lastModified,
|
||||
data: data,
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"github.com/stashapp/stash/pkg/logger"
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
@@ -32,14 +31,13 @@ type Manager struct {
|
||||
|
||||
Client *http.Client
|
||||
|
||||
cacheOnce sync.Once
|
||||
cache *repositoryCache
|
||||
cache *repositoryCache
|
||||
}
|
||||
|
||||
func (m *Manager) getCache() *repositoryCache {
|
||||
m.cacheOnce.Do(func() {
|
||||
if m.cache == nil {
|
||||
m.cache = &repositoryCache{}
|
||||
})
|
||||
}
|
||||
|
||||
return m.cache
|
||||
}
|
||||
|
||||
@@ -4,8 +4,6 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/stashapp/stash/pkg/file/video"
|
||||
"github.com/stashapp/stash/pkg/logger"
|
||||
@@ -34,18 +32,12 @@ type ScanCreatorUpdater interface {
|
||||
AddFileID(ctx context.Context, id int, fileID models.FileID) error
|
||||
}
|
||||
|
||||
type ScanGalleryFinderUpdater interface {
|
||||
FindByPath(ctx context.Context, p string) ([]*models.Gallery, error)
|
||||
AddSceneIDs(ctx context.Context, galleryID int, sceneIDs []int) error
|
||||
}
|
||||
|
||||
type ScanGenerator interface {
|
||||
Generate(ctx context.Context, s *models.Scene, f *models.VideoFile) error
|
||||
}
|
||||
|
||||
type ScanHandler struct {
|
||||
CreatorUpdater ScanCreatorUpdater
|
||||
GalleryFinderUpdater ScanGalleryFinderUpdater
|
||||
CreatorUpdater ScanCreatorUpdater
|
||||
|
||||
ScanGenerator ScanGenerator
|
||||
CaptionUpdater video.CaptionUpdater
|
||||
@@ -135,10 +127,6 @@ func (h *ScanHandler) Handle(ctx context.Context, f models.File, oldFile models.
|
||||
}
|
||||
}
|
||||
|
||||
if err := h.associateGallery(ctx, existing, f); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// do this after the commit so that cover generation doesn't hold up the transaction
|
||||
txn.AddPostCommitHook(ctx, func(ctx context.Context) {
|
||||
for _, s := range existing {
|
||||
@@ -172,44 +160,18 @@ func (h *ScanHandler) associateExisting(ctx context.Context, existing []*models.
|
||||
if err := h.CreatorUpdater.AddFileID(ctx, s.ID, f.ID); err != nil {
|
||||
return fmt.Errorf("adding file to scene: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if !found || updateExisting {
|
||||
// update updated_at time when file association or content changes
|
||||
// update updated_at time
|
||||
scenePartial := models.NewScenePartial()
|
||||
if _, err := h.CreatorUpdater.UpdatePartial(ctx, s.ID, scenePartial); err != nil {
|
||||
return fmt.Errorf("updating scene: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if !found || updateExisting {
|
||||
h.PluginCache.RegisterPostHooks(ctx, s.ID, hook.SceneUpdatePost, nil, nil)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *ScanHandler) associateGallery(ctx context.Context, existing []*models.Scene, f models.File) error {
|
||||
sceneIDs := make([]int, len(existing))
|
||||
for i, s := range existing {
|
||||
sceneIDs[i] = s.ID
|
||||
}
|
||||
|
||||
path := f.Base().Path
|
||||
zipPath := strings.TrimSuffix(path, filepath.Ext(path)) + ".zip"
|
||||
|
||||
// find galleries with a file that matches
|
||||
galleries, err := h.GalleryFinderUpdater.FindByPath(ctx, zipPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, gallery := range galleries {
|
||||
// found related Scene
|
||||
logger.Infof("associate: Scene %s is related to gallery: %d", path, gallery.ID)
|
||||
if err := h.GalleryFinderUpdater.AddSceneIDs(ctx, gallery.ID, sceneIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
package scene
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stashapp/stash/pkg/models/mocks"
|
||||
"github.com/stashapp/stash/pkg/plugin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
func TestAssociateExisting_UpdatePartialOnContentChange(t *testing.T) {
|
||||
const (
|
||||
testSceneID = 1
|
||||
testFileID = 100
|
||||
)
|
||||
|
||||
existingFile := &models.VideoFile{
|
||||
BaseFile: &models.BaseFile{ID: models.FileID(testFileID), Path: "test.mp4"},
|
||||
}
|
||||
|
||||
makeScene := func() *models.Scene {
|
||||
return &models.Scene{
|
||||
ID: testSceneID,
|
||||
Files: models.NewRelatedVideoFiles([]*models.VideoFile{existingFile}),
|
||||
}
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
updateExisting bool
|
||||
expectUpdate bool
|
||||
}{
|
||||
{
|
||||
name: "calls UpdatePartial when file content changed",
|
||||
updateExisting: true,
|
||||
expectUpdate: true,
|
||||
},
|
||||
{
|
||||
name: "skips UpdatePartial when file unchanged and already associated",
|
||||
updateExisting: false,
|
||||
expectUpdate: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
db := mocks.NewDatabase()
|
||||
db.Scene.On("GetFiles", mock.Anything, testSceneID).Return([]*models.VideoFile{existingFile}, nil)
|
||||
|
||||
if tt.expectUpdate {
|
||||
db.Scene.On("UpdatePartial", mock.Anything, testSceneID, mock.Anything).
|
||||
Return(&models.Scene{ID: testSceneID}, nil)
|
||||
}
|
||||
|
||||
h := &ScanHandler{
|
||||
CreatorUpdater: db.Scene,
|
||||
PluginCache: &plugin.Cache{},
|
||||
}
|
||||
|
||||
db.WithTxnCtx(func(ctx context.Context) {
|
||||
err := h.associateExisting(ctx, []*models.Scene{makeScene()}, existingFile, tt.updateExisting)
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
if tt.expectUpdate {
|
||||
db.Scene.AssertCalled(t, "UpdatePartial", mock.Anything, testSceneID, mock.Anything)
|
||||
} else {
|
||||
db.Scene.AssertNotCalled(t, "UpdatePartial", mock.Anything, mock.Anything, mock.Anything)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssociateExisting_UpdatePartialOnNewFile(t *testing.T) {
|
||||
const (
|
||||
testSceneID = 1
|
||||
existFileID = 100
|
||||
newFileID = 200
|
||||
)
|
||||
|
||||
existingFile := &models.VideoFile{
|
||||
BaseFile: &models.BaseFile{ID: models.FileID(existFileID), Path: "existing.mp4"},
|
||||
}
|
||||
newFile := &models.VideoFile{
|
||||
BaseFile: &models.BaseFile{ID: models.FileID(newFileID), Path: "new.mp4"},
|
||||
}
|
||||
|
||||
scene := &models.Scene{
|
||||
ID: testSceneID,
|
||||
Files: models.NewRelatedVideoFiles([]*models.VideoFile{existingFile}),
|
||||
}
|
||||
|
||||
db := mocks.NewDatabase()
|
||||
db.Scene.On("GetFiles", mock.Anything, testSceneID).Return([]*models.VideoFile{existingFile}, nil)
|
||||
db.Scene.On("AddFileID", mock.Anything, testSceneID, models.FileID(newFileID)).Return(nil)
|
||||
db.Scene.On("UpdatePartial", mock.Anything, testSceneID, mock.Anything).
|
||||
Return(&models.Scene{ID: testSceneID}, nil)
|
||||
|
||||
h := &ScanHandler{
|
||||
CreatorUpdater: db.Scene,
|
||||
PluginCache: &plugin.Cache{},
|
||||
}
|
||||
|
||||
db.WithTxnCtx(func(ctx context.Context) {
|
||||
err := h.associateExisting(ctx, []*models.Scene{scene}, newFile, false)
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
db.Scene.AssertCalled(t, "AddFileID", mock.Anything, testSceneID, models.FileID(newFileID))
|
||||
db.Scene.AssertCalled(t, "UpdatePartial", mock.Anything, testSceneID, mock.Anything)
|
||||
}
|
||||
@@ -140,8 +140,8 @@ func (r mappedResult) scrapedPerformer() *models.ScrapedPerformer {
|
||||
PenisLength: r.stringPtr("PenisLength"),
|
||||
Circumcised: r.stringPtr("Circumcised"),
|
||||
CareerLength: r.stringPtr("CareerLength"),
|
||||
CareerStart: r.stringPtr("CareerStart"),
|
||||
CareerEnd: r.stringPtr("CareerEnd"),
|
||||
CareerStart: r.IntPtr("CareerStart"),
|
||||
CareerEnd: r.IntPtr("CareerEnd"),
|
||||
Tattoos: r.stringPtr("Tattoos"),
|
||||
Piercings: r.stringPtr("Piercings"),
|
||||
Aliases: r.stringPtr("Aliases"),
|
||||
|
||||
@@ -20,8 +20,8 @@ type ScrapedPerformerInput struct {
|
||||
PenisLength *string `json:"penis_length"`
|
||||
Circumcised *string `json:"circumcised"`
|
||||
CareerLength *string `json:"career_length"`
|
||||
CareerStart *string `json:"career_start"`
|
||||
CareerEnd *string `json:"career_end"`
|
||||
CareerStart *int `json:"career_start"`
|
||||
CareerEnd *int `json:"career_end"`
|
||||
Tattoos *string `json:"tattoos"`
|
||||
Piercings *string `json:"piercings"`
|
||||
Aliases *string `json:"aliases"`
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
package scraper
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
)
|
||||
|
||||
func TestPostScrapePerformerCareerLength(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
const related = false
|
||||
|
||||
strPtr := func(s string) *string {
|
||||
return &s
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input models.ScrapedPerformer
|
||||
want models.ScrapedPerformer
|
||||
}{
|
||||
{
|
||||
"start = 2000",
|
||||
models.ScrapedPerformer{
|
||||
CareerStart: strPtr("2000"),
|
||||
},
|
||||
models.ScrapedPerformer{
|
||||
CareerStart: strPtr("2000"),
|
||||
CareerLength: strPtr("2000 -"),
|
||||
},
|
||||
},
|
||||
{
|
||||
"end = 2000",
|
||||
models.ScrapedPerformer{
|
||||
CareerEnd: strPtr("2000"),
|
||||
},
|
||||
models.ScrapedPerformer{
|
||||
CareerEnd: strPtr("2000"),
|
||||
CareerLength: strPtr("- 2000"),
|
||||
},
|
||||
},
|
||||
{
|
||||
"start = 2000, end = 2020",
|
||||
models.ScrapedPerformer{
|
||||
CareerStart: strPtr("2000"),
|
||||
CareerEnd: strPtr("2020"),
|
||||
},
|
||||
models.ScrapedPerformer{
|
||||
CareerStart: strPtr("2000"),
|
||||
CareerEnd: strPtr("2020"),
|
||||
CareerLength: strPtr("2000 - 2020"),
|
||||
},
|
||||
},
|
||||
{
|
||||
"length = 2000 -",
|
||||
models.ScrapedPerformer{
|
||||
CareerLength: strPtr("2000 -"),
|
||||
},
|
||||
models.ScrapedPerformer{
|
||||
CareerStart: strPtr("2000"),
|
||||
CareerLength: strPtr("2000 -"),
|
||||
},
|
||||
},
|
||||
{
|
||||
"length = - 2010",
|
||||
models.ScrapedPerformer{
|
||||
CareerLength: strPtr("- 2010"),
|
||||
},
|
||||
models.ScrapedPerformer{
|
||||
CareerEnd: strPtr("2010"),
|
||||
CareerLength: strPtr("- 2010"),
|
||||
},
|
||||
},
|
||||
{
|
||||
"length = 2000 - 2010",
|
||||
models.ScrapedPerformer{
|
||||
CareerLength: strPtr("2000 - 2010"),
|
||||
},
|
||||
models.ScrapedPerformer{
|
||||
CareerStart: strPtr("2000"),
|
||||
CareerEnd: strPtr("2010"),
|
||||
CareerLength: strPtr("2000 - 2010"),
|
||||
},
|
||||
},
|
||||
{
|
||||
"invalid start",
|
||||
models.ScrapedPerformer{
|
||||
CareerStart: strPtr("two thousand"),
|
||||
},
|
||||
models.ScrapedPerformer{
|
||||
CareerStart: strPtr("two thousand"),
|
||||
},
|
||||
},
|
||||
{
|
||||
"invalid end",
|
||||
models.ScrapedPerformer{
|
||||
CareerEnd: strPtr("two thousand"),
|
||||
},
|
||||
models.ScrapedPerformer{
|
||||
CareerEnd: strPtr("two thousand"),
|
||||
},
|
||||
},
|
||||
{
|
||||
"invalid career length",
|
||||
models.ScrapedPerformer{
|
||||
CareerLength: strPtr("1234 - 4567 - 9224"),
|
||||
},
|
||||
models.ScrapedPerformer{
|
||||
CareerLength: strPtr("1234 - 4567 - 9224"),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
compareStrPtr := func(a, b *string) bool {
|
||||
if a == b {
|
||||
return true
|
||||
}
|
||||
if a == nil || b == nil {
|
||||
return false
|
||||
}
|
||||
return *a == *b
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
c := &postScraper{}
|
||||
got, err := c.postScrapePerformer(ctx, tt.input, related)
|
||||
if err != nil {
|
||||
t.Fatalf("postScrapePerformer returned error: %v", err)
|
||||
}
|
||||
postScraped := got.(models.ScrapedPerformer)
|
||||
if !compareStrPtr(postScraped.CareerStart, tt.want.CareerStart) {
|
||||
t.Errorf("CareerStart = %v, want %v", postScraped.CareerStart, tt.want.CareerStart)
|
||||
}
|
||||
if !compareStrPtr(postScraped.CareerEnd, tt.want.CareerEnd) {
|
||||
t.Errorf("CareerEnd = %v, want %v", postScraped.CareerEnd, tt.want.CareerEnd)
|
||||
}
|
||||
if !compareStrPtr(postScraped.CareerLength, tt.want.CareerLength) {
|
||||
t.Errorf("CareerLength = %v, want %v", postScraped.CareerLength, tt.want.CareerLength)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -125,62 +125,21 @@ func (c *postScraper) postScrapePerformer(ctx context.Context, p models.ScrapedP
|
||||
}
|
||||
}
|
||||
|
||||
c.postProcessCareerLength(&p)
|
||||
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (c *postScraper) postProcessCareerLength(p *models.ScrapedPerformer) {
|
||||
isEmptyStr := func(s *string) bool { return s == nil || *s == "" }
|
||||
isEmptyInt := func(s *int) bool { return s == nil || *s == 0 }
|
||||
|
||||
// populate career start/end from career length and vice versa
|
||||
if !isEmptyStr(p.CareerLength) && isEmptyStr(p.CareerStart) && isEmptyStr(p.CareerEnd) {
|
||||
start, end, err := models.ParseYearRangeString(*p.CareerLength)
|
||||
if !isEmptyStr(p.CareerLength) && isEmptyInt(p.CareerStart) && isEmptyInt(p.CareerEnd) {
|
||||
p.CareerStart, p.CareerEnd, err = utils.ParseYearRangeString(*p.CareerLength)
|
||||
if err != nil {
|
||||
logger.Warnf("Could not parse career length %s: %v", *p.CareerLength, err)
|
||||
return
|
||||
}
|
||||
|
||||
if start != nil {
|
||||
startStr := start.String()
|
||||
p.CareerStart = &startStr
|
||||
}
|
||||
if end != nil {
|
||||
endStr := end.String()
|
||||
p.CareerEnd = &endStr
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// populate career length from career start/end if career length is missing
|
||||
if isEmptyStr(p.CareerLength) {
|
||||
var (
|
||||
start *models.Date
|
||||
end *models.Date
|
||||
)
|
||||
|
||||
if !isEmptyStr(p.CareerStart) {
|
||||
date, err := models.ParseDate(*p.CareerStart)
|
||||
if err != nil {
|
||||
logger.Warnf("Could not parse career start %s: %v", *p.CareerStart, err)
|
||||
return
|
||||
}
|
||||
start = &date
|
||||
}
|
||||
|
||||
if !isEmptyStr(p.CareerEnd) {
|
||||
date, err := models.ParseDate(*p.CareerEnd)
|
||||
if err != nil {
|
||||
logger.Warnf("Could not parse career end %s: %v", *p.CareerEnd, err)
|
||||
return
|
||||
}
|
||||
end = &date
|
||||
}
|
||||
|
||||
v := models.FormatYearRange(start, end)
|
||||
} else if isEmptyStr(p.CareerLength) && (!isEmptyInt(p.CareerStart) || !isEmptyInt(p.CareerEnd)) {
|
||||
v := utils.FormatYearRange(p.CareerStart, p.CareerEnd)
|
||||
p.CareerLength = &v
|
||||
}
|
||||
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (c *postScraper) postScrapeMovie(ctx context.Context, m models.ScrapedMovie, related bool) (_ ScrapedContent, err error) {
|
||||
|
||||
@@ -17,12 +17,6 @@ func queryURLParametersFromScene(scene *models.Scene) queryURLParameters {
|
||||
ret["oshash"] = scene.OSHash
|
||||
ret["filename"] = filepath.Base(scene.Path)
|
||||
|
||||
// pull phash from primary file
|
||||
phashFingerprints := scene.Files.Primary().Base().Fingerprints.Filter(models.FingerprintTypePhash)
|
||||
if len(phashFingerprints) > 0 {
|
||||
ret["phash"] = phashFingerprints[0].Value()
|
||||
}
|
||||
|
||||
if scene.Title != "" {
|
||||
ret["title"] = scene.Title
|
||||
}
|
||||
|
||||
@@ -1089,16 +1089,11 @@ func (h *stashIDsCriterionHandler) handle(ctx context.Context, f *filterBuilder)
|
||||
}
|
||||
|
||||
type relatedFilterHandler struct {
|
||||
// column on the primary table that relates to the related table (eg scene_id)
|
||||
relatedIDCol string
|
||||
// repository for the related table (eg sceneRepository)
|
||||
relatedRepo repository
|
||||
// handler for the filter on the related table
|
||||
relatedIDCol string
|
||||
relatedRepo repository
|
||||
relatedHandler criterionHandler
|
||||
// optional function to perform the necessary join(s) to the related table
|
||||
joinFn func(f *filterBuilder)
|
||||
// if true, related filter handler will be run using the existing filterBuilder instead of a subquery.
|
||||
directJoin bool
|
||||
joinFn func(f *filterBuilder)
|
||||
directJoin bool
|
||||
}
|
||||
|
||||
func (h *relatedFilterHandler) handle(ctx context.Context, f *filterBuilder) {
|
||||
@@ -1129,7 +1124,7 @@ func (h *relatedFilterHandler) handle(ctx context.Context, f *filterBuilder) {
|
||||
return
|
||||
}
|
||||
|
||||
f.addWhere(fmt.Sprintf("%s IN ("+subQuery.toSQL(false)+")", h.relatedIDCol), subQuery.allArgs()...)
|
||||
f.addWhere(fmt.Sprintf("%s IN ("+subQuery.toSQL(false)+")", h.relatedIDCol), subQuery.args...)
|
||||
}
|
||||
|
||||
type phashDistanceCriterionHandler struct {
|
||||
|
||||
@@ -261,8 +261,8 @@ func (h *customFieldsFilterHandler) handleCriterion(f *filterBuilder, joinAs str
|
||||
h.innerJoin(f, joinAs, cc.Field)
|
||||
f.addWhere(fmt.Sprintf("%[1]s.value IN %s", joinAs, getInBinding(len(cv))), cv...)
|
||||
case models.CriterionModifierNotEquals:
|
||||
h.leftJoin(f, joinAs, cc.Field)
|
||||
f.addWhere(fmt.Sprintf("(%[1]s.value NOT IN %s OR %[1]s.value IS NULL)", joinAs, getInBinding(len(cv))), cv...)
|
||||
h.innerJoin(f, joinAs, cc.Field)
|
||||
f.addWhere(fmt.Sprintf("%[1]s.value NOT IN %s", joinAs, getInBinding(len(cv))), cv...)
|
||||
case models.CriterionModifierIncludes:
|
||||
clauses := make([]sqlClause, len(cv))
|
||||
for i, v := range cv {
|
||||
@@ -272,7 +272,7 @@ func (h *customFieldsFilterHandler) handleCriterion(f *filterBuilder, joinAs str
|
||||
f.whereClauses = append(f.whereClauses, clauses...)
|
||||
case models.CriterionModifierExcludes:
|
||||
for _, v := range cv {
|
||||
f.addWhere(fmt.Sprintf("(%[1]s.value NOT LIKE ? OR %[1]s.value IS NULL)", joinAs), fmt.Sprintf("%%%v%%", v))
|
||||
f.addWhere(fmt.Sprintf("%[1]s.value NOT LIKE ?", joinAs), fmt.Sprintf("%%%v%%", v))
|
||||
}
|
||||
h.leftJoin(f, joinAs, cc.Field)
|
||||
case models.CriterionModifierMatchesRegex:
|
||||
@@ -315,8 +315,8 @@ func (h *customFieldsFilterHandler) handleCriterion(f *filterBuilder, joinAs str
|
||||
h.innerJoin(f, joinAs, cc.Field)
|
||||
f.addWhere(fmt.Sprintf("%s.value BETWEEN ? AND ?", joinAs), cv[0], cv[1])
|
||||
case models.CriterionModifierNotBetween:
|
||||
h.leftJoin(f, joinAs, cc.Field)
|
||||
f.addWhere(fmt.Sprintf("(%s.value NOT BETWEEN ? AND ? OR %[1]s.value IS NULL)", joinAs), cv[0], cv[1])
|
||||
h.innerJoin(f, joinAs, cc.Field)
|
||||
f.addWhere(fmt.Sprintf("%s.value NOT BETWEEN ? AND ?", joinAs), cv[0], cv[1])
|
||||
case models.CriterionModifierLessThan:
|
||||
if len(cv) != 1 {
|
||||
f.setError(fmt.Errorf("expected 1 value for custom field criterion modifier LESS_THAN, got %d", len(cv)))
|
||||
|
||||
@@ -34,7 +34,7 @@ const (
|
||||
cacheSizeEnv = "STASH_SQLITE_CACHE_SIZE"
|
||||
)
|
||||
|
||||
var appSchemaVersion uint = 85
|
||||
var appSchemaVersion uint = 83
|
||||
|
||||
//go:embed migrations/*.sql
|
||||
var migrationsBox embed.FS
|
||||
|
||||
@@ -695,7 +695,7 @@ func (qb *FileStore) allInPaths(q *goqu.SelectDataset, p []string) *goqu.SelectD
|
||||
// FindAllByPaths returns the all files that are within any of the given paths.
|
||||
// Returns all if limit is < 0.
|
||||
// Returns all files if p is empty.
|
||||
func (qb *FileStore) FindAllInPaths(ctx context.Context, p []string, includeZipContents bool, limit, offset int) ([]models.File, error) {
|
||||
func (qb *FileStore) FindAllInPaths(ctx context.Context, p []string, limit, offset int) ([]models.File, error) {
|
||||
table := qb.table()
|
||||
folderTable := folderTableMgr.table
|
||||
|
||||
@@ -706,10 +706,6 @@ func (qb *FileStore) FindAllInPaths(ctx context.Context, p []string, includeZipC
|
||||
|
||||
q = qb.allInPaths(q, p)
|
||||
|
||||
if !includeZipContents {
|
||||
q = q.Where(table.Col("zip_file_id").IsNull())
|
||||
}
|
||||
|
||||
if limit > -1 {
|
||||
q = q.Limit(uint(limit))
|
||||
}
|
||||
@@ -979,7 +975,7 @@ func (qb *FileStore) queryGroupedFields(ctx context.Context, options models.File
|
||||
Megapixels float64
|
||||
Size int64
|
||||
}{}
|
||||
if err := qb.repository.queryStruct(ctx, aggregateQuery.toSQL(includeSortPagination), query.allArgs(), &out); err != nil {
|
||||
if err := qb.repository.queryStruct(ctx, aggregateQuery.toSQL(includeSortPagination), query.args, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
||||
@@ -238,32 +238,22 @@ func (qb *fileFilterHandler) hashesCriterionHandler(hashes []*models.Fingerprint
|
||||
t := fmt.Sprintf("file_fingerprints_%d", i)
|
||||
f.addLeftJoin(fingerprintTable, t, fmt.Sprintf("files.id = %s.file_id AND %s.type = ?", t, t), hash.Type)
|
||||
|
||||
value, _ := utils.StringToPhash(hash.Value)
|
||||
distance := 0
|
||||
if hash.Distance != nil {
|
||||
distance = *hash.Distance
|
||||
}
|
||||
|
||||
// Only phash supports distance matching and is stored as integer
|
||||
if hash.Type == models.FingerprintTypePhash {
|
||||
value, err := utils.StringToPhash(hash.Value)
|
||||
if err != nil {
|
||||
f.setError(fmt.Errorf("invalid phash value: %w", err))
|
||||
return
|
||||
}
|
||||
if distance > 0 {
|
||||
// needed to avoid a type mismatch
|
||||
f.addWhere(fmt.Sprintf("typeof(%s.fingerprint) = 'integer'", t))
|
||||
f.addWhere(fmt.Sprintf("phash_distance(%s.fingerprint, ?) < ?", t), value, distance)
|
||||
} else {
|
||||
intCriterionHandler(&models.IntCriterionInput{
|
||||
Value: int(value),
|
||||
Modifier: models.CriterionModifierEquals,
|
||||
}, t+".fingerprint", nil)(ctx, f)
|
||||
}
|
||||
if distance > 0 {
|
||||
// needed to avoid a type mismatch
|
||||
f.addWhere(fmt.Sprintf("typeof(%s.fingerprint) = 'integer'", t))
|
||||
f.addWhere(fmt.Sprintf("phash_distance(%s.fingerprint, ?) < ?", t), value, distance)
|
||||
} else {
|
||||
// All other fingerprint types (md5, oshash, sha1, etc.) are stored as strings
|
||||
// Use exact match for string-based fingerprints
|
||||
f.addWhere(fmt.Sprintf("%s.fingerprint = ?", t), hash.Value)
|
||||
// use the default handler
|
||||
intCriterionHandler(&models.IntCriterionInput{
|
||||
Value: int(value),
|
||||
Modifier: models.CriterionModifierEquals,
|
||||
}, t+".fingerprint", nil)(ctx, f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stashapp/stash/pkg/utils"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
@@ -82,45 +81,7 @@ func TestFileQuery(t *testing.T) {
|
||||
includeIDs: []models.FileID{fileIDs[fileIdxInZip]},
|
||||
excludeIdxs: []int{fileIdxStartImageFiles},
|
||||
},
|
||||
{
|
||||
name: "hashes md5",
|
||||
filter: &models.FileFilterType{
|
||||
Hashes: []*models.FingerprintFilterInput{
|
||||
{
|
||||
Type: models.FingerprintTypeMD5,
|
||||
Value: getPrefixedStringValue("file", fileIdxStartVideoFiles, "md5"),
|
||||
},
|
||||
},
|
||||
},
|
||||
includeIdxs: []int{fileIdxStartVideoFiles},
|
||||
excludeIdxs: []int{fileIdxStartImageFiles},
|
||||
},
|
||||
{
|
||||
name: "hashes oshash",
|
||||
filter: &models.FileFilterType{
|
||||
Hashes: []*models.FingerprintFilterInput{
|
||||
{
|
||||
Type: models.FingerprintTypeOshash,
|
||||
Value: getPrefixedStringValue("file", fileIdxStartVideoFiles, "oshash"),
|
||||
},
|
||||
},
|
||||
},
|
||||
includeIdxs: []int{fileIdxStartVideoFiles},
|
||||
excludeIdxs: []int{fileIdxStartImageFiles},
|
||||
},
|
||||
{
|
||||
name: "hashes phash",
|
||||
filter: &models.FileFilterType{
|
||||
Hashes: []*models.FingerprintFilterInput{
|
||||
{
|
||||
Type: models.FingerprintTypePhash,
|
||||
Value: utils.PhashToString(getFilePhash(fileIdxStartImageFiles)),
|
||||
},
|
||||
},
|
||||
},
|
||||
includeIdxs: []int{fileIdxStartImageFiles},
|
||||
excludeIdxs: []int{fileIdxStartVideoFiles},
|
||||
},
|
||||
// TODO - add more tests for other file filters
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
@@ -572,7 +572,7 @@ func TestFileStore_FindByFingerprint(t *testing.T) {
|
||||
{
|
||||
"by MD5",
|
||||
models.Fingerprint{
|
||||
Type: models.FingerprintTypeMD5,
|
||||
Type: "MD5",
|
||||
Fingerprint: getPrefixedStringValue("file", fileIdxZip, "md5"),
|
||||
},
|
||||
[]models.File{makeFileWithID(fileIdxZip)},
|
||||
@@ -581,7 +581,7 @@ func TestFileStore_FindByFingerprint(t *testing.T) {
|
||||
{
|
||||
"by OSHASH",
|
||||
models.Fingerprint{
|
||||
Type: models.FingerprintTypeOshash,
|
||||
Type: "OSHASH",
|
||||
Fingerprint: getPrefixedStringValue("file", fileIdxZip, "oshash"),
|
||||
},
|
||||
[]models.File{makeFileWithID(fileIdxZip)},
|
||||
@@ -590,7 +590,7 @@ func TestFileStore_FindByFingerprint(t *testing.T) {
|
||||
{
|
||||
"non-existing",
|
||||
models.Fingerprint{
|
||||
Type: models.FingerprintTypeOshash,
|
||||
Type: "OSHASH",
|
||||
Fingerprint: "foo",
|
||||
},
|
||||
nil,
|
||||
|
||||
@@ -20,7 +20,6 @@ const folderIDColumn = "folder_id"
|
||||
|
||||
type folderRow struct {
|
||||
ID models.FolderID `db:"id" goqu:"skipinsert"`
|
||||
Basename string `db:"basename"`
|
||||
Path string `db:"path"`
|
||||
ZipFileID null.Int `db:"zip_file_id"`
|
||||
ParentFolderID null.Int `db:"parent_folder_id"`
|
||||
@@ -31,8 +30,6 @@ type folderRow struct {
|
||||
|
||||
func (r *folderRow) fromFolder(o models.Folder) {
|
||||
r.ID = o.ID
|
||||
// derive basename from path
|
||||
r.Basename = filepath.Base(o.Path)
|
||||
r.Path = o.Path
|
||||
r.ZipFileID = nullIntFromFileIDPtr(o.ZipFileID)
|
||||
r.ParentFolderID = nullIntFromFolderIDPtr(o.ParentFolderID)
|
||||
@@ -325,126 +322,6 @@ func (qb *FolderStore) FindByParentFolderID(ctx context.Context, parentFolderID
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func (qb *FolderStore) GetManyParentFolderIDs(ctx context.Context, folderIDs []models.FolderID) ([][]models.FolderID, error) {
|
||||
table := qb.table()
|
||||
|
||||
// SQL recursive query to get all parent folder IDs for each folder ID
|
||||
/*
|
||||
WITH RECURSIVE parent_folders AS (
|
||||
SELECT id, parent_folder_id
|
||||
FROM folders
|
||||
WHERE id IN (folderIDs)
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT f.id, f.parent_folder_id
|
||||
FROM folders f
|
||||
INNER JOIN parent_folders pf ON f.id = pf.parent_folder_id
|
||||
)
|
||||
SELECT id, parent_folder_id FROM parent_folders;
|
||||
*/
|
||||
const parentFolders = "parent_folders"
|
||||
const parentFolderID = "parent_folder_id"
|
||||
const parentID = "parent_id"
|
||||
const foldersAlias = "f"
|
||||
|
||||
const parentFoldersAlias = "pf"
|
||||
foldersAliasedI := table.As(foldersAlias)
|
||||
parentFoldersI := goqu.T(parentFolders).As(parentFoldersAlias)
|
||||
|
||||
q := dialect.From(parentFolders).Prepared(true).
|
||||
WithRecursive(parentFolders,
|
||||
dialect.From(table).Select(table.Col(idColumn), table.Col(parentFolderID).As(parentID)).
|
||||
Where(table.Col(idColumn).In(folderIDs)).
|
||||
Union(
|
||||
dialect.From(foldersAliasedI).InnerJoin(
|
||||
parentFoldersI,
|
||||
goqu.On(foldersAliasedI.Col(idColumn).Eq(parentFoldersI.Col(parentID))),
|
||||
).Select(foldersAliasedI.Col(idColumn), foldersAliasedI.Col(parentFolderID).As(parentID)),
|
||||
),
|
||||
).Select(idColumn, parentID)
|
||||
|
||||
type resultRow struct {
|
||||
FolderID models.FolderID `db:"id"`
|
||||
ParentFolderID null.Int `db:"parent_id"`
|
||||
}
|
||||
|
||||
folderMap := make(map[models.FolderID]models.FolderID)
|
||||
|
||||
if err := queryFunc(ctx, q, false, func(r *sqlx.Rows) error {
|
||||
var row resultRow
|
||||
if err := r.StructScan(&row); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if row.ParentFolderID.Valid {
|
||||
folderMap[row.FolderID] = models.FolderID(row.ParentFolderID.Int64)
|
||||
} else {
|
||||
folderMap[row.FolderID] = 0
|
||||
}
|
||||
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ret := make([][]models.FolderID, len(folderIDs))
|
||||
|
||||
for i, folderID := range folderIDs {
|
||||
var parents []models.FolderID
|
||||
currentID := folderID
|
||||
|
||||
for {
|
||||
parentID, exists := folderMap[currentID]
|
||||
if !exists || parentID == 0 {
|
||||
break
|
||||
}
|
||||
parents = append(parents, parentID)
|
||||
currentID = parentID
|
||||
}
|
||||
|
||||
ret[i] = parents
|
||||
}
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func (qb *FolderStore) GetManySubFolderIDs(ctx context.Context, parentFolderIDs []models.FolderID) ([][]models.FolderID, error) {
|
||||
table := qb.table()
|
||||
q := dialect.From(table).Select(
|
||||
table.Col(idColumn),
|
||||
table.Col("parent_folder_id"),
|
||||
).Where(qb.table().Col("parent_folder_id").In(parentFolderIDs))
|
||||
|
||||
sql, args, err := q.ToSQL()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("building query: %w", err)
|
||||
}
|
||||
|
||||
var results []struct {
|
||||
FolderID int `db:"id"`
|
||||
ParentFolderID models.FolderID `db:"parent_folder_id"`
|
||||
}
|
||||
|
||||
if err := querySelect(ctx, sql, args, &results); err != nil {
|
||||
return nil, fmt.Errorf("getting folders by parent folder ids %v: %w", parentFolderIDs, err)
|
||||
}
|
||||
|
||||
retMap := make(map[models.FolderID][]models.FolderID)
|
||||
|
||||
for _, v := range results {
|
||||
retMap[v.ParentFolderID] = append(retMap[v.ParentFolderID], models.FolderID(v.FolderID))
|
||||
}
|
||||
|
||||
ret := make([][]models.FolderID, len(parentFolderIDs))
|
||||
|
||||
for i, parentID := range parentFolderIDs {
|
||||
ret[i] = retMap[parentID]
|
||||
}
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func (qb *FolderStore) allInPaths(q *goqu.SelectDataset, p []string) *goqu.SelectDataset {
|
||||
table := qb.table()
|
||||
|
||||
@@ -463,14 +340,10 @@ func (qb *FolderStore) allInPaths(q *goqu.SelectDataset, p []string) *goqu.Selec
|
||||
// FindAllInPaths returns the all folders that are or are within any of the given paths.
|
||||
// Returns all if limit is < 0.
|
||||
// Returns all folders if p is empty.
|
||||
func (qb *FolderStore) FindAllInPaths(ctx context.Context, p []string, includeZipContents bool, limit, offset int) ([]*models.Folder, error) {
|
||||
func (qb *FolderStore) FindAllInPaths(ctx context.Context, p []string, limit, offset int) ([]*models.Folder, error) {
|
||||
q := qb.selectDataset().Prepared(true)
|
||||
q = qb.allInPaths(q, p)
|
||||
|
||||
if !includeZipContents {
|
||||
q = q.Where(qb.table().Col("zip_file_id").IsNull())
|
||||
}
|
||||
|
||||
if limit > -1 {
|
||||
q = q.Limit(uint(limit))
|
||||
}
|
||||
@@ -640,7 +513,7 @@ func (qb *FolderStore) queryGroupedFields(ctx context.Context, options models.Fo
|
||||
Megapixels float64
|
||||
Size int64
|
||||
}{}
|
||||
if err := qb.repository.queryStruct(ctx, aggregateQuery.toSQL(includeSortPagination), query.allArgs(), &out); err != nil {
|
||||
if err := qb.repository.queryStruct(ctx, aggregateQuery.toSQL(includeSortPagination), query.args, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -654,7 +527,6 @@ var folderSortOptions = sortOptions{
|
||||
"created_at",
|
||||
"id",
|
||||
"path",
|
||||
"basename",
|
||||
"random",
|
||||
"updated_at",
|
||||
}
|
||||
|
||||
@@ -65,7 +65,6 @@ func (qb *folderFilterHandler) criterionHandler() criterionHandler {
|
||||
folderFilter := qb.folderFilter
|
||||
return compoundHandler{
|
||||
stringCriterionHandler(folderFilter.Path, qb.table.Col("path")),
|
||||
stringCriterionHandler(folderFilter.Basename, qb.table.Col("basename")),
|
||||
×tampCriterionHandler{folderFilter.ModTime, qb.table.Col("mod_time"), nil},
|
||||
|
||||
qb.parentFolderCriterionHandler(folderFilter.ParentFolder),
|
||||
|
||||
@@ -33,17 +33,6 @@ func TestFolderQuery(t *testing.T) {
|
||||
includeIdxs: []int{folderIdxWithSubFolder, folderIdxWithParentFolder},
|
||||
excludeIdxs: []int{folderIdxInZip},
|
||||
},
|
||||
{
|
||||
name: "basename",
|
||||
filter: &models.FolderFilterType{
|
||||
Basename: &models.StringCriterionInput{
|
||||
Value: getFolderBasename(folderIdxWithParentFolder, nil),
|
||||
Modifier: models.CriterionModifierIncludes,
|
||||
},
|
||||
},
|
||||
includeIdxs: []int{folderIdxWithParentFolder},
|
||||
excludeIdxs: []int{folderIdxInZip},
|
||||
},
|
||||
{
|
||||
name: "parent folder",
|
||||
filter: &models.FolderFilterType{
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user